Documentation
¶
Overview ¶
Package tls mints a self-signed certificate on first run and loads existing cert/key material on subsequent runs.
The generated certificate is ECDSA P-256, valid for 397 days, with SANs covering localhost, 127.0.0.1, ::1, 0.0.0.0, and the provided hostname (if any). iOS clients pin by the SHA-256 fingerprint captured during pairing, so the SANs are mostly a convenience for browser-based debugging — the pin is what actually secures the session.
**Why 397 days, not 10 years.** Apple ATS (and the underlying iOS SecureTransport stack) enforces a 398-day maximum validity for server certificates issued after 2020-09-01. A longer cert is rejected at the TLS-handshake layer *before* `URLSessionDelegate` is ever consulted — fingerprint pinning can't override the platform's baseline check. The previous 10-year duration shipped fine because ATS was relaxed via `NSAllowsLocalNetworking`, but iOS 26.4's lower-layer (Network.framework) path applies the 398-day rule independently. Capping at 397 keeps a small safety margin under that ceiling.
**Operator UX.** Yearly rotation is now expected. Surfaced via:
- Startup log at `.notice` when the cert is within 30 days of expiry (operator gets a heads-up before iOS clients start failing to handshake).
- `bridge cert info` CLI + admin-console cert tile (DaysUntilExpiry).
- `bridge cert rotate` CLI + admin "Rotate" button — minting a new cert forces every paired iOS client to re-pair via `bridge://pair?...` deep-link or admin-console QR.
Index ¶
- Constants
- func CertNotAfter(cert *cryptotls.Certificate) (time.Time, error)
- func DefaultPaths(dataDir string) (certPath, keyPath string)
- func FingerprintFromDER(der []byte) string
- func Generate(certPath, keyPath, hostname string) error
- func GenerateWithOptions(certPath, keyPath string, opts GenerateOptions) error
- func LoadOrGenerate(certPath, keyPath, hostname string) (*cryptotls.Certificate, string, error)
- func LoadOrGenerateWithOptions(certPath, keyPath string, opts GenerateOptions) (*cryptotls.Certificate, string, error)
- func LoadTailscaleCertFromDisk(certPath, keyPath string) (*cryptotls.Certificate, error)
- func ParseHostFromURL(raw string) (host string, isIP bool)
- type CertInfo
- type GenerateOptions
- type Manager
- func (m *Manager) AdminTLSConfig() *cryptotls.Config
- func (m *Manager) Get(hello *cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error)
- func (m *Manager) NextProtos() []string
- func (m *Manager) SetAutocertProvider(domain string, ...)
- func (m *Manager) SetMagicDNSSuffix(suffix string)
- func (m *Manager) SetTailscaleCert(cert *cryptotls.Certificate)
- func (m *Manager) TailscaleCert() *cryptotls.Certificate
Constants ¶
const ( CertFileName = "server.crt" KeyFileName = "server.key" )
Variables ¶
This section is empty.
Functions ¶
func CertNotAfter ¶ added in v0.1.2
func CertNotAfter(cert *cryptotls.Certificate) (time.Time, error)
CertNotAfter parses the leaf cert from a `tls.Certificate` and returns its NotAfter timestamp. **`tls.LoadX509KeyPair` does NOT populate `cert.Leaf`** — reading `cert.Leaf.NotAfter` directly is a nil-pointer panic waiting to happen (gotcha #1 from the plan review). This helper is the single approved way to read the expiry; every consumer (renewer, admin tile, SNI Get) MUST route through it.
**Hot-path optimisation**: `Manager.Get` calls this on every TLS handshake to gate LE-cert use on freshness. When the caller loaded the cert via `LoadTailscaleCertFromDisk`, `Leaf` is already populated — short-circuit on that field to avoid a per- handshake `x509.ParseCertificate` (DER parse + allocation). The fall-through path keeps the helper safe for raw `tls.LoadX509KeyPair` callers (Gemini on PR #102).
func DefaultPaths ¶
DefaultPaths returns the cert and key paths used when the user hasn't configured explicit TLS paths in bridge.yaml. Both live inside dataDir.
func FingerprintFromDER ¶
FingerprintFromDER returns the SHA-256 fingerprint of a DER-encoded cert in the canonical colon-separated uppercase-hex form. This is exactly what openssl x509 -fingerprint -sha256 and Safari's certificate inspector show, so users can verify by eye during pairing.
func Generate ¶ added in v0.1.1
Generate (re-)mints the cert + key at the given paths. Used by `bridge cert rotate` for an operator-driven rotation, and internally by `LoadOrGenerate` for first-run minting.
**Performs an unconditional write** — `writePEM` opens with `O_TRUNC`, so any pre-existing files at `certPath` / `keyPath` are overwritten. The first-run path in `LoadOrGenerate` already gates on file-existence before calling Generate; the CLI rotate path explicitly removes the existing files first to make the failure-mode-on-perm-error clearer. Callers that don't want to blow away an existing cert must check themselves before calling.
**A rotated cert always has a new SHA-256 fingerprint** — even if the public key is unchanged, the cert binary differs (NotBefore / NotAfter / serial number) and iOS pins the cert, not the key. Operators must re-pair every device after a rotation; the admin console's per-token "Rotate" button or a fresh `bridge://pair?...` deep link is the supported path. Generate is the legacy 3-arg form. Equivalent to GenerateWithOptions with `GenerateOptions{Hostname: hostname}`. Kept so non-PR-5 call sites (CLI tests, future callers that don't need the broader SAN list) don't have to reach for the options struct.
func GenerateWithOptions ¶ added in v0.1.2
func GenerateWithOptions(certPath, keyPath string, opts GenerateOptions) error
GenerateWithOptions (re-)mints the cert + key at the given paths. Used by `bridge cert rotate` for an operator-driven rotation (where the broader SAN list matters), and internally by `LoadOrGenerateWithOptions` for first-run minting.
See also `Generate` (legacy 3-arg shim).
func LoadOrGenerate ¶
func LoadOrGenerate(certPath, keyPath, hostname string) (*cryptotls.Certificate, string, error)
LoadOrGenerate loads the cert+key at the given paths, or mints a new self-signed ECDSA P-256 pair if both files are absent. hostname (if non- empty) is added to the cert's SANs alongside the default loopback entries.
Returns the loaded certificate and its SHA-256 fingerprint in the standard colon-separated uppercase-hex form ("AB:CD:..."), ready to display in the iOS pairing UI and to compare on the client side for pinning.
Backwards-compat shim: callers that need the broader SAN options (LAN / Tailscale / CustomEndpoints) call LoadOrGenerateWithOptions.
func LoadOrGenerateWithOptions ¶ added in v0.1.2
func LoadOrGenerateWithOptions(certPath, keyPath string, opts GenerateOptions) (*cryptotls.Certificate, string, error)
LoadOrGenerateWithOptions is the SAN-aware path. New installs mint a cert with the full SAN set (hostname + ExtraDNSNames + loopback IPs + ExtraIPs); existing installs LOAD the on-disk cert without regenerating, even if the cert's SANs no longer match `opts` — that would silently break iOS pinning. A SAN mismatch is logged at `.notice` so operators see the staleness signal in the startup log and can drive a deliberate `bridge cert rotate` (every paired iOS device must re-pair afterward; we don't pay that cost without operator consent).
func LoadTailscaleCertFromDisk ¶ added in v0.1.2
func LoadTailscaleCertFromDisk(certPath, keyPath string) (*cryptotls.Certificate, error)
LoadTailscaleCertFromDisk is a convenience for the auto-mint / renewer paths: opens the PEM cert + key files, builds a `tls.Certificate`, and parses the leaf so callers can read the expiry without falling into the LoadX509KeyPair-sets-nil-Leaf trap. Returns the cert with `Leaf` populated.
func ParseHostFromURL ¶ added in v0.1.2
ParseHostFromURL extracts the bare hostname from a URL string and reports whether the host is an IP literal. Used by the SAN-gather pipeline to route `cfg.CustomEndpoints` entries into either `ExtraDNSNames` (DNS hostnames) or `ExtraIPs` (IP literals) — the raw URL string can't go directly into a SAN slot, since x509 rejects `https://host:port` shapes with an opaque error.
Returns ("", false) for unparseable input or empty host.
Types ¶
type CertInfo ¶ added in v0.1.1
type CertInfo struct {
NotBefore time.Time `json:"notBefore"`
NotAfter time.Time `json:"notAfter"`
Fingerprint string `json:"fingerprint"`
DaysUntilExpiry int `json:"daysUntilExpiry"`
Subject string `json:"subject"`
}
CertInfo describes the on-disk certificate. Returned by Inspect and surfaced by the admin `GET /api/cert` endpoint + the `bridge cert info` CLI. Fingerprint is in the canonical colon-separated uppercase-hex form.
func Inspect ¶ added in v0.1.1
Inspect parses the PEM cert at `certPath` and returns its metadata. Used by the admin's cert-tile endpoint and the CLI `bridge cert info` command. No live validation is performed (the operator's view of the on-disk cert is what we report); expired certs are surfaced via DaysUntilExpiry being zero or negative, not by erroring out.
type GenerateOptions ¶ added in v0.1.2
type GenerateOptions struct {
// Hostname is the host's short or fully-qualified hostname, added
// to the SAN DNSNames as a convenience for browser-based access
// to the admin console. Empty / "localhost" is silently dropped
// (localhost is already in the defaults).
Hostname string
// ExtraDNSNames is the operator-relevant DNS list — Tailscale
// MagicDNS (`*.ts.net`), parsed hosts of `cfg.CustomEndpoints`,
// reverse-proxy front-doors. Each entry must be a bare hostname
// (no scheme, no port). Use `parseHostFromURL` to get the
// hostname out of a URL string.
ExtraDNSNames []string
// ExtraIPs is the operator-relevant IP list — every up,
// non-loopback, non-virtual-switch interface IP plus the
// Tailscale CGNAT addresses surfaced by `tailscale status`.
// loopback is always included regardless of this slice.
ExtraIPs []net.IP
}
GenerateOptions bundles the SAN inputs to `Generate`. Empty / nil fields fall back to the legacy hardcoded set (loopback + Hostname).
ExtraDNSNames + ExtraIPs are unioned with the defaults; duplicates across "default + extra" are silently deduped at cert-build time.
type Manager ¶ added in v0.1.2
type Manager struct {
// contains filtered or unexported fields
}
Manager owns the cert(s) the bridge serves and routes incoming TLS handshakes to the right cert by SNI. The bridge can carry up to THREE active certs at a time:
- The self-signed cert at <dataDir>/server.crt, served for LAN / mDNS / IP-literal connections. iOS clients pin its fingerprint at first contact.
- (Optional) A Tailscale-issued Let's Encrypt cert at <dataDir>/tls/tailscale.crt, served when the connecting client's SNI hostname is under the local node's MagicDNS suffix (e.g. *.<tailnet>.ts.net). iOS clients on the LE path do NOT pin — the LE chain validates standard ATS, and the tailnet tunnel already authenticates the peer.
- (Optional, public-mode only) A native autocert-minted LE cert for the operator's configured public domain (set via `SetAutocertProvider` after `internal/tlsacme.New`). Served when the SNI matches the configured domain exactly. iOS clients on this path do NOT pin either — same LE-chain reasoning as the Tailscale cert.
**Why a Manager and not just a `tls.Config{Certificates: ...}`**: `tls.Config.Certificates` matches by SAN, which would push the LE cert past the self-signed cert ONLY when the server name ends in the tailnet suffix — but Go's matching is implicit and depends on SAN-list correctness for both certs. An explicit `GetCertificate` callback makes the routing predicate visible and unit-testable, and future work (per-host pinning policy, cert pre-load timing, etc.) has one obvious place to land.
Concurrency: `Manager.Get` is called from the TLS handshake goroutine for every accepted connection. The Tailscale cert is stored in an `atomic.Pointer` so the renewer (which runs in a separate goroutine) can swap a fresh cert in mid-flight without a mutex on the hot path. The MagicDNS suffix is stored in an `atomic.Value` for the same reason. The autocert provider is also `atomic.Pointer`-held + atomic.Value-name'd; production wiring sets these once at boot and never touches them again, but the atomic shape future-proofs against a runtime SetAutocertProvider (e.g. for a TLS renewal that swaps the underlying autocert.Manager).
func NewManager ¶ added in v0.1.2
func NewManager(selfSigned *cryptotls.Certificate) *Manager
NewManager constructs a Manager with the self-signed cert as the only routing target. Tailscale auto-pilot calls SetTailscaleCert / SetMagicDNSSuffix after detection succeeds; autocert wiring calls SetAutocertProvider when `cfg.Autocert.Enabled` is true.
func (*Manager) AdminTLSConfig ¶ added in v0.1.4
AdminTLSConfig returns a tls.Config suitable for wrapping the admin listener via tls.NewListener. The config's GetCertificate is bound to this Manager's `Get` so the admin listener serves the same SNI-routed cert pool as the public API (autocert cert for the operator's domain, self-signed for direct-IP / unknown SNI). NextProtos merges the autocert ALPN proto with the standard HTTP/1.1 + HTTP/2 entries so the admin listener handles both real admin traffic AND the TLS-ALPN-01 challenge (the challenge runs against TCP/443 by spec; deployments where the admin listener IS the :443 listener get both for free).
func (*Manager) Get ¶ added in v0.1.2
func (m *Manager) Get(hello *cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error)
Get is the `tls.Config.GetCertificate` callback. SNI precedence:
- **Normalize SNI once at the top** — lowercase + trailing-dot strip. The TLS ClientHello can carry a trailing dot (FQDN form) AND mixed-case host (RFC 4343: DNS comparison is case-insensitive). Without normalization, "BRIDGE.EXAMPLE.COM." mis-matches a stored lowercase "bridge.example.com" and falls through to self-signed, causing iOS ATS rejection on a perfectly-routed handshake.
- **autocert path** (PR 3): SNI matches the configured public domain exactly. Delegates to the autocert hook's GetCertificate, which mints / serves the LE cert. On autocert error, falls through to self-signed (logged elsewhere — the renewer surfaces the failure to the admin tile).
- **Tailscale path**: SNI ends in the configured MagicDNS suffix AND the Tailscale cert is loaded + non-expired.
- **Fallback**: self-signed cert.
Empty SNI (rare in 2026 but legal per RFC 6066, e.g. older curl with --resolve) goes straight to self-signed.
Order: autocert before Tailscale because an operator running public mode with Tailscale also enabled (legitimate — tailnet- routed public bridge) wants the LE cert to win against the public domain SNI even if it happens to share a suffix with the MagicDNS zone. In practice the two never overlap (autocert domain is the operator's public FQDN; MagicDNS is *.ts.net), but the precedence is documented.
func (*Manager) NextProtos ¶ added in v0.1.4
NextProtos returns the ALPN proto-ids the wired providers need the underlying listener to advertise. Public-API + admin listener tls.Config merge this with their own NextProtos (typically "h2", "http/1.1"). Returns nil when no provider has custom requirements — callers default to the standard proto list.
func (*Manager) SetAutocertProvider ¶ added in v0.1.4
func (m *Manager) SetAutocertProvider(domain string, getCert func(*cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error), nextProtos []string)
SetAutocertProvider installs (or clears, when both args are zero-valued) the autocert SNI route. Safe to call from any goroutine; the swap is atomic.
domain is the operator's configured public hostname (normalized to lowercase, trailing-dot stripped — pass the value `tlsacme.Manager.Domain()` returns directly).
getCert is the autocert-side GetCertificate function — typically `tlsacmeManager.GetCertificate`. Pass nil to clear the route.
nextProtos is the ALPN proto-id list autocert needs the listener to advertise (`acme.ALPNProto`). Empty when clearing.
func (*Manager) SetMagicDNSSuffix ¶ added in v0.1.2
SetMagicDNSSuffix records the local tailnet's MagicDNS suffix (e.g. "sable-eagle.ts.net"). Empty means "Tailscale not configured / not detected" — Get falls through to self-signed for every connection, same as if the LE cert weren't loaded.
func (*Manager) SetTailscaleCert ¶ added in v0.1.2
func (m *Manager) SetTailscaleCert(cert *cryptotls.Certificate)
SetTailscaleCert installs a freshly-loaded LE cert. Safe to call from any goroutine; the swap is atomic. Pass nil to clear (e.g. when the renewer detects the on-disk cert was deleted out from under the running bridge).
func (*Manager) TailscaleCert ¶ added in v0.1.2
func (m *Manager) TailscaleCert() *cryptotls.Certificate
TailscaleCert returns the current LE cert, or nil if none is loaded. Used by the admin tile + renewer for freshness checks.