Files
ARTEX/selfupdate/bootstrap.go
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

314 lines
11 KiB
Go

package selfupdate
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"time"
)
// smokeEnv lets the child process launched by the smoke test skip Bootstrap entirely.
//
// Strictly speaking it would be fine without it: the child's os.Executable() is
// artex.new, so every derived path carries the .new infix and can never touch the
// real upgrade files. But relying on that coincidence is too fragile — an explicit
// short-circuit is obvious at a glance and saves the child a pointless disk probe.
const smokeEnv = "ARTEX_SELFUPDATE_SMOKE"
// Action is Bootstrap's instruction to main.
type Action int
const (
// Continue: start the server as usual.
Continue Action = iota
// Restart: exit immediately with ExitRestart so the supervisor script relaunches us.
Restart
)
// State describes the upgrade state at this startup, so /api/update/check can
// faithfully tell the frontend "did the last upgrade succeed or was it rolled back".
type State struct {
Pending bool // swapped but not yet confirmed stable
RolledBack bool // this startup just performed an automatic rollback
FailedStage bool // staged file failed verification/smoke test and was discarded
Detail string // one-sentence user-facing explanation
}
// Bootstrap runs at the very beginning of main, and must be called before any port
// is listened on or any database is opened.
//
// Three situations:
//
// ① staged file artex.new exists → verify + smoke test; on success swap and ask
// for a restart; on failure discard it and keep
// running the old version
// ② only the marker file remains → the swap just happened; count one attempt;
// roll back after enough consecutive failures
// ③ nothing → normal startup
func Bootstrap() (Action, State) {
if os.Getenv(smokeEnv) != "" {
return Continue, State{}
}
p, err := ResolvePaths()
if err != nil {
log.Printf("[update] skipping bootstrap: %v", err)
return Continue, State{}
}
if _, err := os.Stat(p.New); err == nil {
return applyStaged(p)
}
m, ok := readMarker(p.Marker)
if !ok {
return Continue, State{}
}
return confirmOrRollback(p, m)
}
// applyStaged handles the "staged file exists" situation: swap if it verifies,
// discard it if not.
//
// This is the only place in the whole upgrade chain that overwrites the executable,
// and the final gate — the smoke test stops problems like a corrupted download,
// a wrong architecture choice, or missing dynamic libraries. Once a binary that
// cannot run is let through, the supervisor script will tirelessly keep relaunching
// it while the Go code never gets a chance to run, making automatic rollback impossible.
func applyStaged(p Paths) (Action, State) {
m, _ := readMarker(p.Marker)
if err := verifyStaged(p); err != nil {
log.Printf("[update] staged new version failed verification, discarded, continuing with the current version: %v", err)
cleanStaged(p)
_ = os.Remove(p.Marker)
return Continue, State{FailedStage: true, Detail: "new version failed verification and was discarded: " + err.Error()}
}
if err := swap(p); err != nil {
log.Printf("[update] swap failed, continuing with the current version: %v", err)
cleanStaged(p)
_ = os.Remove(p.Marker)
return Continue, State{FailedStage: true, Detail: "swap failed: " + err.Error()}
}
// Swap succeeded. Keep the marker; the next startup (running the new version) confirms whether it is stable.
m.Attempts = 0
if m.StagedAt == 0 {
m.StagedAt = time.Now().Unix()
}
if err := writeMarker(p.Marker, m); err != nil {
log.Printf("[update] failed to write upgrade marker (automatic rollback capability lost): %v", err)
}
log.Printf("[update] swapped to %s, exiting to restart (exit %d)", orUnknown(m.To), ExitRestart)
return Restart, State{Pending: true}
}
// confirmOrRollback handles "the startup after a swap": counts attempts, and swaps
// the old version back once the limit is exceeded.
//
// The counter only increments after the Go code is running, so it covers failures
// of the "executable but crashes during initialization" kind (incompatible config,
// port occupied, DB migration blew up); "cannot exec at all" is stopped by the
// smoke test before the swap — together they are complete.
func confirmOrRollback(p Paths, m marker) (Action, State) {
m.Attempts++
if m.Attempts > maxAttempts {
if err := rollback(p); err != nil {
// If even the rollback fails, do not restart again, or we fall into an
// infinite restart loop. Remove the marker and let the process start
// as-is — if it cannot come up, the user can at least see why in the logs.
log.Printf("[update] new version failed to start %d times in a row, and rollback failed too: %v", maxAttempts, err)
_ = os.Remove(p.Marker)
return Continue, State{Detail: "new version failed to start and rollback failed: " + err.Error()}
}
log.Printf("[update] new version failed to start %d times in a row, rolled back to %s, exiting to restart (exit %d)",
maxAttempts, orUnknown(m.From), ExitRestart)
_ = os.Remove(p.Marker)
return Restart, State{RolledBack: true, Detail: fmt.Sprintf("new version failed to start, rolled back to %s", orUnknown(m.From))}
}
if err := writeMarker(p.Marker, m); err != nil {
log.Printf("[update] failed to update upgrade marker: %v", err)
}
log.Printf("[update] new version starting (attempt %d/%d); the upgrade will be confirmed once it runs stably",
m.Attempts, maxAttempts)
return Continue, State{Pending: true}
}
// Settle confirms the new version has been running stably and clears the upgrade marker.
//
// Called by main with a delay after the HTTP listener is up: only surviving that
// period counts; otherwise the marker stays in place and the next startup keeps
// counting attempts until a rollback is triggered.
func Settle() {
p, err := ResolvePaths()
if err != nil {
return
}
settle(p)
}
func settle(p Paths) {
if _, ok := readMarker(p.Marker); !ok {
return // not a post-upgrade startup, nothing to do
}
if err := os.Remove(p.Marker); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("[update] failed to clear upgrade marker: %v", err)
return
}
log.Printf("[update] new version is running stably, upgrade complete (previous version kept as %s)", p.Old)
}
// SettleDelay is the runtime duration required to decide "the new version survived".
const SettleDelay = 30 * time.Second
// verifyStaged verifies the staged file: compare SHA256 first, then actually launch it once.
func verifyStaged(p Paths) error {
want, err := os.ReadFile(p.Sum)
if err != nil {
return fmt.Errorf("failed to read checksum: %w", err)
}
got, err := fileSHA256(p.New)
if err != nil {
return fmt.Errorf("failed to compute checksum: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(string(want)), got) {
return errors.New("SHA256 mismatch (download corrupted or tampered with)")
}
return smokeTest(p.New)
}
// smokeTest launches the new binary with -h to confirm it can really execute on
// the current system. This stops a whole class of problems: truncated downloads,
// wrong architecture (exec format error), missing dependencies, and so on.
func smokeTest(bin string) error {
if err := os.Chmod(bin, 0o755); err != nil {
return fmt.Errorf("failed to grant execute permission: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-h")
cmd.Env = append(os.Environ(), smokeEnv+"=1")
out, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return errors.New("smoke test timed out (new binary unresponsive)")
}
if err != nil {
snippet := strings.TrimSpace(string(out))
if len(snippet) > 300 {
snippet = snippet[:300] + "…"
}
return fmt.Errorf("smoke test failed: %v: %s", err, snippet)
}
return nil
}
// swap replaces the current binary with the staged new version.
//
// Both Unix and Windows allow renaming a running executable (what Windows forbids
// is deleting and overwriting; rename is not on that list), so there is no need to
// split by platform here, nor to stop ourselves first.
func swap(p Paths) error {
// Windows rename does not overwrite an existing target, so the .old left by the previous upgrade must be removed first.
if err := os.Remove(p.Old); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to clean up old backup %s: %w", p.Old, err)
}
if err := os.Rename(p.Current, p.Old); err != nil {
return fmt.Errorf("failed to back up the current version: %w", err)
}
if err := os.Rename(p.New, p.Current); err != nil {
// The swap failed but the current version has already been moved away; it must be put back as-is, or the next startup has no executable.
if rerr := os.Rename(p.Old, p.Current); rerr != nil {
return fmt.Errorf("failed to install the new version (%v), and restoring the current version failed too: %w", err, rerr)
}
return fmt.Errorf("failed to install the new version: %w", err)
}
_ = os.Remove(p.Sum)
return nil
}
// rollback swaps back the old version that swap backed up.
func rollback(p Paths) error {
if _, err := os.Stat(p.Old); err != nil {
return fmt.Errorf("no backup to roll back to %s: %w", p.Old, err)
}
// Move the unstartable new version to .failed for troubleshooting instead of deleting it outright.
failed := p.Current + ".failed"
_ = os.Remove(failed)
if err := os.Rename(p.Current, failed); err != nil {
return fmt.Errorf("failed to move the failing version away: %w", err)
}
if err := os.Rename(p.Old, p.Current); err != nil {
return fmt.Errorf("failed to restore the old version: %w", err)
}
return nil
}
// Rollback implements /api/update/rollback: actively return to the previous version.
// It only does the swap; the restart is likewise left to the supervisor script (the caller then exits with ExitRestart).
func Rollback() error {
p, err := ResolvePaths()
if err != nil {
return err
}
if _, err := os.Stat(p.Old); err != nil {
return errors.New("no previous version to roll back to (" + p.Old + " does not exist)")
}
cleanStaged(p)
if err := smokeTest(p.Old); err != nil {
return fmt.Errorf("the previous version cannot execute, refusing to roll back: %w", err)
}
// Swap current and backup: after the rollback you can still roll forward again.
tmp := p.Current + ".swap"
_ = os.Remove(tmp)
if err := os.Rename(p.Current, tmp); err != nil {
return fmt.Errorf("failed to move the current version away: %w", err)
}
if err := os.Rename(p.Old, p.Current); err != nil {
_ = os.Rename(tmp, p.Current)
return fmt.Errorf("failed to install the previous version: %w", err)
}
if err := os.Rename(tmp, p.Old); err != nil {
log.Printf("[update] failed to tidy up the backup after rollback (does not affect operation): %v", err)
}
_ = os.Remove(p.Marker)
return nil
}
// HasBackup reports whether a previous version exists to roll back to, so the frontend can decide whether to show the rollback button.
func HasBackup() bool {
p, err := ResolvePaths()
if err != nil {
return false
}
_, err = os.Stat(p.Old)
return err == nil
}
func fileSHA256(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
}
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "unknown version"
}
return s
}