23dcfb393f
Implements the design in claude.md as a building skeleton: pure Go, no cgo, cross-compiles to a single Windows .exe from Linux. Architecture follows the spec's deliberate split. The fast path is native — tray icon, grouped submenus, a Win32 MessageBox showing a live before/after diff, then netsh. The slow path is an embedded web editor served on a random loopback port and opened in the default browser. Two decisions worth recording: Reads use GetAdaptersAddresses, writes use netsh. The spec left the enumeration mechanism open; parsing `netsh show config` breaks on a non-English Windows because the output is localised. DNS static-vs-DHCP origin is not exposed by that API, so it comes from one registry read. The netsh command plan is built in portable code. That puts the delete-every- existing-address step — the one that stops secondary addresses leaking across switches — under test without needing a Windows box. The editor requires the session token in a header for mutations, not just the cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie attached, but it cannot set a header. The updater refuses to install a release that publishes no SHA256. Not yet done: no group picker for export (the API supports it), no single-instance guard, and internal/server/web/app.css is reconstructed from the description in claude.md rather than the canonical apointless.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
7.5 KiB
Go
267 lines
7.5 KiB
Go
// Package updater checks a Gitea repository for a newer release and, on
|
|
// request, downloads and verifies the new binary.
|
|
//
|
|
// Two rules shape the whole package: it never installs on its own, and it never
|
|
// blocks startup. The check runs in a goroutine behind a short timeout and
|
|
// fails silently into the log, because a laptop on a customer site frequently
|
|
// has no route to the Gitea host and that must not be a visible error.
|
|
package updater
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// checkTimeout bounds the startup release check.
|
|
const checkTimeout = 8 * time.Second
|
|
|
|
// maxAssetBytes caps a download. The binary is a few MB; anything near this is
|
|
// a wrong URL or a captive portal serving a login page.
|
|
const maxAssetBytes = 128 << 20
|
|
|
|
// Release is the subset of Gitea's release JSON that matters here.
|
|
type Release struct {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
Body string `json:"body"`
|
|
Assets []Asset `json:"assets"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
|
|
// Asset is one file attached to a release.
|
|
type Asset struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
}
|
|
|
|
// Checker polls one repository.
|
|
type Checker struct {
|
|
// Repo is the repository as "https://gitea.example.com/owner/repo".
|
|
Repo string
|
|
// Current is the compiled-in version, from -X main.version.
|
|
Current string
|
|
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// Update describes an available newer release.
|
|
type Update struct {
|
|
Version string
|
|
Release Release
|
|
Asset Asset
|
|
// SHA256 is the expected checksum, empty if the release published none.
|
|
SHA256 string
|
|
}
|
|
|
|
// apiURL turns a repo browse URL into its releases/latest API endpoint.
|
|
func apiURL(repo string) (string, error) {
|
|
repo = strings.TrimSuffix(strings.TrimSpace(repo), "/")
|
|
if repo == "" {
|
|
return "", fmt.Errorf("no update repository configured")
|
|
}
|
|
u, err := url.Parse(repo)
|
|
if err != nil {
|
|
return "", fmt.Errorf("update repo %q is not a URL: %w", repo, err)
|
|
}
|
|
if u.Scheme != "https" && u.Scheme != "http" {
|
|
return "", fmt.Errorf("update repo %q must be an http(s) URL", repo)
|
|
}
|
|
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", fmt.Errorf("update repo %q should look like https://host/owner/repo", repo)
|
|
}
|
|
|
|
u.Path = path.Join("/api/v1/repos", parts[0], parts[1], "releases/latest")
|
|
return u.String(), nil
|
|
}
|
|
|
|
func (c *Checker) client() *http.Client {
|
|
if c.HTTP != nil {
|
|
return c.HTTP
|
|
}
|
|
return &http.Client{Timeout: checkTimeout}
|
|
}
|
|
|
|
// Check asks for the latest release and returns an Update if it is newer than
|
|
// the running build. A nil Update with a nil error means "up to date".
|
|
func (c *Checker) Check(ctx context.Context) (*Update, error) {
|
|
endpoint, err := apiURL(c.Repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.client().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("contacting %s: %w", endpoint, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%s returned %s", endpoint, resp.Status)
|
|
}
|
|
|
|
var rel Release
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&rel); err != nil {
|
|
return nil, fmt.Errorf("parsing the release response: %w", err)
|
|
}
|
|
|
|
if !IsNewer(rel.TagName, c.Current) {
|
|
return nil, nil
|
|
}
|
|
|
|
asset, ok := pickExe(rel.Assets)
|
|
if !ok {
|
|
return nil, fmt.Errorf("release %s has no .exe asset", rel.TagName)
|
|
}
|
|
|
|
return &Update{
|
|
Version: strings.TrimPrefix(rel.TagName, "v"),
|
|
Release: rel,
|
|
Asset: asset,
|
|
SHA256: findChecksum(rel, asset.Name),
|
|
}, nil
|
|
}
|
|
|
|
func pickExe(assets []Asset) (Asset, bool) {
|
|
for _, a := range assets {
|
|
if strings.HasSuffix(strings.ToLower(a.Name), ".exe") {
|
|
return a, true
|
|
}
|
|
}
|
|
return Asset{}, false
|
|
}
|
|
|
|
var sha256Re = regexp.MustCompile(`\b([a-fA-F0-9]{64})\b`)
|
|
|
|
// findChecksum digs the expected SHA256 out of the release body. The body is
|
|
// scanned for a line naming the asset; a bare 64-hex string anywhere is
|
|
// accepted as a fallback for a release that publishes only the one checksum.
|
|
//
|
|
// A SHA256SUMS sibling asset is handled by the caller, which can fetch it — it
|
|
// is not available from the release JSON alone.
|
|
func findChecksum(rel Release, assetName string) string {
|
|
for _, line := range strings.Split(rel.Body, "\n") {
|
|
if strings.Contains(line, assetName) {
|
|
if m := sha256Re.FindStringSubmatch(line); m != nil {
|
|
return strings.ToLower(m[1])
|
|
}
|
|
}
|
|
}
|
|
if m := sha256Re.FindStringSubmatch(rel.Body); m != nil {
|
|
return strings.ToLower(m[1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SumsAsset finds a SHA256SUMS-style asset in the release, if there is one.
|
|
func (u *Update) SumsAsset() (Asset, bool) {
|
|
for _, a := range u.Release.Assets {
|
|
n := strings.ToUpper(a.Name)
|
|
if strings.Contains(n, "SHA256") {
|
|
return a, true
|
|
}
|
|
}
|
|
return Asset{}, false
|
|
}
|
|
|
|
// Download fetches the update's .exe into dir and verifies its SHA256 before
|
|
// returning the path. A release with no published checksum is refused: an
|
|
// unverified binary that we are about to swap in and run is not worth the
|
|
// convenience.
|
|
func (c *Checker) Download(ctx context.Context, u *Update, dir string) (string, error) {
|
|
want := u.SHA256
|
|
if want == "" {
|
|
// Try the sibling SHA256SUMS asset before giving up.
|
|
if sums, ok := u.SumsAsset(); ok {
|
|
body, err := c.fetch(ctx, sums.BrowserDownloadURL, 1<<20)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetching %s: %w", sums.Name, err)
|
|
}
|
|
want = checksumFor(string(body), u.Asset.Name)
|
|
}
|
|
}
|
|
if want == "" {
|
|
return "", fmt.Errorf("release %s publishes no SHA256 for %s; refusing to install an unverified binary", u.Release.TagName, u.Asset.Name)
|
|
}
|
|
|
|
body, err := c.fetch(ctx, u.Asset.BrowserDownloadURL, maxAssetBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("downloading %s: %w", u.Asset.Name, err)
|
|
}
|
|
|
|
sum := sha256.Sum256(body)
|
|
got := hex.EncodeToString(sum[:])
|
|
if !strings.EqualFold(got, want) {
|
|
return "", fmt.Errorf("checksum mismatch for %s:\n expected %s\n got %s", u.Asset.Name, want, got)
|
|
}
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
dest := filepath.Join(dir, u.Asset.Name)
|
|
if err := os.WriteFile(dest, body, 0o755); err != nil {
|
|
return "", fmt.Errorf("writing %s: %w", dest, err)
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
func (c *Checker) fetch(ctx context.Context, rawURL string, limit int64) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.client().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("server returned %s", resp.Status)
|
|
}
|
|
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
|
}
|
|
|
|
// checksumFor pulls one file's hash out of a `sha256sum` style listing.
|
|
func checksumFor(sums, name string) string {
|
|
for _, line := range strings.Split(sums, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
// The name may carry sha256sum's binary-mode "*" prefix.
|
|
if strings.TrimPrefix(fields[len(fields)-1], "*") == name {
|
|
if sha256Re.MatchString(fields[0]) {
|
|
return strings.ToLower(fields[0])
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|