Files
mesh-host/internal/upgrade/fallback.go
T
jochen fc6c5a92e6
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Believe a hand-over only signed by the mesh, and give it through no link (hq issue 356, review)
Two holes the review proved. The ask's subject proved nothing — a responder can reach it through a reply — so the engine now verifies the mesh's signature over a fixed context with the key it verifies declarations with, before reading anything, and refuses an ask for another node, expired, good too long, without a nonce, or replayed. And the apply followed a link the hand-over had refused: a component swapped for a link after the record carried root's chmod and chown to its target. A handed-over directory must now still resolve to itself, and is changed only through one descriptor opened from / with no link followed (fchmod, fchown). Tests for the reply guard, the run loop's hook, the fallback renewal on proof and a non-regular fallback; 27 checks each removed in turn, each failing a test.
2026-10-09 19:45:00 +02:00

136 lines
4.4 KiB
Go

package upgrade
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// The engine placed by hand, kept current (novox/hq issue 356).
//
// The first engine on a machine is copied there by a person (novox/hq ADR 0141): the launcher runs it
// while nothing is delivered, and it is the one `nox-mesh-host` on any PATH. Every engine after it is
// delivered under VersionsDirName and run from there, so the file on PATH fell behind: on 2026-10-09 the
// operator typed its `hand-over` and was answered "unexpected argument" by a build months older than the
// one running the machine. A command that answers for a build that is not running is the lie this
// repository exists to refuse.
//
// So a proved engine (Proved) writes itself over the one placed by hand when the two differ: a plain
// copy, renamed into place — never a link, which the mesh creates none of (novox/hq ADR 0012), and never
// a write through one. The launcher's fallback is then the newest proved build, which is also the best
// one to fall back on.
// DefaultFallback is where the engine placed by hand lives: the launcher's fallback, and the command.
const DefaultFallback = "/usr/bin/nox-mesh-host"
// FallbackVar is the environment the launcher reads the fallback's path from; read here too, so the two
// never name different files.
const FallbackVar = "MESH_HOST_BIN"
// FallbackPath is the engine placed by hand: FallbackVar, or DefaultFallback.
func FallbackPath() string {
if p := os.Getenv(FallbackVar); p != "" {
return p
}
return DefaultFallback
}
// RenewFallback makes the file at fallback a copy of the executable at self, when it is not one already.
//
// Nothing is written when the two hold the same bytes — which an engine running from the fallback itself, while
// nothing is delivered, always does. A fallback that is a link is refused: the mesh writes
// through no link, and a link there would be somebody else's arrangement. Reports whether it was renewed.
func RenewFallback(self, fallback string) (bool, error) {
if self == "" || fallback == "" {
return false, errors.New("the running engine or the fallback has no path")
}
mine, err := os.Stat(self)
if err != nil {
return false, fmt.Errorf("cannot read the running engine at %s: %w", self, err)
}
if !mine.Mode().IsRegular() {
return false, fmt.Errorf("the running engine at %s is not a regular file", self)
}
placed, err := os.Lstat(fallback)
switch {
case errors.Is(err, os.ErrNotExist):
// Nothing placed by hand: the launcher would have nothing to fall back on. Placed now.
case err != nil:
return false, fmt.Errorf("cannot read the engine placed by hand at %s: %w", fallback, err)
case placed.Mode()&os.ModeSymlink != 0:
return false, fmt.Errorf("%s is a link, and the mesh writes through none (novox/hq ADR 0012): "+
"it is left as it is", fallback)
case !placed.Mode().IsRegular():
return false, fmt.Errorf("%s is not a regular file: it is left as it is", fallback)
default:
same, err := sameBytes(self, fallback)
if err != nil {
return false, err
}
if same {
return false, nil
}
}
src, err := os.Open(self)
if err != nil {
return false, err
}
defer src.Close()
tmp, err := os.CreateTemp(filepath.Dir(fallback), "."+filepath.Base(fallback)+"-*")
if err != nil {
return false, fmt.Errorf("cannot write beside %s: %w", fallback, err)
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
return false, err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return false, err
}
if err := tmp.Close(); err != nil {
return false, err
}
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return false, err
}
// Renamed, never written in place: the kernel refuses to truncate an executable that runs, and a
// rename leaves whatever runs from the old file untouched.
if err := os.Rename(tmp.Name(), fallback); err != nil {
return false, err
}
return true, nil
}
// sameBytes says whether two files hold the same bytes, by digest.
func sameBytes(a, b string) (bool, error) {
da, err := digestOf(a)
if err != nil {
return false, err
}
db, err := digestOf(b)
if err != nil {
return false, err
}
return bytes.Equal(da, db), nil
}
func digestOf(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
return h.Sum(nil), nil
}