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 ¶
- type Config
- type Server
- func (s *Server) AuthURL() string
- func (s *Server) CertDomains() []string
- func (s *Server) Close() error
- func (s *Server) HTTP3TLSConfig() *tls.Config
- func (s *Server) ListenPacket(network, addr string) (net.PacketConn, error)
- func (s *Server) ListenTLS(addr string) (net.Listener, error)
- func (s *Server) MetricsDERPLatencies() map[string]float64
- func (s *Server) MetricsPeersOnline() int
- func (s *Server) MetricsState() int
- func (s *Server) Start(ctx context.Context) error
- func (s *Server) Status(ctx context.Context) (*ipnstate.Status, error)
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) HTTP3TLSConfig ¶ added in v0.1.4
HTTP3TLSConfig returns a tls.Config suitable for use with http3.Server. It uses GetCertificate to dynamically fetch certificates from the tailnet engine, ensuring QUIC handshakes work over tsnet without on-disk cert files.
func (*Server) ListenPacket ¶ added in v0.1.4
func (s *Server) ListenPacket(network, addr string) (net.PacketConn, error)
ListenPacket wraps the underlying tsnet.Server.ListenPacket.
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) MetricsDERPLatencies ¶ added in v0.1.4
MetricsDERPLatencies returns the current per-region DERP latency table from the tailnet, in seconds. v1 returns an empty map — `ipnstate.Status` doesn't surface DERP latencies directly (those live in the `netcheck` subsystem), and wiring an independent netcheck client into the bridge would balloon the dependency surface for marginal observability value. Follow-up: import `tailscale.com/net/netcheck` and run one probe per scrape if real operators ask for it.
func (*Server) MetricsPeersOnline ¶ added in v0.1.4
MetricsPeersOnline counts peers reported online by the tailnet's most recent Status() snapshot. A 1-second context cap defends against a hung control-plane stalling a Prometheus scrape.
Returns zero before Start completes OR if the Status() call fails — both indistinguishable on the wire but documented in the metric's Help string ("Count of peers reported online via tsnet Status()"; absence is honest).
func (*Server) MetricsState ¶ added in v0.1.4
MetricsState returns the abstract state value the /metrics + /v1/diagnostics surfaces consume:
- 0: down (Start() not yet called, or Close() torn it down)
- 1: starting (Start() in flight, before Up() returns)
- 2: running (Start() succeeded; node is live on the tailnet)
**Disabled** mode (tailscale.mode=disabled in bridge.yaml) never constructs a Server, so the metrics collector's "no provider registered" branch handles that case; this method is only ever called on a real Server instance.
Lock-light: reads `started` / `starting` under the same lifecycle mutex Start/Close already use. No control-plane I/O.
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.