Files
privatae-bastion/client-go/pair_window.go
T
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

1020 lines
34 KiB
Go

package main
// pair_window.go: pairing a server with a Host that connects to it.
//
// privatae-bastion pair --open [--port 8812]
//
// Run on the server. It opens a pairing window for one Host: a code, good once
// and for 15 minutes, and the door (hostdoor.go) listening for it. The person
// gives their Host this server's address and the code; the Host connects, and
// the pairing is made over that connection the same way round as every other
// pairing: this bastion is the TLS client and the HTTP client, the Host
// answers, signs this bastion's operator CSR and issues its bearer.
//
// The code is what each side proves to the other. Neither sends it: each sends
// an HMAC of it over the Host's certificate as this connection presented it,
// the Host's CA and a nonce from each side. The Host proves first, so a
// stranger who connects to the open window learns nothing it could test codes
// against; the bastion proves second, before the Host signs anything, so a
// machine that is not this server can't pair itself with the Host even if the
// Host was sent to it. A proof made on one connection is worth nothing on
// another (the certificate differs), so neither can be relayed. The code is
// 100 bits, out of reach offline; online, a wrong one costs a wait that
// doubles each time, and the fifth closes the window.
//
// Opening the window at this server is the person's say-so for one Host, so
// it is the "yes at this computer's terminal" a local machine asks before a
// second Host. Everything after the Host has proved itself is pair.go's:
// the conflict rules, pending behind the Host in charge, the anchor rules,
// the atomic save.
//
// The wire contract is docs/host-connects.md.
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
)
const (
// windowLifetime is how long a pairing window stays open.
windowLifetime = 15 * time.Minute
// windowMaxFailures wrong codes close the window.
windowMaxFailures = 5
// windowExchangeTimeout bounds the whole pairing exchange on one connection.
windowExchangeTimeout = 60 * time.Second
// windowCodeLen characters of windowAlphabet: 5 bits each, 100 bits.
windowCodeLen = 20
// windowAlphabet is Crockford's base32: no I, L, O or U to misread.
windowAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// windowTransport is the transport a Host answers a window redeem with.
windowTransport = "host_dials"
// windowProofLabel starts every proof's MAC input.
windowProofLabel = "privatae-pair-v1 "
)
// windowFailWait is the wait after the first wrong code; it doubles with each
// one after. A variable so a test can shorten it.
var windowFailWait = time.Second
// windowPollEvery is how often `pair --open` looks for the result. A variable
// so a test can shorten it.
var windowPollEvery = 500 * time.Millisecond
// newWindowCode is a fresh code, normalised (windowCodeLen characters).
func newWindowCode() (string, error) {
b := make([]byte, windowCodeLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = windowAlphabet[int(b[i])&31] // 256 is a multiple of 32: uniform
}
return string(b), nil
}
// formatWindowCode is the code as the person reads it: groups of five.
func formatWindowCode(code string) string {
var parts []string
for i := 0; i < len(code); i += 5 {
parts = append(parts, code[i:min(i+5, len(code))])
}
return strings.Join(parts, "-")
}
// normalizeWindowCode is what both sides key the proofs with: upper case, no
// dashes or spaces, O read as 0 and I or L as 1. "" when it is not a code.
func normalizeWindowCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
switch r {
case '-', ' ', '\t':
continue
case 'O':
r = '0'
case 'I', 'L':
r = '1'
}
if !strings.ContainsRune(windowAlphabet, r) {
return ""
}
b.WriteRune(r)
}
if b.Len() != windowCodeLen {
return ""
}
return b.String()
}
// windowProof is role's proof of the code on one connection: HMAC-SHA256
// keyed by the normalised code over the label, the role, the SHA-256 of the
// Host's certificate as presented, that of its CA, and both nonces.
func windowProof(code, role string, leafSHA, caSHA, nonceB, nonceH []byte) string {
m := hmac.New(sha256.New, []byte(code))
m.Write([]byte(windowProofLabel + role + "\x00"))
for _, p := range [][]byte{leafSHA, caSHA, nonceB, nonceH} {
m.Write(p)
}
return hex.EncodeToString(m.Sum(nil))
}
// ── the window file ──────────────────────────────────────────────────────────
// pairWindow is <configDir>/pairing-window.json: the one open window, written
// by `pair --open`, read by whichever door serves it, which writes the result
// into it. 0600 in the bastion's own state, like the pairings' bearers.
type pairWindow struct {
ID string `json:"id"`
Code string `json:"code"`
Door string `json:"door"`
Opened string `json:"opened"`
Expires string `json:"expires"`
// Authority: the person who opened it held machine authority, so the
// Host paired on it may be put in charge of a machine nobody is in charge
// of yet (anchor_local.go anchorAfterWindow).
Authority bool `json:"machine_authority,omitempty"`
Result *windowResult `json:"result,omitempty"`
}
// windowResult is how the window ended.
type windowResult struct {
Host string `json:"host,omitempty"`
State string `json:"state,omitempty"`
AdmittedBy string `json:"admitted_by,omitempty"`
Rotated bool `json:"rotated,omitempty"`
Taken bool `json:"anchor_taken,omitempty"`
Why string `json:"anchor_why,omitempty"`
Replaced string `json:"anchor_replaced,omitempty"`
Waiting []string `json:"anchor_waiting,omitempty"`
Error string `json:"error,omitempty"`
}
var windowFileMu sync.Mutex
func windowPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "pairing-window.json"), nil
}
func readWindowFile() *pairWindow {
p, err := windowPath()
if err != nil {
return nil
}
return readWindowAt(p)
}
func readWindowAt(p string) *pairWindow {
raw, err := os.ReadFile(p)
if err != nil || len(raw) > 64<<10 {
return nil
}
var w pairWindow
if json.Unmarshal(raw, &w) != nil || w.ID == "" {
return nil
}
return &w
}
// claimedWindowPath is where a door moves the window the moment it commits a
// pairing on it. The move is one rename, and so is the terminal closing the
// window (removeWindow): whichever happens first wins, across processes, so a
// window closed at the terminal never pairs, and a pairing made is never
// reported as "nothing was paired".
func claimedWindowPath() (string, error) {
p, err := windowPath()
return p + ".claimed", err
}
// claimWindow takes the window with this id for the pairing about to be
// saved. False when it is closed, replaced or already ended.
func claimWindow(id string) bool {
windowFileMu.Lock()
defer windowFileMu.Unlock()
if !windowStillOpen(id) {
return false
}
p, err := windowPath()
if err != nil {
return false
}
c := p + ".claimed"
if os.Rename(p, c) != nil {
return false
}
if w := readWindowAt(c); w == nil || w.ID != id {
_ = os.Rename(c, p) // a newer window landed between the check and the move
return false
}
return true
}
// claimedWindow is the claimed window with this id, or nil.
func claimedWindow(id string) *pairWindow {
c, err := claimedWindowPath()
if err != nil {
return nil
}
if w := readWindowAt(c); w != nil && w.ID == id {
return w
}
return nil
}
// claimedWindowUnderWay is the window a door claimed for a pairing it has not
// finished: the window file is gone (claimWindow) and the pairing may not be
// saved yet, so neither reads as the door being in use, but it is. Bounded:
// a claim is made before the window expires and its exchange ends within
// windowExchangeTimeout, so a claimed file a killed process left behind
// stops counting then.
func claimedWindowUnderWay(now time.Time) *pairWindow {
c, err := claimedWindowPath()
if err != nil {
return nil
}
w := readWindowAt(c)
if w == nil || w.Result != nil {
return nil
}
t, err := time.Parse(time.RFC3339, w.Expires)
if err != nil || !now.Before(t.Add(windowExchangeTimeout)) {
return nil
}
return w
}
func (w *pairWindow) expired(now time.Time) bool {
t, err := time.Parse(time.RFC3339, w.Expires)
return err != nil || !now.Before(t)
}
// loadOpenWindow is the window a door may serve now: present, not expired,
// not ended, with a usable code.
func loadOpenWindow() *pairWindow {
w := readWindowFile()
if w == nil || w.Result != nil || w.expired(time.Now()) || normalizeWindowCode(w.Code) != w.Code {
return nil
}
return w
}
func saveWindowFile(w *pairWindow) error {
p, err := windowPath()
if err != nil {
return err
}
return writeWindowAt(p, w)
}
func writeWindowAt(p string, w *pairWindow) error {
raw, err := json.MarshalIndent(w, "", " ")
if err != nil {
return err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, p)
}
// endWindow writes the result into the window, if it is still the one with
// this id (a newer `pair --open` replaces it).
func endWindow(id string, res windowResult) {
windowFileMu.Lock()
defer windowFileMu.Unlock()
if w := claimedWindow(id); w != nil {
if w.Result == nil {
w.Result = &res
if c, err := claimedWindowPath(); err == nil {
_ = writeWindowAt(c, w)
}
}
return
}
w := readWindowFile()
if w == nil || w.ID != id || w.Result != nil {
return
}
w.Result = &res
_ = saveWindowFile(w)
}
// removeWindow closes the window with this id: its file is removed, unless a
// door claimed it first (claimWindow), which removeWindow reports.
func removeWindow(id string) (claimed bool) {
windowFileMu.Lock()
defer windowFileMu.Unlock()
if w := readWindowFile(); w != nil && w.ID == id {
if p, err := windowPath(); err == nil && os.Remove(p) == nil {
return false
}
}
return claimedWindow(id) != nil
}
// awaitClaimedResult waits, at most an exchange's length, for the result of a
// pairing a door claimed the window for, and clears the claimed file.
func awaitClaimedResult(id string) *windowResult {
deadline := time.Now().Add(windowExchangeTimeout)
for time.Now().Before(deadline) {
if w := claimedWindow(id); w == nil || w.Result != nil {
if c, err := claimedWindowPath(); err == nil && w != nil {
_ = os.Remove(c)
}
if w == nil {
return nil
}
return w.Result
}
time.Sleep(windowPollEvery)
}
return &windowResult{Error: "a pairing was under way when the window closed, and it did not finish; run `privatae-bastion status` to see whether the Host was paired"}
}
// ── the door's side of a window ──────────────────────────────────────────────
// windowState is what a door knows of the window it serves: its wrong codes
// and the wait they cost, and whether a pairing is under way on it.
type windowState struct {
id string
failures int
next time.Time
busy bool
}
// openWindow is the window this door offers pairing on now, or nil: none
// open, another door's, a pairing already under way on it, or a wrong code's
// wait not yet over.
func (d *hostDoor) openWindow() *pairWindow {
w := loadOpenWindow()
if w == nil || w.Door != d.addr {
return nil
}
d.mu.Lock()
defer d.mu.Unlock()
if d.win.id != w.ID {
d.win = windowState{id: w.ID}
}
if d.win.busy || d.win.failures >= windowMaxFailures || time.Now().Before(d.win.next) {
return nil
}
return w
}
// windowBegin starts the pairing on the window, if it may still start: the
// window still open in its file (not closed at the terminal, ended or
// expired since the connection came in), no pairing under way on it, and no
// wrong code's wait or window-closing wrong code since that connection was
// offered it. Several connections can be in their handshake at once; each is
// asked here again.
func (d *hostDoor) windowBegin(w *pairWindow) bool {
if !windowStillOpen(w.ID) {
return false
}
d.mu.Lock()
defer d.mu.Unlock()
if d.win.id != w.ID || d.win.busy || d.win.failures >= windowMaxFailures || time.Now().Before(d.win.next) {
return false
}
d.win.busy = true
return true
}
// windowStillOpen: the window with this id is the open one.
func windowStillOpen(id string) bool {
cur := loadOpenWindow()
return cur != nil && cur.ID == id
}
func (d *hostDoor) windowEnd() {
d.mu.Lock()
d.win.busy = false
d.mu.Unlock()
}
// windowFailed counts a wrong code: a wait of 1, 2, 4, 8 s before the next
// try, and the fifth closes the window.
func (d *hostDoor) windowFailed(w *pairWindow, src string) {
d.mu.Lock()
d.win.failures++
n := d.win.failures
d.win.next = time.Now().Add(time.Duration(1<<(n-1)) * windowFailWait)
d.mu.Unlock()
pairingAudit("window_wrong_code", src, fmt.Sprintf("refused: a wrong pairing code (%d of %d)", n, windowMaxFailures))
if n >= windowMaxFailures {
endWindow(w.ID, windowResult{Error: fmt.Sprintf("the window closed after %d wrong codes; nothing was paired", n)})
pairingAudit("window_closed", d.addr, "closed after too many wrong codes")
}
}
// The messages of a pairing exchange (docs/host-connects.md).
type windowHelloReq struct {
V int `json:"v"`
Nonce string `json:"nonce"`
}
type windowHelloResp struct {
V int `json:"v"`
HostHandle string `json:"host_handle"`
HostName string `json:"host_name"`
Nonce string `json:"nonce"`
Proof string `json:"proof"`
Error string `json:"error"`
}
type windowRedeemReq struct {
V int `json:"v"`
Proof string `json:"proof"`
WGPublicKey string `json:"wg_public_key"`
OperatorCSR string `json:"operator_csr"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
Handle string `json:"handle"`
}
type windowDoneReq struct {
V int `json:"v"`
State string `json:"state"`
HostName string `json:"host_name,omitempty"`
AdmittedBy string `json:"admitted_by,omitempty"`
Error string `json:"error,omitempty"`
}
// oneConnClient is an HTTP client whose every request goes over tc, and over
// nothing else: no proxy, no redirect, no second connection.
func oneConnClient(tc *tls.Conn) *http.Client {
var mu sync.Mutex
used := false
tr := &http.Transport{
Proxy: nil, MaxConnsPerHost: 1, DisableCompression: true,
DialTLSContext: func(context.Context, string, string) (net.Conn, error) {
mu.Lock()
defer mu.Unlock()
if used {
return nil, errors.New("the Host closed the pairing connection")
}
used = true
return tc, nil
},
}
return &http.Client{Transport: tr, Timeout: windowExchangeTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
// windowPost POSTs one message of the exchange and decodes the answer (into
// out, when it is not nil).
func windowPost(c *http.Client, path string, in, out any) error {
body, err := json.Marshal(in)
if err != nil {
return err
}
resp, err := c.Post("https://"+tcpServerName+path, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != 200 {
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(rb, &e)
if e.Error != "" {
return fmt.Errorf("the Host refused: %s", e.Error)
}
return fmt.Errorf("%s: HTTP %d", path, resp.StatusCode)
}
if out == nil {
return nil
}
if err := json.Unmarshal(rb, out); err != nil {
return fmt.Errorf("%s: the Host's answer is not JSON", path)
}
return nil
}
// serveWindow is a pairing on the window, over the connection the Host
// opened. ca is the CA its chain carried (chainCA).
func (d *hostDoor) serveWindow(tc *tls.Conn, w *pairWindow, ca *x509.Certificate, src string) {
defer tc.Close()
if !d.windowBegin(w) {
return
}
defer d.windowEnd()
_ = tc.SetDeadline(time.Now().Add(windowExchangeTimeout))
cs := tc.ConnectionState()
leaf := sha256.Sum256(cs.PeerCertificates[0].Raw)
caSum := sha256.Sum256(ca.Raw)
c := oneConnClient(tc)
nb := make([]byte, 32)
if _, err := rand.Read(nb); err != nil {
return
}
var hello windowHelloResp
if err := windowPost(c, "/v1/pair/hello", windowHelloReq{V: 1, Nonce: hex.EncodeToString(nb)}, &hello); err != nil {
pairingAudit("window_failed", src, "refused: "+err.Error())
return
}
nh, err := hex.DecodeString(hello.Nonce)
if err != nil || len(nh) != 32 {
pairingAudit("window_failed", src, "refused: the Host's hello carries no 32-byte nonce")
return
}
want := windowProof(w.Code, "host", leaf[:], caSum[:], nb, nh)
if !hmac.Equal([]byte(want), []byte(strings.ToLower(hello.Proof))) {
d.windowFailed(w, src)
return
}
caPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw}))
cfg, res := windowCommit(c, w, hello, caPEM, func() string { return windowProof(w.Code, "bastion", leaf[:], caSum[:], nb, nh) })
done := windowDoneReq{V: 1, State: res.State, HostName: res.Host, AdmittedBy: res.AdmittedBy}
if res.Error != "" {
done = windowDoneReq{V: 1, State: "refused", Error: res.Error}
}
_ = windowPost(c, "/v1/pair/done", done, nil)
endWindow(w.ID, res)
if cfg == nil {
pairingAudit("window_refused", firstNonEmpty(firstNonEmpty(hello.HostName, hello.HostHandle), src), "refused: "+res.Error)
return
}
// Its lane connections follow from the same address: they count as a
// known Host's from the first (admitHandshake).
doorRememberHost(sourceKey(src), time.Now())
pairingAudit("window_paired", cfg.HostName, fmt.Sprintf("ok: paired through the window from %s, %s; it connects to this server on %s",
src, cfg.stateName(), cfg.doorAddr()))
}
// windowCommit is the pairing once the Host has proved the code: pair.go's
// rules, the redeem with this bastion's proof, the checks and the save.
// bastionProof makes the proof the redeem carries.
func windowCommit(c *http.Client, w *pairWindow, hello windowHelloResp, hostCA string, bastionProof func() string) (*Config, windowResult) {
refuse := func(err error) (*Config, windowResult) { return nil, windowResult{Error: err.Error()} }
handle := hello.HostHandle
if !validHostName(handle) {
return refuse(fmt.Errorf("the Host named itself %q, which is not a name this bastion keeps", handle))
}
if err := singletonGuardForPair(); err != nil {
return refuse(err)
}
cfgs, err := loadPairings()
if err != nil {
return refuse(err)
}
wantCA := caSHA256(hostCA)
replace := false
// The same Host pairing this server again through a fresh window: a Host
// whose connection presented the CA a pairing here already pins (its
// certificate verified under it, chainCA) is that pairing's Host, whatever
// handle it gives. Opening the window is the person's say-so to give it
// new credentials, so this replaces the pairing in place, its state, its
// anchor and its grants kept, with no `unpair` first.
key := handle
if same := doorPairingWithCA(cfgs, hostCA); same != nil {
key = same.HostName
}
existing, state, err := pairPrecheck(cfgs, key, false, false)
var already *alreadyPairedError
if errors.As(err, &already) {
// The same Host, its CA unchanged, through a window opened here: the
// person's say-so to give it new credentials.
if refusal := sameNameOtherHost(pairingFor(cfgs, key), wantCA); refusal != nil {
return refuse(refusal)
}
replace = true
existing, state, err = pairPrecheck(cfgs, key, true, false)
}
var second *localSecondHostError
if errors.As(err, &second) {
// Opening the window at this computer is the yes the terminal asks.
existing, state, err = pairPrecheckState(false, handle)
}
if err != nil {
return refuse(err)
}
if err := replaceKeepsTransport(existing, transportTCP); err != nil {
return refuse(err)
}
if err := tcpPinRefusal(existing, hostCA); err != nil {
return refuse(err)
}
if err := doorCARefusal(cfgs, existing, hostCA); err != nil {
return refuse(err)
}
wgPriv, wgPub, err := genWGKeypair()
if err != nil {
return refuse(err)
}
fp, err := genSecret(20)
if err != nil {
return refuse(err)
}
opKey, opCSR, err := genOperatorCSR(fp, true)
if err != nil {
return refuse(err)
}
// The person may have closed the window at the terminal, or it expired,
// during the exchange: the Host signs nothing then.
if !windowStillOpen(w.ID) {
return refuse(errors.New("the pairing window closed before the pairing finished; nothing was paired"))
}
hn, _ := os.Hostname()
var rr tcpRedeemResp
if err := windowPost(c, "/v1/pair/redeem", windowRedeemReq{V: 1, Proof: bastionProof(), WGPublicKey: wgPub,
OperatorCSR: opCSR, Hostname: hn, Platform: runtime.GOOS, Handle: handle}, &rr); err != nil {
return refuse(err)
}
if err := windowAnswerRefusal(rr, hostCA, opKey); err != nil {
return refuse(err)
}
// The redeemed name is authoritative, as on every other path.
if prev := pairingFor(cfgs, rr.hostName()); prev != nil && (existing == nil || prev.HostName != existing.HostName) {
if refusal := sameNameOtherHost(prev, wantCA); refusal != nil {
return refuse(refusal)
}
if err := replaceKeepsTransport(prev, transportTCP); err != nil {
return refuse(err)
}
existing, replace = prev, true
if prev.State == pairingRevoked {
state = pairingPending
} else {
state = prev.State
}
}
rr.LaneAddress = ""
cfg, err := tcpPairingConfig(rr, cfgs, existing, replace, wgPriv, opKey, fp, hostCA)
if err != nil {
return refuse(err)
}
cfg.HostDials, cfg.DoorAddr, cfg.LaneAddr = true, w.Door, ""
if !claimWindow(w.ID) {
return refuse(errors.New("the pairing window closed before the pairing finished; nothing was paired"))
}
cfg, all, err := commitPairing(cfg, existing, replace, state)
if err != nil {
return refuse(err)
}
if err := carryAnchorToNewName(existing, cfg); err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
}
took, aerr := anchorAfterWindow(cfg, all, w.Authority)
if aerr != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", aerr)
}
if took.Taken {
if fresh, lerr := loadPairings(); lerr == nil {
if c := pairingFor(fresh, cfg.HostName); c != nil {
cfg = c
}
}
}
res := windowResult{Host: cfg.HostName, State: cfg.stateName(), Rotated: replace,
Taken: took.Taken, Why: took.Why, Replaced: took.Replaced, Waiting: took.Waiting}
if !cfg.active() {
res.AdmittedBy = admitter()
}
return cfg, res
}
// windowAnswerRefusal checks a Host's redeem answer before anything is saved:
// the window's transport, every field, a name that can be a file name, the CA
// the one its connection presented, the operator certificate ours.
func windowAnswerRefusal(rr tcpRedeemResp, hostCA, opKey string) error {
if rr.Transport != windowTransport {
return fmt.Errorf("the Host answered transport %q; a Host that connects to this server answers %q", rr.Transport, windowTransport)
}
for k, v := range map[string]string{"operator_cert": rr.operatorCert(), "ca_cert": rr.caCert(), "bearer": rr.Bearer,
"host_name": rr.hostName()} {
if v == "" {
return fmt.Errorf("redeem response missing %s", k)
}
}
if !validHostName(rr.hostName()) {
return fmt.Errorf("redeem response has an unsafe host name %q — refusing", rr.hostName())
}
if !sameCAs(rr.caCert(), hostCA) {
return errors.New("the Host answered with a CA other than the one its connection presented — refusing")
}
return operatorCertRefusal(rr.operatorCert(), hostCA, opKey)
}
// carryAnchorToNewName: the anchor's Host paired again through a window under
// another name, its CA the one the anchor's pairing pinned (doorPairingWithCA),
// so it is the same Host and stays in charge under its new name, its pinned
// verify key kept, as claim-anchor --repin does for a renamed anchor. Every
// other case leaves the anchor as it is, and so does a process without
// machine authority (every anchor write needs it, claim-anchor --repin's too).
func carryAnchorToNewName(existing, cfg *Config) error {
if existing == nil || cfg == nil || existing.HostName == cfg.HostName || !sameCAs(existing.CACert, cfg.CACert) {
return nil
}
if a := currentAnchor(); a == nil || a.AnchorHost != existing.HostName {
return nil
}
if !machineAuthorityHeld() {
return fmt.Errorf("%s was in charge of this machine as %s; moving that to its new name needs machine authority: `privatae-bastion claim-anchor --repin` (%s)",
cfg.HostName, existing.HostName, adminHowTo())
}
a := currentAnchor()
if a == nil || a.AnchorHost != existing.HostName {
return nil
}
f, err := loadAnchorFile()
if err != nil || f == nil {
return err
}
owner := a.Owner
if oc := currentOwnerClaim(); oc != nil && oc.Owner != "" {
owner = oc.Owner
}
if err := writeAnchorPreservingPin(owner, cfg.HostName, f.VerifyPubkey); err != nil {
return fmt.Errorf("%s was in charge of this machine as %s, and moving that to its new name failed: %w", cfg.HostName, existing.HostName, err)
}
pairingAudit("anchor_rename", cfg.HostName, "ok: in charge of this machine as before, now under this name (it was "+existing.HostName+")")
return nil
}
// doorPairingWithCA is the pairing whose Host connects to this server and
// whose pinned CA is hostCA, or nil.
func doorPairingWithCA(cfgs []*Config, hostCA string) *Config {
for _, o := range cfgs {
if o.hostDials() && sameCAs(o.CACert, hostCA) {
return o
}
}
return nil
}
// doorCARefusal: two pairings whose Hosts connect to this server never pin
// the same CA, because the CA is how the door tells their connections apart.
func doorCARefusal(cfgs []*Config, existing *Config, hostCA string) error {
for _, o := range cfgs {
if existing != nil && o.HostName == existing.HostName {
continue
}
if o.hostDials() && sameCAs(o.CACert, hostCA) {
return fmt.Errorf("this Host is already paired with this server as %s; nothing was paired", o.HostName)
}
}
return nil
}
// ── pair --open ──────────────────────────────────────────────────────────────
// parsePairOpenArgs reads `pair --open [--port N]`. ok is false when the
// arguments are not a --open pair at all.
func parsePairOpenArgs(args []string) (addr string, ok bool, err error) {
port := ""
for i := 0; i < len(args); i++ {
switch {
case args[i] == "--open":
ok = true
case args[i] == "--port" && i+1 < len(args):
port = args[i+1]
i++
case strings.HasPrefix(args[i], "--port="):
port = strings.TrimPrefix(args[i], "--port=")
default:
if ok {
return "", true, fmt.Errorf("pair --open takes only --port <port>, not %s", args[i])
}
}
}
if !ok {
return "", false, nil
}
if port == "" {
return doorDefaultAddr, true, nil
}
var n int
if _, err := fmt.Sscanf(port, "%d", &n); err != nil || n < 1 || n > 65535 || fmt.Sprint(n) != port {
return "", true, fmt.Errorf("--port %q is not a port", port)
}
return ":" + port, true, nil
}
// cmdPairOpen opens the window, says what to give the Host, and waits for it.
func cmdPairOpen(addr string, out io.Writer) error {
if err := singletonGuardForPair(); err != nil {
return err
}
if _, err := loadPairings(); err != nil {
return err
}
code, err := newWindowCode()
if err != nil {
return err
}
id, err := genSecret(9)
if err != nil {
return err
}
now := time.Now().UTC()
w := &pairWindow{ID: id, Code: code, Door: addr, Opened: now.Format(time.RFC3339),
Expires: now.Add(windowLifetime).Format(time.RFC3339), Authority: machineAuthorityHeld()}
// A door of this window's own, unless the running bastion's door holds the
// port: it serves the window then (it reads this file on every connection).
ln, lerr := listenDoor(addr)
if lerr != nil && pairDaemonRunning() && !doorHeldByRunningBastion(addr) {
// The running bastion may still hold the port for a pairing just
// removed (`unpair`): it lets go once it sees the file gone, within a
// supervision period. Wait for that rather than fail.
ln, lerr = awaitDoorReleased(addr, out)
}
if lerr != nil && !(pairDaemonRunning() && doorHeldByRunningBastion(addr)) {
return fmt.Errorf("cannot listen on port %s: %v. Another program holds it; choose another with --port", doorPort(addr), lerr)
}
windowFileMu.Lock()
err = saveWindowFile(w)
windowFileMu.Unlock()
if err != nil {
if ln != nil {
_ = ln.Close()
}
return err
}
// The way in through this machine's own firewall, while the window is
// open (doorfw.go). Released last, once the window file is gone: it stays
// if the pairing made here is active, as the daemon's door then serves it.
fwSaid := doorFirewallEnsure(addr)
defer doorFirewallRelease(addr)
// closed closes the window and says how it ended: a pairing a door had
// already claimed it for is reported, never "nothing was paired".
closed := func(why string, err error) error {
if removeWindow(id) {
if res := awaitClaimedResult(id); res != nil {
return reportWindow(out, res)
}
}
pairingAudit("window_closed", addr, why)
return err
}
defer removeWindow(id)
if ln != nil {
d := newHostDoor(addr, false)
go d.serve(ln)
defer ln.Close()
}
pairingAudit("window_opened", addr, "ok: a pairing window for one Host until "+w.Expires)
printWindow(out, w, fwSaid)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
defer signal.Stop(sig)
tick := time.NewTicker(windowPollEvery)
defer tick.Stop()
for {
select {
case <-sig:
return closed("closed by the person at this server", errors.New("the pairing window is closed; nothing was paired"))
case <-tick.C:
}
if claimedWindow(id) != nil {
if res := awaitClaimedResult(id); res != nil {
return reportWindow(out, res)
}
}
cur := readWindowFile()
if cur == nil || cur.ID != id {
return errors.New("another pairing window was opened on this server, so this one is closed; nothing was paired here")
}
if cur.Result != nil {
return reportWindow(out, cur.Result)
}
if cur.expired(time.Now()) {
return closed("closed after "+windowLifetime.String(), fmt.Errorf("the pairing window closed after %d minutes and nothing was paired. Run `privatae-bastion pair --open` again when your Host is ready", int(windowLifetime.Minutes())))
}
}
}
// doorReleaseWait is how long `pair --open` waits for the running bastion to
// let go of the door's port after its last pairing there was removed: a
// supervision period for it to see that (run.go superviseOnce), and the
// pairing's stop. A variable so a test can shorten it.
var doorReleaseWait = supervisePeriod + 10*time.Second
// awaitDoorReleased binds addr once the running bastion lets go of it, or
// returns the last bind error after doorReleaseWait. Only an address in use
// is waited for; any other error is returned at once.
func awaitDoorReleased(addr string, out io.Writer) (net.Listener, error) {
deadline := time.Now().Add(doorReleaseWait)
said := false
for {
ln, err := listenDoor(addr)
if err == nil || !addrInUse(err) || !time.Now().Before(deadline) {
return ln, err
}
if !said {
fmt.Fprintf(out, "Port %s is in use, most likely by the bastion running here for a pairing that was removed; waiting for it to let go (at most %d seconds)...\n",
doorPort(addr), int(doorReleaseWait.Seconds()))
said = true
}
time.Sleep(doorReleasePoll)
}
}
// addrInUse: err is a bind refused because the address is in use (Windows
// says so as WSAEADDRINUSE, 10048).
func addrInUse(err error) bool {
var errno syscall.Errno
return errors.As(err, &errno) && (errno == syscall.EADDRINUSE || uintptr(errno) == 10048)
}
// doorReleasePoll is how often awaitDoorReleased tries the port again.
var doorReleasePoll = 250 * time.Millisecond
// doorHeldByRunningBastion: a pairing served here has its Host connect on
// addr, so the running bastion's door is what holds the port.
func doorHeldByRunningBastion(addr string) bool {
cfgs, err := loadPairings()
if err != nil {
return false
}
for _, c := range cfgs {
if c.hostDials() && c.active() && c.doorAddr() == addr {
return true
}
}
return false
}
// printWindow says, in the person's words, what to give their Host.
func printWindow(out io.Writer, w *pairWindow, fwSaid string) {
port := doorPort(w.Door)
fmt.Fprintf(out, "Your Host connects to this server. On your Host, choose \"Add a server\" and give it:\n\n")
fmt.Fprintf(out, " address: this server's address, port %s\n", port)
if addrs := machineAddrs(); len(addrs) > 0 {
fmt.Fprintf(out, " (this server's own addresses: %s - use the one your Host can reach)\n", strings.Join(addrs, ", "))
}
fmt.Fprintf(out, " code: %s\n\n", formatWindowCode(w.Code))
fmt.Fprintf(out, "The code works once, for %d minutes. Nothing on your Host's network needs opening;\n", int(windowLifetime.Minutes()))
fmt.Fprintf(out, "this server needs TCP port %s reachable from the internet.\n", port)
if fwSaid != "" {
fmt.Fprintln(out, fwSaid)
}
fmt.Fprintf(out, "Waiting for your Host... (Ctrl-C closes the window)\n")
}
// machineAddrs is this machine's own non-loopback unicast addresses.
func machineAddrs() []string {
ifs, err := net.InterfaceAddrs()
if err != nil {
return nil
}
var out []string
for _, a := range ifs {
ipn, ok := a.(*net.IPNet)
if !ok || ipn.IP.IsLoopback() || ipn.IP.IsLinkLocalUnicast() || ipn.IP.IsMulticast() {
continue
}
out = append(out, ipn.IP.String())
if len(out) == 6 {
break
}
}
return out
}
// reportWindow prints how the window ended: the pairing as `pair` reports
// one, or why nothing was paired.
func reportWindow(out io.Writer, res *windowResult) error {
if res.Error != "" {
return errors.New(res.Error)
}
cfgs, err := loadPairings()
if err != nil {
return err
}
cfg := pairingFor(cfgs, res.Host)
if cfg == nil {
return fmt.Errorf("the window reported %s paired, but no pairing for it is on this server", res.Host)
}
fmt.Fprintln(out)
pairReport(out, cfg, cfgs, anchorTake{Taken: res.Taken, Why: res.Why, Replaced: res.Replaced, Waiting: res.Waiting})
return nil
}