package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "net/url" "os" "os/exec" "runtime" "strings" "time" "nadir" "nadir/internal/config" "github.com/jedisct1/go-minisign" ) // updateCmd implements `nadir update`: hit the configured Gitea repo's // releases/latest, pick the asset for the host's GOARCH, verify the release // signature (minisign) and checksum (SHA-256), atomically replace // /usr/local/bin/nadir (or wherever the running binary lives), and restart // the systemd unit so the new code takes effect. func updateCmd(args []string) error { fs := flag.NewFlagSet("update", flag.ExitOnError) check := fs.Bool("check", false, "report the latest version without downloading") force := fs.Bool("force", false, "re-download even when already at the latest version") fs.Parse(args) configPath, err := resolveConfigPath() if err != nil { return err } cfg, err := config.Load(configPath) if err != nil { return err } if cfg.Server.ReleaseRepo == "" { return fmt.Errorf("server.release_repo not set in %s", configPath) } apiURL, err := releaseAPIURL(cfg.Server.ReleaseRepo) if err != nil { return err } suffix := "linux-" + runtime.GOARCH fmt.Printf("querying %s ...\n", apiURL) rel, err := fetchLatestRelease(apiURL) if err != nil { return err } fmt.Printf("current: %s\nlatest: %s\n", nadir.Version, rel.TagName) upToDate := nadir.Version == rel.TagName switch { case *check: if upToDate { fmt.Println("already up to date.") } else { fmt.Println("update available; run `nadir update` to install.") } return nil case upToDate && !*force: fmt.Println("already up to date; pass --force to re-download.") return nil } // Locate the binary asset and the two verification assets in the release. var assetURL, assetName string var sumsURL, sigURL string for _, a := range rel.Assets { switch { case strings.HasSuffix(a.Name, suffix): assetURL, assetName = a.URL, a.Name case a.Name == "sha256sums.txt": sumsURL = a.URL case a.Name == "sha256sums.txt.minisig": sigURL = a.URL } } if assetURL == "" { return fmt.Errorf("no %s asset in release %s", suffix, rel.TagName) } if sumsURL == "" || sigURL == "" { return fmt.Errorf("release %s is missing sha256sums.txt or sha256sums.txt.minisig — cannot verify; refusing to install an unverified binary", rel.TagName) } exe, err := os.Executable() if err != nil { return err } fmt.Printf("downloading %s (%s) ...\n", assetName, rel.TagName) tmp := exe + ".tmp" if err := download(assetURL, tmp); err != nil { os.Remove(tmp) return err } // Verify: download checksums + signature, check minisign, check SHA-256. fmt.Println("verifying release signature ...") if err := verifyRelease(tmp, assetName, sumsURL, sigURL); err != nil { os.Remove(tmp) return fmt.Errorf("verification failed: %w", err) } fmt.Println("signature and checksum OK.") // Atomic on the same filesystem; replaces the on-disk file without // disturbing the still-running process (its inode stays alive). if err := os.Rename(tmp, exe); err != nil { os.Remove(tmp) return err } fmt.Printf("installed %s at %s\n", rel.TagName, exe) fmt.Println("restarting service ...") if err := exec.Command("systemctl", "restart", serviceName).Run(); err != nil { fmt.Fprintf(os.Stderr, "warning: could not restart %s service (%v); restart it manually to pick up the new binary\n", serviceName, err) } return nil } // verifyRelease downloads sha256sums.txt + its minisig from the release, // verifies the signature against the embedded public key, then checks the // downloaded binary's SHA-256 against the matching line in the checksums file. func verifyRelease(binaryPath, assetName, sumsURL, sigURL string) error { // Parse the embedded public key. The placeholder file will fail here // (no valid base64 line), which is the desired behavior: updates are // disabled until a real key is committed. pubKey, err := minisign.DecodePublicKey(nadir.ReleasePublicKey) if err != nil { return fmt.Errorf("embedded minisign public key is invalid (is minisign.pub still the placeholder?): %w", err) } // Download the checksums file and its signature. sumsBody, err := downloadBytes(sumsURL) if err != nil { return fmt.Errorf("download sha256sums.txt: %w", err) } sigBody, err := downloadBytes(sigURL) if err != nil { return fmt.Errorf("download sha256sums.txt.minisig: %w", err) } // Verify the minisign signature on the checksums file. sig, err := minisign.DecodeSignature(string(sigBody)) if err != nil { return fmt.Errorf("decode minisig: %w", err) } ok, err := pubKey.Verify(sumsBody, sig) if err != nil { return fmt.Errorf("minisign verify: %w", err) } if !ok { return fmt.Errorf("minisign signature is not valid for this sha256sums.txt") } // Find the expected hash for our binary in the signed checksums file. expectedHash, err := findChecksum(string(sumsBody), assetName) if err != nil { return err } // Hash the downloaded binary and compare. actualHash, err := sha256File(binaryPath) if err != nil { return fmt.Errorf("hash downloaded binary: %w", err) } if actualHash != expectedHash { return fmt.Errorf("SHA-256 mismatch for %s: expected %s, got %s", assetName, expectedHash, actualHash) } return nil } // findChecksum parses a sha256sums.txt body (one "hash filename\n" per line) // and returns the hex hash for the named asset. func findChecksum(sums, assetName string) (string, error) { for _, line := range strings.Split(sums, "\n") { line = strings.TrimSpace(line) if line == "" { continue } // Standard sha256sum output: " " (two spaces). parts := strings.SplitN(line, " ", 2) if len(parts) != 2 { // Also accept single-space separation. parts = strings.SplitN(line, " ", 2) } if len(parts) == 2 && strings.TrimSpace(parts[1]) == assetName { h := strings.TrimSpace(parts[0]) if len(h) != 64 { return "", fmt.Errorf("sha256sums.txt: invalid hash length for %s: %q", assetName, h) } return strings.ToLower(h), nil } } return "", fmt.Errorf("sha256sums.txt does not contain a hash for %s", assetName) } // sha256File returns the lowercase hex SHA-256 of the file at path. func sha256File(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } // downloadBytes fetches a URL and returns its body as a byte slice (for small // files like sha256sums.txt and .minisig). func downloadBytes(srcURL string) ([]byte, error) { c := &http.Client{Timeout: 30 * time.Second} resp, err := c.Get(srcURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", srcURL, resp.Status) } // Cap read to 1 MB — sha256sums.txt and .minisig are tiny. return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) } type giteaRelease struct { TagName string `json:"tag_name"` Assets []struct { Name string `json:"name"` URL string `json:"browser_download_url"` } `json:"assets"` } func fetchLatestRelease(apiURL string) (*giteaRelease, error) { c := &http.Client{Timeout: 30 * time.Second} resp, err := c.Get(apiURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("releases API returned %s", resp.Status) } var r giteaRelease if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("decode release JSON: %w", err) } return &r, nil } // releaseAPIURL converts https://host/owner/repo (the same URL pasted into // release_repo and shown in a browser) into the Gitea releases/latest API // endpoint: https://host/api/v1/repos/owner/repo/releases/latest. func releaseAPIURL(repoURL string) (string, error) { u, err := url.Parse(repoURL) if err != nil { return "", fmt.Errorf("release_repo: %w", err) } if u.Scheme != "https" { return "", fmt.Errorf("release_repo must use https:// (got %q) — auto-update downloads and executes the binary, plaintext would let any on-path attacker replace it", repoURL) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", fmt.Errorf("release_repo must look like https://host/owner/repo, got %q", repoURL) } return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s/releases/latest", u.Scheme, u.Host, parts[0], parts[1]), nil } func download(srcURL, dst string) error { c := &http.Client{Timeout: 5 * time.Minute} resp, err := c.Get(srcURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download %s: %s", srcURL, resp.Status) } f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) if err != nil { return err } defer f.Close() if _, err := io.Copy(f, resp.Body); err != nil { return err } return nil }