299 lines
8.9 KiB
Go
299 lines
8.9 KiB
Go
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: "<hex> <filename>" (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
|
|
}
|