mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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
176 lines
6.9 KiB
Go
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")
|
|
}
|