tls

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 8, 2026 License: MIT Imports: 20 Imported by: 0

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

View Source
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

func DefaultPaths(dataDir string) (certPath, keyPath string)

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

func FingerprintFromDER(der []byte) string

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

func Generate(certPath, keyPath, hostname string) error

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

func ParseHostFromURL(raw string) (host string, isIP bool)

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

func Inspect(certPath string) (CertInfo, error)

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:

  1. The self-signed cert at <dataDir>/server.crt, served for LAN / mDNS / IP-literal connections. iOS clients pin its fingerprint at first contact.
  2. (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

Get is the `tls.Config.GetCertificate` callback. Returns the LE cert when:

  1. The client supplied an SNI hostname (no SNI → self-signed; rare in 2026 but legal per RFC 6066, e.g. older curl with --resolve)
  2. The SNI ends in the configured MagicDNS suffix
  3. 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

func (m *Manager) SetMagicDNSSuffix(suffix string)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL