Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

416 lines
16 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// Self-update. The host directs an update via the heartbeat RESPONSE
// ({"update":{"version":X}}); the bastion fetches the published manifest, downloads its own
// platform's binary, verifies the sha256, atomically swaps its own executable, and exits —
// systemd's Restart=always brings it back on the new binary.
//
// Trust: the directive rides the authenticated mTLS heartbeat from the bastion's OWN host, so the
// platform can't push code unilaterally — only the user's host can direct it — the manifest must
// carry a valid release signature (release_sign.go), and the binary is checked against the
// digest in that signed manifest. FAIL-CLOSED: any error (bad manifest, download failure, sha256 mismatch) aborts
// WITHOUT swapping, so the bastion stays on the working binary. A .bak rollback copy is kept.
// manifestURL is a var so a test can prove a refused directive never fetches.
var manifestURL = "https://privatae.ai/bastion/version.json"
type updateManifest struct {
Version string `json:"version"`
Binaries map[string]struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
} `json:"binaries"`
}
// parseUpdateTarget extracts the host's requested target version from a heartbeat response, or "".
func parseUpdateTarget(body []byte) string {
var hb struct {
Update *struct {
Version string `json:"version"`
} `json:"update"`
}
if json.Unmarshal(body, &hb) != nil || hb.Update == nil {
return ""
}
return hb.Update.Version
}
// selfUpdateMu makes the update a process singleton: with multi-homing (0.10.0)
// EVERY paired host's heartbeat can direct an update concurrently, and two
// goroutines racing replaceSelf would clobber the binary mid-swap. One attempt at
// a time — a successful swap exits the process, a failed one may retry on a later
// beat (same semantics as before, just serialized).
var selfUpdateMu sync.Mutex
// self is the pairing whose heartbeat carried the directive. The binary is
// shared by every pairing on the box, so on an anchored machine only the
// anchor host may direct a swap (anchor.go anchorOnly); a member host's
// directive is logged and ignored. An unanchored LOCAL box keeps the old
// behaviour; an unanchored NETWORKED one refuses the swap outright (0.24.0),
// before any fetch or disk write.
func maybeSelfUpdate(self, dcnDir string, heartbeatResponse []byte) {
target := parseUpdateTarget(heartbeatResponse)
if target == "" || target == clientVersion {
return
}
// directiveGate (writes.go): anchor-only, then the person's local switch,
// then — on an anchored box — an approval bound to THIS target version.
// A binary swap is the one write that can change every rule after it, so
// it is the last thing that should run on a host's word alone.
//
// A move to a NEWER version skips only the approval (0.44.0). What approves
// it is the release signature, made on the key holder's own computer:
// fetchManifest below refuses a manifest that does not verify, and the swap
// happens only when the manifest names exactly this target, so the one
// thing that gets past without an approval is a newer signed release. That
// is the rule the agent has always updated under. A move to an older
// version, or to one that cannot be compared with this one ("dev", a
// suffix), still needs the approval: a signature shows that a release was
// published once, not that it is the latest.
var why string
if versionNewer(target, clientVersion) {
why = directiveWalls(self, directiveSelfUpdate)
} else {
why = directiveGate(self, dcnDir, directiveSelfUpdate, []string{target},
directiveApproval(heartbeatResponse, directiveSelfUpdate))
}
if why != "" {
fmt.Printf("self-update: %s\n", why)
// Tell the HOST, not just this box's log: it asked for a version and is
// otherwise left watching a number that never changes.
recordDirectiveRefusal("self-update", why)
return
}
clearDirectiveRefusal("self-update")
selfUpdateMu.Lock()
defer selfUpdateMu.Unlock()
m, err := fetchManifest()
if err != nil {
fmt.Printf("self-update: manifest fetch failed (%v), staying on %s\n", err, clientVersion)
return
}
// The six-hourly check is not the only place this is learned. A directed
// update reads the same manifest, and its answer is fresher than anything
// the poll holds. It matters most on the mismatch path immediately below,
// where this process does NOT exit: the host asked for a version the
// manifest does not carry, and the page is then the only thing that can say
// what is actually published.
recordLatestPublished(m.Version)
if m.Version != target {
fmt.Printf("self-update: manifest is %s but host asked for %s, skipping\n", m.Version, target)
return
}
key := runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
key += ".exe"
}
b, ok := m.Binaries[key]
if !ok {
fmt.Printf("self-update: manifest has no binary for %s, skipping\n", key)
return
}
data, err := download(b.URL)
if err != nil {
fmt.Printf("self-update: download failed (%v), staying on %s\n", err, clientVersion)
return
}
sum := sha256.Sum256(data)
if hex.EncodeToString(sum[:]) != b.SHA256 {
fmt.Printf("self-update: sha256 mismatch for %s, aborting (staying on %s)\n", key, clientVersion)
return
}
if err := replaceSelf(data); err != nil {
fmt.Printf("self-update: swap failed (%v), staying on %s\n", err, clientVersion)
return
}
fmt.Printf("self-update: %s -> %s applied, restarting\n", clientVersion, target)
// On Linux this is the whole restart: systemd Restart=always re-execs the
// path, now the new binary, and launchd's KeepAlive does the same for the
// macOS daemon. An ordinary Windows install has no supervisor, so
// relaunchAfterUpdate starts the replacement itself — without it the update
// was a clean shutdown and the machine went dark (relaunch_windows.go carries
// the measurement). As a Windows service it starts nothing, and the service
// manager restarts it (sysservice_windows.go).
relaunchAfterUpdate()
os.Exit(0)
}
// resolvedSelf is the on-disk path of THIS running binary, symlinks resolved.
func resolvedSelf() (string, error) {
self, err := os.Executable()
if err != nil {
return "", err
}
if resolved, e := filepath.EvalSymlinks(self); e == nil {
self = resolved
}
return self, nil
}
// replaceSelf swaps the running executable for `data`, cross-platform.
//
// The old code wrote a temp file and os.Rename'd it OVER the running binary. That
// works on Linux/macOS but FAILS on Windows: a running .exe image is locked, so you
// can neither overwrite nor delete it — the rename returns Access Denied and the
// update silently no-ops (every Windows bastion stayed on its old version forever).
// Windows CAN, however, RENAME a running image. So move the current binary aside to
// <self>.old, then write the new one at the original path. Same path works on Linux/
// macOS. The .old copy is the rollback; cleanupStaleBinaries removes it on next start
// (a running binary can't delete itself, so cleanup waits for the restart).
func replaceSelf(data []byte) error {
self, err := resolvedSelf()
if err != nil {
return err
}
return swapBinaryAt(self, data)
}
// swapBinaryAt is the testable core of replaceSelf: move `path` aside to path.old,
// then write `data` at `path`. Roll back on write failure.
func swapBinaryAt(path string, data []byte) error {
old := path + ".old"
_ = os.Remove(old)
if err := os.Rename(path, old); err != nil {
return fmt.Errorf("move running binary aside: %w", err)
}
if err := os.WriteFile(path, data, 0o755); err != nil {
_ = os.Rename(old, path) // roll back so the service still has a binary to re-exec
return fmt.Errorf("write new binary: %w", err)
}
return nil
}
// cleanupStaleBinaries removes leftovers from a prior self-update swap (a running
// binary can't delete its predecessor, so it's cleared on the next start). Called
// once at daemon startup. Best-effort.
func cleanupStaleBinaries() {
self, err := resolvedSelf()
if err != nil {
return
}
for _, suffix := range []string{".old", ".new", ".bak"} {
_ = os.Remove(self + suffix)
}
}
// fetchManifest returns the release manifest only when its signature verifies
// (release_sign.go). Both readers go through here: the directed update, which
// trusts the manifest's digests, and the availability poll, which shows its
// version on the local page.
func fetchManifest() (*updateManifest, error) {
body, err := fetchSignedManifest(manifestURL, releaseKindBastion)
if err != nil {
return nil, err
}
var m updateManifest
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
return &m, nil
}
// ── is a newer version published? ───────────────────────────────────────────
//
// fetchManifest existed before this and was called from exactly one place:
// maybeSelfUpdate, which runs only when the HOST directs an update. So the box
// never asked whether it was current, and the local UI could not say "0.24.4 is
// available" — the person at the machine had to open the host to find out
// whether the thing in front of them was the thing that shipped.
//
// This ONLY RECORDS. It never downloads, never verifies, never swaps: the
// directed path above is the only thing that touches the binary, and the local
// UI must never grow a button that starts one (ui.go's header — the UI is a
// view of this machine and never originates a request to the host).
const (
// updateCheckEvery — WHY SIX HOURS, measured against the cadences already in
// this binary.
//
// Everything else that repeats here is either a local read or a beat to the
// user's OWN host down their own tunnel: supervise 15 s (run.go:239),
// heartbeat 60 s (run.go:596), host posture and the second-bastion scan
// 5 min (observe_ops.go:797, otherbastions.go:317), local agent reconcile
// 10 min (freeagent_managed.go:114). This one is different in kind. It is
// the only repeating request in the binary that leaves for privatae.ai, the
// platform's public edge, and it carries this machine's address to the
// company whose whole claim is that it cannot see this machine. So it is
// priced at the rate the ANSWER changes, not at the rate the page redraws.
//
// Measured: 32 bumps of deploy/VERSION in one 30-day span, with publishes
// on 18 distinct days out of 60 — about one publish a day, sometimes
// several. Six hours is four requests a day and is never more than six hours
// behind a publish, which is well inside the window that matters, because
// this banner does not act on anything. The HOST applies updates (the
// Network app's per-machine Update button, which posts to the host's
// per-bastion update endpoint), and the host reads the same manifest on
// its own account.
//
// Rejected — matching the 5-minute refreshes: 288 requests a day per machine
// to learn a fact that changes about once a day, and it would hand the
// platform a five-minute-resolution presence trace of a box that is supposed
// to be invisible to it. The cost here is privacy before bandwidth.
// Rejected — riding the 60 s heartbeat: the same objection 1440 times a day,
// and that beat goes to the user's host, not to us.
// Rejected — jitter: start times already stagger these, because the first
// check is updateCheckFirst after a daemon comes up and daemons do not come
// up together. A simultaneous fleet restart would correlate them, on a
// static JSON behind the edge cache, which is the one job an edge has.
// Jitter would buy a seam and a weak test for nothing measurable.
updateCheckEvery = 6 * time.Hour
// updateCheckFirst keeps the first check clear of daemon startup, the
// busiest moment this process has: every pairing is bringing up a tunnel and
// beating immediately (run.go:595). It still lands long before someone who
// has just started the bastion has finished opening the page, and the
// fragment re-polls every 3 s (uiassets/page.html), so the banner appears on
// its own when the answer arrives.
updateCheckFirst = 30 * time.Second
)
var (
latestMu sync.Mutex
latestPublished string
)
// runUpdateAvailabilityPoll asks, slowly, until ctx is done.
func runUpdateAvailabilityPoll(ctx context.Context) {
select {
case <-ctx.Done():
return
case <-time.After(updateCheckFirst):
}
for {
checkLatestPublished()
select {
case <-ctx.Done():
return
case <-time.After(updateCheckEvery):
}
}
}
// checkLatestPublished fetches the manifest and records what it said.
//
// Failure is SILENT and leaves the last known answer in place. Running with no
// route to privatae.ai is a supported way to use this product — an egress-
// filtered or genuinely isolated machine still serves its host over its own
// tunnel — and telling that user something is wrong because a version file is
// unreachable would be raising an alarm about the thing they chose.
func checkLatestPublished() {
if m, err := fetchManifest(); err == nil {
recordLatestPublished(m.Version)
}
}
func recordLatestPublished(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
latestMu.Lock()
latestPublished = v
latestMu.Unlock()
}
// updateAvailable is the page's question: is a published version NEWER than the
// one running here. "" means no — and "" is also the answer to a question that
// cannot be answered, because an unparseable clientVersion (a `dev` build,
// run.go:32) or an unparseable manifest cannot support the claim. The page says
// nothing rather than something it cannot stand behind.
func updateAvailable() string {
latestMu.Lock()
v := latestPublished
latestMu.Unlock()
if !versionNewer(v, clientVersion) {
return ""
}
return v
}
// versionNewer reports whether a is a strictly later release than b, in the
// dotted-decimal shape deploy/VERSION has always had ("0.24.3").
//
// NOT a string comparison, which is the bug this exists to prevent: "0.9.0"
// sorts ABOVE "0.24.3" lexically, so every machine on a 0.9 line would have
// been told it was ahead of the world, and every 0.24 machine would have been
// told to update to a release from months earlier. Compared component by
// component; a missing component is 0, so 0.24 and 0.24.0 are one release; and
// anything that is not a plain number makes the comparison unanswerable rather
// than guessed at.
func versionNewer(a, b string) bool {
av, aok := parseVersion(a)
bv, bok := parseVersion(b)
if !aok || !bok {
return false
}
for i := 0; i < len(av) || i < len(bv); i++ {
x, y := 0, 0
if i < len(av) {
x = av[i]
}
if i < len(bv) {
y = bv[i]
}
if x != y {
return x > y
}
}
return false
}
// ownVersionComparable reports whether this build can compare its own version
// with another, which a move forward with no approval needs (maybeSelfUpdate).
func ownVersionComparable() bool {
_, ok := parseVersion(clientVersion)
return ok
}
func parseVersion(s string) ([]int, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if s == "" {
return nil, false
}
parts := strings.Split(s, ".")
out := make([]int, 0, len(parts))
for _, p := range parts {
// Atoi accepts a sign, so "0.-1" would parse and then compare as a
// release older than everything. A version component is a count.
n, err := strconv.Atoi(p)
if err != nil || n < 0 || strings.HasPrefix(p, "+") {
return nil, false
}
out = append(out, n)
}
return out, true
}
func download(url string) ([]byte, error) {
c := &http.Client{Timeout: 120 * time.Second}
resp, err := c.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("download %s: %d", url, resp.StatusCode)
}
return io.ReadAll(resp.Body)
}