bastion 0.55.3
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s

This commit is contained in:
Privatae
2026-10-25 23:16:45 +00:00
parent 77b7853ae1
commit 8822d5df85
136 changed files with 8638 additions and 1641 deletions
+126 -36
View File
@@ -16,6 +16,7 @@ import (
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
@@ -31,11 +32,17 @@ import (
// deploy/publish.sh + deploy/VERSION). The "dev" default flags an unstamped local build.
var clientVersion = "dev"
// b64ToHex turns a base64 WireGuard key into the hex the device's
// configuration text takes. A key is 32 bytes; anything else is refused here
// rather than by the device.
func b64ToHex(b64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", err
}
if len(raw) != 32 {
return "", fmt.Errorf("a WireGuard key is 32 bytes, this one is %d", len(raw))
}
return hex.EncodeToString(raw), nil
}
@@ -131,6 +138,11 @@ func cmdRun() error {
}
}
if started == 0 {
for _, cfg := range cfgs {
if cfg.invalid != "" {
return fmt.Errorf("no pairing can be served — %s", cfg.invalid)
}
}
return fmt.Errorf("no active pairing — every pairing is pending or revoked; the anchor host must approve one")
}
go superviseLoop(ctx)
@@ -204,6 +216,10 @@ type pairingRun struct {
// pairing — it is a file), when it is already running, or when the daemon is
// not running (a CLI process handling a grant has nothing to start).
func startPairing(cfg *Config) bool {
if cfg.invalid != "" {
fmt.Printf("[%s] not started (no tunnel, no lanes, no dirs): %s\n", cfg.HostName, cfg.invalid)
return false
}
if !cfg.active() {
fmt.Printf("[%s] %s — not started (no tunnel, no lanes, no dirs) until the anchor host approves it\n", cfg.HostName, cfg.stateName())
return false
@@ -648,20 +664,12 @@ func runPairing(ctx context.Context, cfg *Config) error {
// may ever wait on that.
go runDeltaRefresh(ctx)
beat() // first beat: flip online + bind fp now, don't wait a full interval
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
// Path failover BEFORE the beat: if the current relay entry has gone dead
// (no handshake for RejectAfterTime) the beat could not reach the host
// anyway; move to the next candidate first, no platform round trip.
maybeFailover(cfg, dev, startedAt)
beat()
}
}
// Every 60 s, and at once after a pairing on this box changes state
// (beatnow.go), so this Host learns of it without waiting for the tick.
// Path failover BEFORE a ticked beat: if the current relay entry has gone
// dead (no handshake for RejectAfterTime) the beat could not reach the
// host anyway; move to the next candidate first, no platform round trip.
runBeatLoop(ctx, 60*time.Second, func() { maybeFailover(cfg, dev, startedAt) }, beat)
}()
srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux}
@@ -852,16 +860,9 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
go runOtherBastionRefresh(ctx)
go runDeltaRefresh(ctx)
beat()
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
beat()
}
}
// Every 60 s, and at once after a pairing on this box changes state
// (beatnow.go).
runBeatLoop(ctx, 60*time.Second, nil, beat)
}()
srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux}
@@ -901,27 +902,112 @@ func heartbeatPayload(cfg *Config, dev interface{ IpcGet() (string, error) }, ho
// function of its own so a test can read back what a device holds after
// exactly the configuration runPairing applies.
func configureTunnelPeer(dev interface{ IpcSet(string) error }, cfg *Config) error {
privHex, err := b64ToHex(cfg.WGPrivateKey)
if err != nil {
return fmt.Errorf("wg private key: %w", err)
}
endpointPubHex, err := b64ToHex(cfg.EndpointPubKey)
if err != nil {
return fmt.Errorf("endpoint pubkey: %w", err)
}
hostCIDR, err := hostTunnelAllowedIP(cfg.HostTunnelIP)
uapi, err := tunnelPeerUAPI(cfg, cfg.Endpoint, cfg.EndpointPubKey, false)
if err != nil {
return err
}
// AllowedIPs is the host's tunnel alias ONLY — the tunnel can reach nothing else.
uapi := fmt.Sprintf("private_key=%s\npublic_key=%s\nendpoint=%s\npersistent_keepalive_interval=25\nallowed_ip=%s\n",
privHex, endpointPubHex, cfg.Endpoint, hostCIDR)
if err := dev.IpcSet(uapi); err != nil {
return fmt.Errorf("configure wireguard: %w", err)
}
return nil
}
// tunnelPeerUAPI is the configuration text that gives the device its one
// peer: the host at endpoint under pubkey, with the host's tunnel alias as
// the only allowed address. Both the boot-time configuration and the live
// endpoint swap build it here. The text is one setting per line and every
// value in it comes from the host, so each is checked before it is written:
// a newline in any of them would add settings of its own.
func tunnelPeerUAPI(cfg *Config, endpoint, pubkey string, replacePeers bool) (string, error) {
privHex, err := b64ToHex(cfg.WGPrivateKey)
if err != nil {
return "", fmt.Errorf("wg private key: %w", err)
}
pubHex, err := b64ToHex(pubkey)
if err != nil {
return "", fmt.Errorf("endpoint pubkey: %w", err)
}
if err := checkEndpoint(endpoint); err != nil {
return "", err
}
hostCIDR, err := hostTunnelAllowedIP(cfg.HostTunnelIP)
if err != nil {
return "", err
}
replace := ""
if replacePeers {
replace = "replace_peers=true\n"
}
// AllowedIPs is the host's tunnel alias ONLY — the tunnel can reach nothing else.
return fmt.Sprintf("private_key=%s\n%spublic_key=%s\nendpoint=%s\npersistent_keepalive_interval=25\nallowed_ip=%s\n",
privHex, replace, pubHex, endpoint, hostCIDR), nil
}
// checkEndpoint accepts one tunnel endpoint: an IP address and port
// ("203.0.113.7:51820", "[2001:db8::7]:51820") or an RFC 1123 host name and
// port, the port 1-65535. Anything else is refused, any space or control
// character included: the value is written into the configuration text.
func checkEndpoint(s string) error {
bad := fmt.Errorf("the endpoint %q is not a host:port — refusing to configure the tunnel to it", s)
for i := 0; i < len(s); i++ {
if s[i] <= ' ' || s[i] >= 0x7f {
return bad
}
}
if ap, err := netip.ParseAddrPort(s); err == nil {
if ap.Port() == 0 || ap.Addr().Zone() != "" || ap.Addr().IsUnspecified() {
return bad
}
return nil
}
if strings.HasPrefix(s, "[") {
return bad // brackets hold an IPv6 address, and this one did not parse
}
host, port, err := net.SplitHostPort(s)
if err != nil || !rfc1123Host(host) || len(port) > 5 || strings.Trim(port, "0123456789") != "" {
return bad
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return bad
}
return nil
}
// checkEndpoints applies checkEndpoint to each non-empty entry: a pairing
// over a socket has no endpoint, and an empty list entry is dropped by
// candidateList.
func checkEndpoints(eps ...string) error {
for _, e := range eps {
if e == "" {
continue
}
if err := checkEndpoint(e); err != nil {
return err
}
}
return nil
}
// rfc1123Host is a DNS host name: dot-separated labels of 1-63 letters,
// digits and hyphens, no label starting or ending with a hyphen.
func rfc1123Host(h string) bool {
if h == "" || len(h) > 253 {
return false
}
for _, label := range strings.Split(h, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
}
return true
}
// hostTunnelAllowedIP is the one allowed address the tunnel's peer gets: the
// host's tunnel alias as a single-address prefix (/128, or /32 for IPv4). The
// value arrives in the pairing response, and the platform sends one bare
@@ -991,6 +1077,10 @@ func cmdStatus() error {
}
fmt.Println()
for _, cfg := range cfgs {
if cfg.invalid != "" {
fmt.Printf("paired with: %s [%s]\n problem: %s\n", cfg.HostName, cfg.stateName(), cfg.invalid)
continue
}
live := "not answering"
if bastionListening(cfg.ListenAddr) {
live = "answering"