2026-06-22 17:51:19 +02:00
package main
import (
2026-06-22 20:03:27 +02:00
"crypto/sha256"
"encoding/hex"
2026-06-22 17:51:19 +02:00
"encoding/json"
2026-06-22 18:24:59 +02:00
"flag"
2026-06-22 17:51:19 +02:00
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
2026-06-22 18:24:59 +02:00
"nadir"
2026-06-22 17:51:19 +02:00
"nadir/internal/config"
2026-06-22 20:03:27 +02:00
"github.com/jedisct1/go-minisign"
2026-06-22 17:51:19 +02:00
)
// updateCmd implements `nadir update`: hit the configured Gitea repo's
2026-06-22 20:03:27 +02:00
// releases/latest, pick the asset for the host's GOARCH, verify the release
// signature (minisign) and checksum (SHA-256), atomically replace
2026-06-22 17:51:19 +02:00
// /usr/local/bin/nadir (or wherever the running binary lives), and restart
// the systemd unit so the new code takes effect.
2026-06-22 18:24:59 +02:00
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 )
2026-06-22 17:51:19 +02:00
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
}
2026-06-22 18:24:59 +02:00
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
}
2026-06-22 20:03:27 +02:00
// Locate the binary asset and the two verification assets in the release.
2026-06-22 17:51:19 +02:00
var assetURL , assetName string
2026-06-22 20:03:27 +02:00
var sumsURL , sigURL string
2026-06-22 17:51:19 +02:00
for _ , a := range rel . Assets {
2026-06-22 20:03:27 +02:00
switch {
case strings . HasSuffix ( a . Name , suffix ):
2026-06-22 17:51:19 +02:00
assetURL , assetName = a . URL , a . Name
2026-06-22 20:03:27 +02:00
case a . Name == "sha256sums.txt" :
sumsURL = a . URL
case a . Name == "sha256sums.txt.minisig" :
sigURL = a . URL
2026-06-22 17:51:19 +02:00
}
}
if assetURL == "" {
return fmt . Errorf ( "no %s asset in release %s" , suffix , rel . TagName )
}
2026-06-22 20:03:27 +02:00
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 )
}
2026-06-22 17:51:19 +02:00
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
}
2026-06-22 20:03:27 +02:00
// 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." )
2026-06-22 17:51:19 +02:00
// 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
}
2026-06-22 20:03:27 +02:00
// 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 ))
}
2026-06-22 17:51:19 +02:00
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 )
}
2026-06-22 20:03:27 +02:00
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 )
}
2026-06-22 17:51:19 +02:00
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
}