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
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 two 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.
**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.
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.
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. Returns the LE cert when:
- The client supplied an SNI hostname (no SNI → self-signed; rare in 2026 but legal per RFC 6066, e.g. older curl with --resolve)
- The SNI ends in the configured MagicDNS suffix
- The Tailscale cert is loaded and non-expired
All other branches return the self-signed cert. The renewer tries hard to keep the LE cert fresh, but if it slips past expiry the honest fallback is "serve self-signed and let ATS reject" — same behaviour as a host without Tailscale at all, no surprise.
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.