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 .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_. 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 `. "+ "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 ` 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://; 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 : 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/.json) and two dir names // (context/, dcn/). 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/.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 }