Documentation
¶
Overview ¶
Package tsnet wraps the upstream tailscale.com/tsnet library so the bridge can run as its own embedded tailnet node — no external `tailscaled` daemon, no CLI shell-out, no on-disk Let's Encrypt cert files.
The bridge's tailscale integration has two paths:
mode=cli (default, internal/tailscale): shells out to the `tailscale` CLI for endpoint detection (`tailscale status --json`) and LE cert minting (`tailscale cert <magicDNS>`). Requires a host-level tailscaled and `tailscale` in $PATH; LE cert/key files are written to disk for the SNI cert switcher to load. This is the historical flow.
mode=tsnet (this package): the bridge process IS the tailnet node. tsnet.Server.ListenTLS terminates LE in-process, so there's no on-disk cert material at all and no SNI switcher is needed. State (machine identity) persists under <dataDir>/tailscale/.
The two paths converge on the same internal/api mux. Operators opt into tsnet via `tailscale.mode: tsnet` in bridge.yaml; the CLI path stays default until the tsnet path soaks for at least one release.
Lifecycle is owned by cmd/bridge/main.go: construct a *Server, call Start (blocks until tailnet-reachable; surfaces an interactive AuthURL on first run when no AuthKey is supplied), call ListenTLS to get the second http listener for *.ts.net connections, and call Close on shutdown to drain magicsock / netcheck / control-plane goroutines (without Close, integration tests would leak goroutines per spawn).
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// AuthKey, if non-empty, is used on first run. Empty triggers
// interactive OAuth (Server.Start prints the AuthURL via the
// logger and blocks until the operator authorizes in a browser).
// Once tsnet has persisted state, this field is ignored.
//
// Precedence at startup (Tailscale-standard idiom):
// 1. TS_AUTHKEY environment variable (preferred — keeps
// secrets out of yaml-on-disk)
// 2. This field (fallback for ops who can't set env vars)
// 3. Empty → triggers interactive OAuth on first run
//
// See startUnlocked for the resolution code.
AuthKey string
// Hostname is the magic-DNS hostname tsnet registers with.
// Empty falls back to the deviceName / library name supplied
// by the caller via this field.
Hostname string
// StateDir is the on-disk location of tsnet's state store.
// MUST be set explicitly — empty causes tsnet to fall into
// ephemeral mode (re-registers as a new device on every restart,
// surprises the operator's admin console). Typically
// <dataDir>/tailscale/, mode 0700 on POSIX.
StateDir string
// Logger receives both UserLogf (operator-facing AuthURL +
// status) and Logf (verbose backend). The two channels are
// adapted into different slog levels — see logfFromSlog and
// noisyLogfFromSlog.
Logger *slog.Logger
}
Config carries everything NewServer needs. Mode is unused here — the dispatcher in cmd/bridge/main.go branches on it before constructing a *Server. Logger MUST be non-nil.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server wraps tailscale.com/tsnet.Server with the bridge-specific lifecycle (interactive auth, slog-routed logging, secure state-dir guard, idempotent Start/Close). Construct with NewServer; call Start once to bring the node up; call Close on shutdown.
Concurrency model: Start and Close are caller-serialized in production (cmd/bridge/main.go single-threaded). Read paths (Status, ListenTLS, CertDomains, AuthURL) AND the userLogf callback (invoked from tsnet's internal goroutines during the Up() phase) need cheap, non-blocking access — so the lock surface is split:
- lifecycleMu protects started + server. Start releases this lock during the long Up() I/O so userLogf can take authMu without deadlocking. Close holds it through the upstream Close() call (~ms).
- authMu protects authURL only. userLogf takes it for ~µs per write; AuthURL() takes it for one read. Doesn't block on Up() I/O.
Caller-side: Start and Close are NOT safe to call concurrently. The wrapper detects double-Start (second caller sees the in-progress flag and returns an error) but doesn't try to be fully reentrant — cmd/bridge/main.go is the sole caller and runs them serially.
func NewServer ¶
NewServer validates the configuration and constructs an unstarted Server. The actual tailnet-up dance happens in Start.
func (*Server) AuthURL ¶
AuthURL returns the most recent interactive-auth URL captured from UserLogf, or empty if none was emitted. Used by `bridge tsnet auth` to echo the URL explicitly to the operator's terminal in case the slog output was filtered or piped elsewhere.
Read path is on a separate mutex from the lifecycle state so AuthURL() polls during a long Up() don't block on Start.
func (*Server) CertDomains ¶
CertDomains returns the magic-DNS names tsnet will serve LE certs for. Empty slice before Start.
func (*Server) Close ¶
Close tears down the embedded tsnet.Server. MUST be called on bridge shutdown to drain magicsock / netcheck / control-plane goroutines — without it, every Start/Close cycle (e.g. integration tests) leaks goroutines until process exit.
func (*Server) ListenTLS ¶
ListenTLS returns an HTTPS listener with auto-renewing Let's Encrypt certs. Backed by tsnet's in-process ACME — there's no on-disk cert material. addr should be ":443" or ":7798"; the network is always "tcp".
func (*Server) Start ¶
Start brings the tailnet node up. Idempotent: a second Start on an already-started server is a no-op (returns nil). A concurrent Start while another is in flight returns an error rather than racing — cmd/bridge/main.go calls Start serially, so this is a programmer-error guard, not a performance path.
First-run behaviour: if no AuthKey is configured AND no persisted state exists, tsnet emits an AuthURL via UserLogf — Start blocks until the operator opens the URL and authorizes the device.
Subsequent runs (state persisted) re-authenticate from the state store and return as soon as the tailnet is reachable.