Files
Privatae 48899b41a9
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
bastion 0.63.1
2026-11-03 22:56:18 +00:00

805 lines
33 KiB
Go

package main
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"golang.org/x/crypto/curve25519"
)
// validHostName accepts only a bare DNS hostname (letters/digits/dot/hyphen, no
// traversal), so the redeem-supplied host_name can be safely used as a filename
// and directory name. Rejects "", anything with a path separator or "..", and a
// leading/trailing dot. Expected shape is <handle>.host.privatae.ai.
func validHostName(h string) bool {
if h == "" || len(h) > 253 {
return false
}
if strings.Contains(h, "..") || strings.HasPrefix(h, ".") || strings.HasSuffix(h, ".") {
return false
}
for _, r := range h {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '.' || r == '-'
if !ok {
return false
}
}
return true
}
// Token: skb_<base64url(handle "." secret)>. It carries WHERE to redeem (the
// host handle) + the single-use secret. The secret is the only sensitive thing
// the user copies, it's short-lived, and it grants exactly one enrollment.
func decodeToken(token string) (handle, secret string, err error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(token, "skb_"))
if err != nil {
return "", "", fmt.Errorf("malformed token")
}
parts := strings.SplitN(string(raw), ".", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("malformed token")
}
return parts[0], parts[1], nil
}
func genWGKeypair() (privB64, pubB64 string, err error) {
var priv [32]byte
if _, err = rand.Read(priv[:]); err != nil {
return
}
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
pub, err := curve25519.X25519(priv[:], curve25519.Basepoint)
if err != nil {
return
}
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub), nil
}
// fpSANPrefix carries the device fingerprint as a URI SAN in the operator CSR:
// the host extracts it from the device-signed CSR at redeem and binds it,
// instead of TOFU-trusting a self-reported heartbeat field.
const fpSANPrefix = "urn:skema:hwfp:"
// hostFPSANPrefix carries the same fingerprint under the name a Host with no
// platform reads (its bastion_enroll._FP_SAN_PREFIX).
const hostFPSANPrefix = "urn:privatae:hwfp:"
// fpURIs is the device fingerprint under the names the redeem's signer takes,
// in the order the CSR carries them. The platform signs a CSR whose one SAN is
// fpSANPrefix and refuses any other (the bridge's _checked_operator_csr: one
// name, that prefix), so a platform redeem carries that name alone. A redeem
// with a Host that has no platform (local: unix:// or https://) carries it and
// then hostFPSANPrefix.
func fpURIs(fp string, local bool) ([]*url.URL, error) {
prefixes := []string{fpSANPrefix}
if local {
prefixes = append(prefixes, hostFPSANPrefix)
}
var out []*url.URL
for _, p := range prefixes {
u, err := url.Parse(p + fp)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
// genOperatorCSR makes the gateway's OWN mTLS key + a CSR for the host to sign, with
// the device fingerprint embedded as URI SANs so it is signed by this device's key
// (fpURIs: which names depends on whether the redeem is local). The private key
// never leaves this machine.
func genOperatorCSR(fp string, local bool) (keyPEM, csrPEM string, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
uris, err := fpURIs(fp, local)
if err != nil {
return
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader,
&x509.CertificateRequest{
Subject: pkix.Name{CommonName: "operator"},
URIs: uris,
}, key)
if err != nil {
return
}
csrPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
return
}
type redeemReq struct {
Secret string `json:"secret"`
WGPublicKey string `json:"wg_public_key"`
OperatorCSR string `json:"operator_csr"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Handle string `json:"handle"` // target host handle (from the token); forms host_name
}
type redeemResp struct {
OperatorCert string `json:"operator_crt"`
CACert string `json:"ca_crt"`
Bearer string `json:"bearer"`
Endpoint string `json:"endpoint"`
Endpoints []string `json:"endpoints"` // preferred first; absent from an older host
EndpointPubKey string `json:"endpoint_pubkey"`
BastionTunnelIP string `json:"bastion_tunnel_ip"`
HostTunnelIP string `json:"host_tunnel_ip"`
HostName string `json:"host_name"`
Error string `json:"error"`
}
// pairPrecheck is the whole pre-redeem wall, in one place and free of network
// so it can be tested: the multi-homing conflict rule (checkPairConflict), the
// mode rule, and the anchoring rule. Returns the existing pairing on a
// --replace and the state the new pairing file must carry.
//
// - LOCAL mode (mode.go): one host unless the person says otherwise. A
// second `pair` is refused here with localSecondHostError; `pair` itself
// turns that into a question asked on this machine's terminal
// (pair_terminal.go), and every other caller keeps the refusal. On a
// single-user machine a second host appearing unasked means someone else
// got a token onto the box, so only a yes typed here adds one.
// - NETWORKED + anchored (anchor.go): every pairing after the anchor is written
// PENDING. It gets nothing until the anchor host's grant arrives.
// - --replace keeps the pairing's current state: rotating credentials is not
// an approval.
// - forceActive: claim-anchor redeems as active — the anchor never waits on
// itself.
func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (existing *Config, state string, err error) {
existing, err = checkPairConflict(cfgs, handle, replace)
if err != nil {
return nil, "", err
}
if replace {
if forceActive {
return existing, pairingActive, nil
}
// A revoked host that presents a FRESH token is a new request for the
// anchor: pending again, never straight back to active (before this a
// revoked host could never be re-approved at all).
if existing.State == pairingRevoked {
return existing, pairingPending, nil
}
return existing, existing.State, nil
}
if boxMode() == modeLocal && len(cfgs) > 0 {
return nil, "", &localSecondHostError{serving: cfgs[0].HostName}
}
return pairPrecheckState(forceActive, handle)
}
// pairPrecheckState is the state a new pairing gets once pairPrecheck's walls
// are passed: active for claim-anchor, pending behind an anchor (or, on a
// machine an organisation's policy governs and no Host holds yet, behind the
// Host the policy names: anchor_local.go), else "".
func pairPrecheckState(forceActive bool, handle string) (*Config, string, error) {
if forceActive {
return nil, pairingActive, nil
}
if currentAnchor() != nil || pendingUnderPolicy(handle) {
return nil, pairingPending, nil
}
return nil, "", nil
}
// localSecondHostError is pairPrecheck's refusal on a local machine that
// already serves a host. cmdPair asks the person instead (confirmAddHost).
type localSecondHostError struct{ serving string }
func (e *localSecondHostError) Error() string { return localOneHostRefusal(e.serving) }
// localOneHostRefusal says why a second host was not added to a local
// bastion and what the person can do, in this system's own words. A networked
// reinstall starts a bastion with its own state, so it says plainly that the
// host already here would have to be paired again.
func localOneHostRefusal(serving string) string {
networked := "reinstall it as root with install.sh --networked"
if runtime.GOOS == "windows" {
networked = "reinstall it from an Administrator PowerShell with install.ps1 -Networked"
}
return fmt.Sprintf("this bastion is in local mode and already serves %s, and it adds another Host only when you "+
"answer yes at this computer's terminal. To rotate %s's credentials instead: `privatae-bastion pair --replace <token>`. "+
"A machine that other people's Hosts pair to is a networked bastion: %s. That makes a new bastion with "+
"state of its own, so %s has to be paired with it again.", serving, serving, networked, serving)
}
// alreadyPairedError: the host a pair names is already paired here, and
// nothing was changed. `pair` exits pairExitAlreadyPaired on it, so an
// installer can tell "already connected" from a failure.
type alreadyPairedError struct{ host, msg string }
func (e *alreadyPairedError) Error() string { return e.msg }
// pairExitAlreadyPaired is `pair`'s exit status for alreadyPairedError.
const pairExitAlreadyPaired = 3
// sameNameOtherHost refuses, as a plain failure and never as
// alreadyPairedError, a pair whose Host has the name of prev but trusts
// another CA (wantCA, its fingerprint). A Cloud Host's name can repeat; its CA
// cannot, so that is another Host and never "already connected". nil when
// prev is nil or holds that same CA.
func sameNameOtherHost(prev *Config, wantCA string) error {
if prev == nil || caSHA256(prev.CACert) == wantCA {
return nil
}
return fmt.Errorf("this computer already serves a different Host named %s (its certificate is not this one's), "+
"and one bastion cannot serve two Hosts of the same name yet; nothing was paired", prev.HostName)
}
func alreadyPairedRefusal(host string) error {
return &alreadyPairedError{host: host, msg: fmt.Sprintf("already paired with %s — run `privatae-bastion pair --replace <token>` to rotate its credentials", host)}
}
// pairOpts are what `pair` brings beyond the token: where to redeem, the
// Host CA as a file or as a pairing code's fingerprint, and the person to ask
// before a local machine adds a second host (nil: refuse, as every caller but
// `pair` does).
type pairOpts struct {
redeemURL string
hostCAFile string
caSHA256 string
replace bool
forceActive bool
askSecond func(serving, newHost string) (bool, error)
}
func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
token, opts, err := pairOptsFor(token, redeemURL, hostCAFile, replace)
if err != nil {
return err
}
cfg, all, err := pairWith(token, opts)
if err != nil {
return err
}
// A networked machine is a server, with one Host in charge of it: the
// anchor (anchor.go). Left unanchored it refuses every op to every host
// (capsAllowIn: unanchoredNetworkedRefusal) until someone runs
// claim-anchor, a step people missed. So with machine authority, which
// claim-anchor needs anyway, `pair` puts the right Host in charge by
// itself (anchor_local.go anchorAfterPair): the Host an organisation's
// policy names, else a Host on this machine, else the first Host paired.
// The pairing itself succeeded, so a failed anchor is reported, not
// returned: `pair` failing here would read as "could not pair" to a caller
// (npx cseries exits on it) when the pairing exists. claim-anchor, which
// such a caller runs next, reports the anchor's own failure.
took, err := anchorAfterPair(cfg, all)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
}
if took.Taken {
if fresh, lerr := loadPairings(); lerr == nil {
if c := pairingFor(fresh, cfg.HostName); c != nil {
cfg, all = c, fresh
}
}
}
pairReport(os.Stdout, cfg, all, took)
return nil
}
// pairOptsFor is what `pair`'s arguments come to: the token to redeem and
// the options for it, the person at this machine to ask about a second host
// among them. A pairing code (pair_code.go) carries the redeem address and the
// Host CA's fingerprint itself, so it takes neither flag.
func pairOptsFor(token, redeemURL, hostCAFile string, replace bool) (string, pairOpts, error) {
opts := pairOpts{redeemURL: redeemURL, hostCAFile: hostCAFile, replace: replace, askSecond: confirmAddHost}
if !isPairCode(token) {
return token, opts, nil
}
if redeemURL != "" || hostCAFile != "" {
return "", opts, errors.New("a pairing code (skp_…) carries its own address and certificate; give it alone, without --redeem-url or --host-ca")
}
pc, err := decodePairCode(token)
if err != nil {
return "", opts, err
}
opts.redeemURL, opts.caSHA256 = pc.RedeemURL, pc.CASHA256
return pc.Token, opts, nil
}
// pairReport is what `pair` prints once the pairing stands. Whatever the form
// (put in charge just now, over TCP, over a unix socket, waiting to be
// admitted, or over the tunnel), it ends by saying to enrol a second factor
// when this pairing has none and no approval device is pinned: without one, a
// local machine installs a folder grant on the host's signed grant alone, and
// an anchored one has no way to approve anything the host asks for.
//
// It never tells the person to start a bastion that is already running: a
// running bastion serves a new pairing by itself within supervisePeriod
// (run.go superviseLoop), and on a machine whose bastion is a system service
// the command that starts it is the service manager's (pairServeNote).
func pairReport(w io.Writer, cfg *Config, all []*Config, took anchorTake) {
enrol := ""
if !secondFactorAvailable(cfg.dcnDir()) {
enrol = stepupEnrollHint(cfg.HostName, len(all))
}
defer func() { pairEnrolNote(w, cfg.HostName, enrol) }()
if took.Taken {
fmt.Fprintf(w, "\n%s is now the Host in charge of this machine: %s.\n", cfg.HostName, took.Why)
if took.Replaced != "" {
fmt.Fprintf(w, "%s was in charge before. It is told so, and waits for %s to admit it, as any other Host does.\n",
took.Replaced, cfg.HostName)
}
if others := without(took.Waiting, took.Replaced); len(others) > 0 {
fmt.Fprintf(w, "Waiting for %s to admit them: %s.\n", cfg.HostName, strings.Join(others, ", "))
}
fmt.Fprintln(w, "Every terminal and every write waits for your approval, and any other Host waits")
fmt.Fprintf(w, "until %s admits it: %s asks you to approve it.\n", cfg.HostName, cfg.HostName)
if enrol != "" {
fmt.Fprintf(w, "Enrol your second factor now, so you can approve here:\n %s\n\n", enrol)
enrol = ""
}
}
if !cfg.active() {
who := admitter()
if who == "" {
fmt.Fprintf(w, "Paired with %s — PENDING. Nothing runs for it until it is admitted.\n", cfg.HostName)
return
}
why := "the Host in charge of this computer"
if p := currentAnchorPolicy(); p != nil && boxMode() == modeNetworked && policyNames(p, who) {
why += ", named by an organisation's policy (" + p.Source + ")"
}
fmt.Fprintf(w, "Paired with %s — WAITING for %s to admit it.\n\n", cfg.HostName, who)
fmt.Fprintf(w, "The code was used: %s now lists this computer, but nothing runs for it yet (no lanes).\n", cfg.HostName)
fmt.Fprintf(w, "%s is %s, and it admits every other Host:\n", who, why)
fmt.Fprintf(w, "%s asks you to approve %s. Once you do, %s is served at once, with no restart.\n", who, cfg.HostName, cfg.HostName)
fmt.Fprintf(w, "Until then `privatae-bastion status` shows it as waiting for %s.\n", who)
return
}
if cfg.hostDials() {
fmt.Fprintf(w, "Paired with %s. Your Host connects to this server; this server never connects to it.\n\n", cfg.HostName)
fmt.Fprintf(w, " lanes: over the connections your Host opens to port %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n",
doorPort(cfg.doorAddr()), tcpServerName)
fmt.Fprintf(w, " firewall: this server needs TCP port %s reachable inbound; the bastion keeps its own rule for it in a firewall it manages "+
"here (status shows it), and nothing on your Host's network needs opening\n", doorPort(cfg.doorAddr()))
fmt.Fprintf(w, " local: http://%s\n\n%s\n", cfg.ListenAddr, pairServeNote())
return
}
if cfg.tcpTransport() {
fmt.Fprintf(w, "Paired with %s over TCP.\n\n", cfg.HostName)
fmt.Fprintf(w, " lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
fmt.Fprintf(w, " local: http://%s\n\n%s\n", cfg.ListenAddr, pairServeNote())
return
}
if cfg.unixTransport() {
fmt.Fprintf(w, "Paired with %s, a Host on this machine.\n\n", cfg.HostName)
fmt.Fprintf(w, " lanes: %s (mutual TLS, the Host's CA pinned)\n coder runs: a socket per run in %s\n", cfg.LaneSocket, cfg.RunsDir)
fmt.Fprintf(w, " local: http://%s\n\n%s\n", cfg.ListenAddr, pairServeNote())
return
}
fmt.Fprintf(w, "Paired with %s.\n\nOne bastion serves every pairing:\n", cfg.HostName)
for _, c := range all {
// The listen address is the CLI's loopback control port for that host
// (add-folder, git-credential). The local secret that guards it is read
// from the pairing file by those verbs and is never shown: there is no
// operator client to hand it to since the MCP relay was removed.
fmt.Fprintf(w, "\n %s\n local: http://%s\n", c.HostName, c.ListenAddr)
}
fmt.Fprintf(w, "\n%s\n", pairServeNote())
fmt.Fprintf(w, "\ngit to your forge goes through this bastion — one-time setup:\n privatae-bastion git-setup\n")
}
// pairDaemonRunning: a bastion is serving on this machine (its console
// answers, or the system service runs, waiting for its first pairing). A seam
// for the suite.
var pairDaemonRunning = func() bool { return uiDaemonListening() || systemServiceRunning() }
// pairServeNote is `pair`'s last line about serving the new pairing: a running
// bastion picks it up by itself; otherwise the command that starts this
// machine's bastion (the service manager's on a service machine).
func pairServeNote() string {
if pairDaemonRunning() {
return fmt.Sprintf("The bastion running on this machine serves it within %d seconds; nothing needs restarting.",
int(supervisePeriod.Seconds()))
}
return "Start the bastion so it serves it:\n " + runCommandHint()
}
// without is list less drop.
func without(list []string, drop string) []string {
var out []string
for _, s := range list {
if s != drop {
out = append(out, s)
}
}
return out
}
// pairEnrolNote says what a pairing with no second factor means on this kind
// of machine, and how to enrol one. hint "" says nothing.
func pairEnrolNote(w io.Writer, host, hint string) {
if hint == "" {
return
}
if currentAnchor() != nil {
fmt.Fprintf(w, "\nNo second factor is enrolled here for %s, so this machine cannot approve what it\n"+
"asks for until you enrol one. Enrol it now:\n %s\n", host, hint)
return
}
fmt.Fprintf(w, "\nNo second factor is enrolled here for %s. Until you enrol one, a folder grant or a\n"+
"move plan from it installs on its signed grant alone, and model pulls and root\n"+
"sessions are refused. Enrol it now, so each needs a code from this machine:\n %s\n", host, hint)
}
// redeemURLBase is the platform's enrollment ingress. A variable so a test can
// serve a redeem response on loopback; nothing outside tests assigns it.
var redeemURLBase = "https://api.privatae.ai"
// pairWithToken redeems a token and writes the pairing file. Shared by `pair`
// and `claim-anchor` (forceActive). Returns the new pairing and every pairing
// on the box after the write.
func pairWithToken(token string, replace, forceActive bool) (*Config, []*Config, error) {
return pairWithTokenAt(token, "", replace, forceActive)
}
// pairWithTokenAt is pairWithToken with the redeem address: "" is the
// platform's ingress, and a unix:// URL is a Host on this machine
// (pair_unix.go). Everything before the redeem is the same for both.
func pairWithTokenAt(token, redeemURL string, replace, forceActive bool) (*Config, []*Config, error) {
return pairWithTokenVia(token, redeemURL, "", replace, forceActive)
}
// pairWithTokenVia is pairWithTokenAt with the Host CA file an https://
// redeem URL needs (pair_tcp.go): the Host's enroll port, trusted through
// that CA alone. A unix:// or platform redeem takes no --host-ca.
func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive bool) (*Config, []*Config, error) {
return pairWith(token, pairOpts{redeemURL: redeemURL, hostCAFile: hostCAFile, replace: replace, forceActive: forceActive})
}
// pairWith is every pairing's one path. opts.caSHA256 set (a pairing code)
// takes the Host CA from the enroll port once the walls below are passed,
// kept only under that fingerprint (fetchPinnedHostCA).
func pairWith(token string, opts pairOpts) (*Config, []*Config, error) {
redeemURL, hostCAFile, replace, forceActive := opts.redeemURL, opts.hostCAFile, opts.replace, opts.forceActive
// ONE BASTION PER MACHINE, and this is the check that matters most.
//
// It is FIRST on purpose. loadPairings below reaches pairingsDir -> MkdirAll,
// which CREATES the forked state directory — so a guard placed after it has
// already done the damage it exists to prevent. The hole that matters is here
// at `pair`, not at `run`: a second OS user's HOST holds a fully enrolled
// record for this machine whether or not their daemon ever starts, and the
// platform cannot link the two because the device fingerprint is fresh random
// bytes per pairing by design. A host with an enrollment the anchor never
// admitted is precisely the traceability failure the singleton exists to close.
//
// Marker absent ⇒ nil ⇒ exactly today's behaviour (singleton.go).
if err := singletonGuardForPair(); err != nil {
return nil, nil, err
}
// A bad redeem URL, or a --host-ca that does not go with it, is refused
// before the token is read: nothing is created and nothing is dialled.
enrollSock, transport, hostCA := "", "", ""
var tcpRedeem *url.URL
switch {
case redeemURL == "":
if hostCAFile != "" {
return nil, nil, errors.New("--host-ca goes with --redeem-url https://<the Host's enroll port>; a platform pairing needs neither")
}
case strings.HasPrefix(strings.ToLower(redeemURL), "https:"):
u, err := parseTCPRedeemURL(redeemURL)
if err != nil {
return nil, nil, err
}
switch {
case opts.caSHA256 != "":
// A pairing code: the CA comes from the enroll port after the
// walls, under the code's fingerprint.
case hostCAFile == "":
return nil, nil, errors.New("--redeem-url https://… needs --host-ca <the Host's CA, PEM>: it is the only certificate the redeem trusts")
default:
if hostCA, err = loadHostCA(hostCAFile); err != nil {
return nil, nil, err
}
}
tcpRedeem, transport = u, transportTCP
default:
s, err := parseUnixRedeemURL(redeemURL)
if err != nil {
return nil, nil, err
}
if hostCAFile != "" {
return nil, nil, errors.New("--host-ca goes with an https:// --redeem-url; over a unix socket the Host's CA comes back in its answer")
}
enrollSock, transport = s, transportUnix
}
handle, secret, err := decodeToken(token)
if err != nil {
return nil, nil, err
}
// Multi-homing (0.10.0): pairing ADDS a host, never overwrites — the old
// single-config overwrite silently unpaired the previous host (one host's
// embed lane died when a box re-paired to another). Conflicts are
// checked BEFORE the redeem so a refusal doesn't burn the single-use token.
cfgs, err := loadPairings()
if err != nil {
return nil, nil, err
}
existing, state, err := pairPrecheck(cfgs, handle, replace, forceActive)
var second *localSecondHostError
if errors.As(err, &second) && opts.askSecond != nil {
yes, aerr := opts.askSecond(second.serving, handle)
if aerr != nil {
return nil, nil, aerr
}
if !yes {
return nil, nil, fmt.Errorf("nothing was paired: %s stays the only Host this computer serves", second.serving)
}
existing, state, err = pairPrecheckState(forceActive, handle)
}
// The CA this pair trusts, as a fingerprint: the pairing code's, or that
// of the --host-ca file. "" for a platform or unix-socket pairing, whose
// CA comes back in the answer.
wantCA := opts.caSHA256
if wantCA == "" && hostCA != "" {
wantCA = caSHA256(hostCA)
}
var already *alreadyPairedError
if errors.As(err, &already) && wantCA != "" {
if refusal := sameNameOtherHost(pairingFor(cfgs, handle), wantCA); refusal != nil {
return nil, nil, refusal
}
}
if err != nil {
return nil, nil, err
}
if err := replaceKeepsTransport(existing, transport); err != nil {
return nil, nil, err
}
if tcpRedeem != nil && hostCA == "" {
if hostCA, err = fetchPinnedHostCA(tcpRedeem, opts.caSHA256); err != nil {
return nil, nil, err
}
}
if tcpRedeem != nil {
if err := tcpPinRefusal(existing, hostCA); err != nil {
return nil, nil, err
}
}
wgPriv, wgPub, err := genWGKeypair()
if err != nil {
return nil, nil, err
}
// The stable device fingerprint is generated BEFORE the CSR and embedded
// in it, so the host binds it from the device-signed CSR at redeem (not via TOFU).
fp, err := genSecret(20)
if err != nil {
return nil, nil, err
}
// transport is "" for a platform redeem, whose signer takes one SAN only.
opKey, opCSR, err := genOperatorCSR(fp, transport != "")
if err != nil {
return nil, nil, err
}
hn, _ := os.Hostname()
body, _ := json.Marshal(redeemReq{
Secret: secret, WGPublicKey: wgPub, OperatorCSR: opCSR,
Hostname: hn, Platform: runtime.GOOS, Handle: handle,
})
if enrollSock != "" {
rr, err := redeemOverUnix(enrollSock, body)
if err != nil {
return nil, nil, err
}
// The same belt as the platform path below: the redeemed name is
// authoritative, and it never overwrites a pairing without --replace.
if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) {
existing = prev
if !replace {
return nil, nil, alreadyPairedRefusal(prev.HostName)
}
if err := replaceKeepsTransport(existing, transportUnix); err != nil {
return nil, nil, err
}
}
if err := unixAnswerRefusal(rr, enrollSock, opKey, existing, cfgs); err != nil {
return nil, nil, err
}
cfg, err := unixPairingConfig(rr, cfgs, existing, replace, wgPriv, opKey, fp)
if err != nil {
return nil, nil, err
}
return commitPairing(cfg, existing, replace, state)
}
if tcpRedeem != nil {
rr, err := redeemOverTCP(tcpRedeem, hostCA, body)
if err != nil {
return nil, nil, err
}
// The same belt as the other paths: the redeemed name is
// authoritative, and it never overwrites a pairing without --replace.
if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) {
existing = prev
if !replace {
if refusal := sameNameOtherHost(prev, caSHA256(hostCA)); refusal != nil {
return nil, nil, refusal
}
return nil, nil, alreadyPairedRefusal(prev.HostName)
}
if err := replaceKeepsTransport(existing, transportTCP); err != nil {
return nil, nil, err
}
}
if err := tcpAnswerRefusal(rr, tcpRedeem, hostCA, opKey, existing, cfgs); err != nil {
return nil, nil, err
}
cfg, err := tcpPairingConfig(rr, cfgs, existing, replace, wgPriv, opKey, fp, hostCA)
if err != nil {
return nil, nil, err
}
return commitPairing(cfg, existing, replace, state)
}
// The redeem is a short-lived, token-authed call on the platform's public
// enrollment ingress (api.privatae.ai, a normal LE cert verified by system
// roots — no CA bootstrap). Caddy terminates it and mTLS-forwards to THIS
// host (router cert) which runs the redeem self-scoped. It is the ONLY time
// the bastion talks to anything but its host's tunnel.
url := fmt.Sprintf("%s/v1/bastions/%s/redeem", redeemURLBase, handle)
httpc := &http.Client{Timeout: 30 * time.Second}
resp, err := httpc.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, nil, fmt.Errorf("redeem: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
var rr redeemResp
_ = json.Unmarshal(rb, &rr)
if resp.StatusCode != 200 {
if rr.Error != "" {
return nil, nil, fmt.Errorf("redeem rejected: %s", rr.Error)
}
return nil, nil, fmt.Errorf("redeem rejected: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
}
for k, v := range map[string]string{
"operator_crt": rr.OperatorCert, "ca_crt": rr.CACert, "bearer": rr.Bearer,
"endpoint": rr.Endpoint, "endpoint_pubkey": rr.EndpointPubKey,
"bastion_tunnel_ip": rr.BastionTunnelIP, "host_tunnel_ip": rr.HostTunnelIP,
"host_name": rr.HostName,
} {
if v == "" {
return nil, nil, fmt.Errorf("redeem response missing %s", k)
}
}
// host_name becomes a filename (pairings/<host>.json) and two dir names
// (context/<host>, dcn/<host>). Guard it: a hostile or MITM'd redeem response
// with a path separator or ".." could write outside the config tree, or —
// worse — clean to another pairing's path and silently overwrite it, the exact
// cross-host clobber this change exists to prevent. Accept only a bare hostname.
if !validHostName(rr.HostName) {
return nil, nil, fmt.Errorf("redeem response has an unsafe host_name %q — refusing", rr.HostName)
}
// host_tunnel_ip becomes the tunnel's only allowed address. Checked here so a
// pairing that could never bring its tunnel up is not saved at all.
if _, err := hostTunnelAllowedIP(rr.HostTunnelIP); err != nil {
return nil, nil, fmt.Errorf("redeem response: %w", err)
}
// endpoint and endpoints are written into the tunnel's configuration text.
if err := checkEndpoints(append([]string{rr.Endpoint}, rr.Endpoints...)...); err != nil {
return nil, nil, fmt.Errorf("redeem response: %w", err)
}
// Belt: the redeemed host_name is authoritative — if it lands on a pairing the
// pre-redeem handle check missed, still refuse to overwrite it.
if prev := pairingFor(cfgs, rr.HostName); prev != nil {
existing = prev
if !replace {
return nil, nil, alreadyPairedRefusal(prev.HostName)
}
}
opSecret, err := genSecret(24)
if err != nil {
return nil, nil, err
}
cd, err := configDir()
if err != nil {
return nil, nil, err
}
cfg := &Config{
HostName: rr.HostName, WGPrivateKey: wgPriv,
BastionTunnelIP: rr.BastionTunnelIP, HostTunnelIP: rr.HostTunnelIP,
// Boot on the v4 entry the host names in "endpoint": it is the one every relay
// serves. The first heartbeat's rotate directive walks "endpoints" and moves
// the tunnel to the preferred entry once a real handshake proves it reachable.
Endpoint: rr.Endpoint, Endpoints: candidateList(rr.Endpoints, rr.Endpoint),
EndpointPubKey: rr.EndpointPubKey,
OperatorCert: rr.OperatorCert, OperatorKey: opKey, CACert: rr.CACert,
Bearer: rr.Bearer, OperatorSecret: opSecret, Fingerprint: fp,
// Per-host everything: own loopback port, own context store, own DCN state
// (the TOFU pin is per-entity — shared dirs would bleed across hosts).
ListenAddr: nextListenAddr(cfgs),
GitProxyAddr: nextGitProxyAddr(cfgs),
ContextRoot: filepath.Join(cd, "context", rr.HostName),
DCNDir: filepath.Join(cd, "dcn", rr.HostName),
}
if replace && existing != nil {
// A rotation, not a new home: keep the pairing's local address and stores so
// the operator URL, context blobs, and TOFU-pinned DCN state all survive.
cfg.ListenAddr = existing.ListenAddr
if existing.GitProxyAddr != "" {
cfg.GitProxyAddr = existing.GitProxyAddr
}
cfg.ContextRoot = existing.ContextRoot
cfg.DCNDir = existing.DCNDir
}
return commitPairing(cfg, existing, replace, state)
}
// commitPairing is the tail every redeem shares, platform or a Host on this
// machine: the state and age, the atomic save, and the stale file a renaming
// --replace leaves behind.
func commitPairing(cfg, existing *Config, replace bool, state string) (*Config, []*Config, error) {
cfg.State = state
// first_seen: the pairing's age. Preserved across --replace so a credential
// rotation (or a revoked host coming back) does not reset "since".
cfg.FirstSeen = time.Now().UTC().Format(time.RFC3339)
if replace && existing != nil && existing.FirstSeen != "" {
cfg.FirstSeen = existing.FirstSeen
}
if err := cfg.save(); err != nil {
return nil, nil, err
}
// A --replace that lands a DIFFERENT host_name renames the pairing's file:
// saveLocked writes pairings/<host_name>.json, so the old file survives and
// loadPairings then returns BOTH. cmdRun starts every pairing, and the
// replacement deliberately inherits the old one's ListenAddr (just above),
// so the two collide on the same loopback port; the second bind is a fatal
// error and takes the whole daemon down — on a box that was just
// successfully re-paired. The old pairing's credentials are rotated out by
// this very redeem, so it could never work again anyway.
//
// Ordered AFTER the successful save on purpose: a crash between the two
// leaves a duplicate (recoverable, and the next --replace clears it), never
// zero pairings.
//
// This is reachable today because a host's name can change under it (a
// host renamed when its address moved). unpair() is deliberately not
// reused: it refuses to remove the anchor's own pairing, which is exactly
// the case here — the anchor is re-paired like every other host. The anchor
// RECORD is untouched here; anchor.json still names the old host until
// `claim-anchor --repin` re-signs it, or, for a Host paired again through a
// window under its pinned CA, carryAnchorToNewName (pair_window.go) does.
if replace && existing != nil && existing.HostName != cfg.HostName {
if err := removeStalePairingFile(existing.HostName); err != nil {
return nil, nil, fmt.Errorf("paired as %s, but the superseded pairing file for %s "+
"could not be removed (%w) — remove it before `run`, or the daemon will refuse "+
"to start on a port collision", cfg.HostName, existing.HostName, err)
}
}
all, err := loadPairings()
if err != nil {
all = []*Config{cfg}
}
return cfg, all, nil
}