Files
ARTEX/selfupdate/selfupdate.go
T
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

176 lines
6.9 KiB
Go

// Package selfupdate implements ARTEX's one-click update from the web UI: it pulls
// the new binary from a GitHub Release, verifies and stages it, and swaps it in atomically on the next start.
//
// Overall division of labor (see start.sh / start.bat):
//
// Start script = a dumb supervision loop, responsible only for "deciding whether to relaunch the process after it exits, based on its exit code"
// This package = all the error-prone logic (download / SHA256 verify / smoke test / swap / rollback on failure)
//
// The swap lives in Go rather than the scripts because sha256 verification and the smoke test would need two implementations in sh and bat,
// (sha256sum / shasum / certutil), and this is precisely the step that must never fail — swap in a binary that cannot run,
// and the supervisor will faithfully relaunch it forever, leaving the user to rescue things on the machine by hand.
//
// A full upgrade goes through three process starts:
//
// (1) the old server receives /api/update/apply → download and verify → stage artex.new → exit 75
// (2) the script relaunches the old version → Bootstrap spots artex.new → verify + smoke test → swap → exit 75
// (3) the script relaunches again, now the new version → Bootstrap records one attempt → clears the marker after a successful start
//
// Any failed step falls back to the old version: in (2) a failed verification deletes the staged file and keeps the old version running; in (3) three
// consecutive crashes before the marker is cleared mean the new version cannot start, so artex.old is swapped back automatically.
package selfupdate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// ExitRestart is the "please relaunch me" exit code (EX_TEMPFAIL). When the start script sees it,
// it reruns immediately without counting toward crash backoff. 0 means the user stopped it normally (the script exits the loop); anything else is treated as a crash.
const ExitRestart = 75
// maxAttempts is the number of start attempts allowed after the swap. Every start of the new version increments the counter; surviving
// settleDelay clears the marker; maxAttempts consecutive crashes mean the new version simply cannot start, so it rolls back automatically.
const maxAttempts = 3
// Paths lists every file an upgrade touches, all anchored under **the executable's directory**.
// CWD is deliberately avoided: when running as a service the working directory may be / or any path, and using CWD would drop the staged files
// somewhere else, breaking the swap logic outright.
type Paths struct {
Dir string // directory of the executable
Current string // currently running binary artex / artex.exe
New string // staged new version artex.new / artex.new.exe
Sum string // new version sha256 (hex) artex.new.sha256 / artex.new.exe.sha256
Old string // pre-swap backup of old version artex.old / artex.old.exe
Marker string // upgrade state marker artex.upgrade.json
}
// ResolvePaths derives all upgrade paths from the current executable.
//
// On Windows .new/.old must also carry the .exe suffix, otherwise both the smoke test and post-swap execution fail,
// so the suffix is stripped off first and re-appended, keeping the naming symmetric across the two platforms.
func ResolvePaths() (Paths, error) {
exe, err := os.Executable()
if err != nil {
return Paths{}, fmt.Errorf("locate executable: %w", err)
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
dir := filepath.Dir(exe)
name := filepath.Base(exe)
ext := filepath.Ext(name) // ".exe" on Windows, usually empty on Unix
stem := strings.TrimSuffix(name, ext)
join := func(suffix string) string { return filepath.Join(dir, stem+suffix+ext) }
return Paths{
Dir: dir,
Current: exe,
New: join(".new"),
Sum: join(".new") + ".sha256",
Old: join(".old"),
Marker: filepath.Join(dir, stem+".upgrade.json"),
}, nil
}
// marker records the progress of one swap and triggers automatic rollback when the new version cannot start.
type marker struct {
From string `json:"from"` // version before the upgrade
To string `json:"to"` // target version
Attempts int `json:"attempts"` // start attempts made after the swap
StagedAt int64 `json:"staged_at"`
}
func readMarker(path string) (marker, bool) {
b, err := os.ReadFile(path)
if err != nil {
return marker{}, false
}
var m marker
if json.Unmarshal(b, &m) != nil {
return marker{}, false
}
return m, true
}
func writeMarker(path string, m marker) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// cleanStaged removes the staged files. Successful swap, failed verification, and user cancellation all go through it, so a leftover
// artex.new is not retried on the next start.
func cleanStaged(p Paths) {
_ = os.Remove(p.New)
_ = os.Remove(p.Sum)
}
// CompareVersions compares two version numbers and returns -1/0/1 (a<b / a==b / a>b).
// ok=false means at least one side is not a comparable version number (such as a local dev build's "dev" or
// a git describe output like "0.3.7-2-gabc1234-dirty"); the caller must disable one-click update then,
// otherwise a dev build would be "upgraded" to a release, overwriting uncommitted changes.
func CompareVersions(a, b string) (int, bool) {
av, aok := parseVersion(a)
bv, bok := parseVersion(b)
if !aok || !bok {
return 0, false
}
for i := range 3 {
if av[i] != bv[i] {
if av[i] < bv[i] {
return -1, true
}
return 1, true
}
}
return 0, true
}
// parseVersion parses a "v0.3.7" / "0.3.7" style version number into [3]int.
//
// Only a clean three-part form is accepted: for non-tag builds build.sh produces suffixed
// versions via git describe like "0.3.7-2-gabc1234"; those must be judged non-comparable rather than treated as
// 0.3.7 — otherwise a dev build gets misjudged as "already latest" or overwritten by a release.
func parseVersion(s string) ([3]int, bool) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
if s == "" {
return [3]int{}, false
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
// InDocker reports whether the process runs inside a container. Under Docker the swap writes to the container's writable layer,
// and `docker compose up -d` recreating the container reverts to the version bundled in the image — that is expected behavior
// (the user is pulling a new image at that point anyway), but the frontend needs to be able to explain it clearly.
func InDocker() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
b, err := os.ReadFile("/proc/1/cgroup")
if err != nil {
return false
}
s := string(b)
return strings.Contains(s, "docker") || strings.Contains(s, "containerd")
}