diff --git a/CHANGELOG.md b/CHANGELOG.md index debf31d..0fb6c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ +## Unreleased + +### Added + +- `tls:` checks the certificate a service presents. It fails when the leaf expires + within 7 days or is not yet valid, and it verifies the owning peer's MagicDNS name, so a + Tailscale IP, a short name and a `*.ts.net` name all work. `insecure_tls` skips chain and + name verification but still checks the leaf dates. The monitor UI has a TLS certificate + row. See [Checks](docs/reference/checks.md). + ## 0.3.0 - 2026-10-23 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0075314..fb6d119 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,8 @@ snapshot, so the manager closes the incident directly with `closeRemoved`. - `POST /api/v1/probe` holds the manager mutex only for validation, then runs each check through `runCheckWith` (the prober's error normalization) after `bindChecks` gives - `tsping` the shared resolver. A failing probe never refuses a save server-side; the UI + `tsping` the shared resolver and a `tls:` check its clock and that resolver. A + failing probe never refuses a save server-side; the UI gates Save on it with a confirm dialog. `guard` takes the audit `op` and always requires `editor`; an empty op is unaudited but still authorized (validate and `POST /route-preview` need `editor`). `guardRead` wraps `GET` @@ -54,6 +55,9 @@ registers the HTTP handlers. - `presence.go` is the IPN bus view. `tsping` name resolution uses it and falls back to the 30s `Status()` cache only while that view is unseeded. + A `tls:` check dials through `netEnv`, takes the verified name from the + owning peer's DNS name and not from a subnet route, and fails when the leaf + expires within 7 days. - `collector.local_only` runs the collector with no tailscaled (the ProxyGroup sidecar placement): `config.go` defaults a network-less target to `local` and rejects `network: tailnet`, `tsping`, the UI, `collector.setec`, and diff --git a/ROADMAP.md b/ROADMAP.md index 50730a9..6853f0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,8 +26,10 @@ Phase 2 of the original design, ordered by value to an operator who already runs PagerDuty account lets us run the live check (task 6.4 of `add-pagerduty-sink`) [M] - [ ] incident.io sink: HTTP alert source, `firing` and `resolved` paired by `deduplication_key`. OpenSpec change `add-incidentio-sink` drafted [M] -- [ ] TLS expiry check (`tls:` is rejected today). Also covers the `*.ts.net` +- [x] TLS expiry check. Also covers the `*.ts.net` certificates that Tailscale Services and Serve use [M] +- [ ] Per-check TLS expiry window (for example `tls:443|14d`) and tiered severity, so a + leaf can warn at 14 days and page at 3. The `tls:` check has a fixed 7-day window [M] - [ ] Emit `target.degraded`: path warnings (direct to DERP or relay) with latency baselines, without paging [L] - [ ] `tsnetEnv` collector for hosts without a daemon or root. It also unlocks setec where diff --git a/cmd/tail-watch/checks.go b/cmd/tail-watch/checks.go index e94a4c1..8cccb56 100644 --- a/cmd/tail-watch/checks.go +++ b/cmd/tail-watch/checks.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "io" @@ -32,14 +33,30 @@ const ( statusCacheTTL = 30 * time.Second icmpPermissionMsg = "cannot open an ICMP socket: set net.ipv4.ping_group_range or grant CAP_NET_RAW" + + // tlsExpiryWindow is how far out a leaf must remain valid. A leaf inside + // this window fails even though clients still accept it. + tlsExpiryWindow = 7 * 24 * time.Hour ) +type checkBinder interface { + bind(clock tstime.Clock, resolver *tsResolver) check +} + type check interface { String() string Kind() string Run(ctx context.Context, env netEnv, t *Target) (Result, error) } +func parsePort(portStr, kind string) (int, error) { + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("invalid %s port %q", kind, portStr) + } + return port, nil +} + func parseCheck(s string) (check, error) { switch { case s == checkICMP: @@ -47,12 +64,15 @@ func parseCheck(s string) (check, error) { case s == checkTSPing: return tspingCheck{}, nil case strings.HasPrefix(s, "tls:"): - return nil, errors.New("tls checks are not supported in this version") + port, err := parsePort(strings.TrimPrefix(s, "tls:"), "TLS") + if err != nil { + return nil, err + } + return tlsCheck{port: port, clock: nil, resolver: nil, roots: nil}, nil case strings.HasPrefix(s, "tcp:"): - portStr := strings.TrimPrefix(s, "tcp:") - port, err := strconv.Atoi(portStr) - if err != nil || port < 1 || port > 65535 { - return nil, fmt.Errorf("invalid TCP port %q", portStr) + port, err := parsePort(strings.TrimPrefix(s, "tcp:"), "TCP") + if err != nil { + return nil, err } return tcpCheck{port: port}, nil case strings.HasPrefix(s, "http:"): @@ -175,6 +195,145 @@ func (c tcpCheck) Run(ctx context.Context, env netEnv, t *Target) (Result, error return res, nil } +type tlsCheck struct { + port int + clock tstime.Clock + resolver *tsResolver + // roots is the test trust pool. Nil uses the system roots. + roots *x509.CertPool +} + +func (c tlsCheck) String() string { return fmt.Sprintf("tls:%d", c.port) } +func (c tlsCheck) Kind() string { return "tls" } + +func (c tlsCheck) Run(ctx context.Context, env netEnv, t *Target) (Result, error) { + res := Result{Target: t, Check: c.String(), Kind: c.Kind(), + Start: time.Time{}, + Generation: 0, + Latency: 0, + Err: nil, + Ping: nil, + } + name := c.verifiedName(ctx, t.Addr) + cfg := &tls.Config{ + ServerName: name, + RootCAs: c.roots, + Time: c.now, + } + if t.InsecureTLS { + cfg.InsecureSkipVerify = true + } + start := time.Now() + raw, err := env.Dial(ctx, "tcp", net.JoinHostPort(t.Addr, strconv.Itoa(c.port))) + if err != nil { + res.Latency = time.Since(start) + err = classifyProbeErr(err, false) + res.Err = err + return res, err + } + conn := tls.Client(raw, cfg) + err = conn.HandshakeContext(ctx) + res.Latency = time.Since(start) + var leafErr error + if err == nil { + leafErr = checkLeaf(conn.ConnectionState().PeerCertificates, c.now()) + } + if cerr := conn.Close(); cerr != nil { + //nolint:sloglint // Checks use the CLI-configured process logger. + slog.DebugContext(ctx, "close tls", "err", cerr) + } + if err != nil { + err = classifyProbeErr(err, false) + res.Err = err + return res, err + } + if leafErr != nil { + res.Err = leafErr + return res, leafErr + } + return res, nil +} + +func (c tlsCheck) bind(clock tstime.Clock, resolver *tsResolver) check { + c.clock = clock + if resolver != nil { + c.resolver = resolver + } + return c +} + +func (c tlsCheck) now() time.Time { + if c.clock == nil { + return tstime.StdClock{}.Now() + } + return c.clock.Now() +} + +// verifiedName is the certificate name for addr. A status failure leaves the +// address itself, so a missing tailscaled does not fail a name that needs no peer. +func (c tlsCheck) verifiedName(ctx context.Context, addr string) string { + if name, ok := c.resolver.peerDNS(ctx, addr); ok { + return name + } + trimmed := strings.TrimSuffix(strings.TrimSpace(addr), ".") + if ip, err := netip.ParseAddr(trimmed); err == nil { + return ip.Unmap().String() + } + return trimmed +} + +type leafDateError interface { + error + leafDate() +} + +type certExpiryError struct { + notAfter time.Time + name string +} + +func (*certExpiryError) leafDate() {} + +func (e *certExpiryError) Error() string { + return fmt.Sprintf("certificate expires %s: %s", e.notAfter.UTC().Format(time.RFC3339), e.name) +} + +type certNotYetValidError struct { + notBefore time.Time + name string +} + +func (*certNotYetValidError) leafDate() {} + +func (e *certNotYetValidError) Error() string { + return fmt.Sprintf("certificate is not valid before %s: %s", e.notBefore.UTC().Format(time.RFC3339), e.name) +} + +func checkLeaf(certs []*x509.Certificate, now time.Time) error { + if len(certs) == 0 || certs[0] == nil { + return errors.New("server presented no certificate") + } + leaf := certs[0] + name := leafName(leaf) + if leaf.NotBefore.After(now) { + return &certNotYetValidError{notBefore: leaf.NotBefore, name: name} + } + if leaf.NotAfter.Before(now.Add(tlsExpiryWindow)) { + return &certExpiryError{notAfter: leaf.NotAfter, name: name} + } + return nil +} + +func leafName(cert *x509.Certificate) string { + if cert == nil { + return "" + } + if len(cert.DNSNames) > 0 { + return cert.DNSNames[0] + } + return cert.Subject.String() +} + type httpCheck struct { raw string url string @@ -228,7 +387,7 @@ func (c httpCheck) Run(ctx context.Context, env netEnv, t *Target) (Result, erro } if t != nil && t.InsecureTLS { // Devices such as a NAS often present a self-signed certificate. - // Certificate expiry is the phase-2 tls check. + // Certificate expiry is the tls: check. transport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, //nolint:gosec // Per-target insecure_tls opt-in. } @@ -285,6 +444,13 @@ type tspingCheck struct { func (tspingCheck) String() string { return checkTSPing } func (tspingCheck) Kind() string { return checkTSPing } +func (c tspingCheck) bind(_ tstime.Clock, resolver *tsResolver) check { + if resolver != nil { + c.resolver = resolver + } + return c +} + func (c tspingCheck) Run(ctx context.Context, env netEnv, t *Target) (Result, error) { r := c.resolver if r == nil { diff --git a/cmd/tail-watch/checks_test.go b/cmd/tail-watch/checks_test.go index b59d655..1899b06 100644 --- a/cmd/tail-watch/checks_test.go +++ b/cmd/tail-watch/checks_test.go @@ -2,8 +2,16 @@ package main import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "errors" "io" + "math/big" "net" "net/http" "net/http/httptest" @@ -13,10 +21,12 @@ import ( "testing" "time" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tstest" "tailscale.com/tstime" "tailscale.com/types/key" + "tailscale.com/types/views" ) func TestTCPOpenAndRefused(t *testing.T) { @@ -714,3 +724,359 @@ func mustCheck(t *testing.T, s string) check { } return c } + +func TestTLSCheck(t *testing.T) { + t.Parallel() + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + clk := tstest.NewClock(tstest.ClockOpts{ + Start: start, + Step: 0, + TimerChannelSize: 0, + FollowRealTime: false, + }) + ca := newTestCA(t, start.Add(-48*time.Hour), start.Add(365*24*time.Hour)) + + nas := &ipnstate.PeerStatus{ + NodeID: 8, + HostName: "nas", + DNSName: "nas.tailnet.ts.net.", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.8")}, + } + routerRoutes := views.SliceOf([]netip.Prefix{netip.MustParsePrefix("10.20.0.0/24")}) + router := &ipnstate.PeerStatus{ + NodeID: 7, + HostName: "nyc-router", + DNSName: "nyc-router.tailnet.ts.net.", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.7")}, + PrimaryRoutes: &routerRoutes, + } + withPeers := statusWith(nas, router) + noPeers := statusWith() + + leaf := func(dns string, notBefore, notAfter time.Time) tls.Certificate { + t.Helper() + return ca.leaf(t, dns, notBefore, notAfter) + } + day := 24 * time.Hour + valid := leaf("nas.tailnet.ts.net", start.Add(-time.Hour), start.Add(30*day)) + + peers := statusResolver(withPeers) + none := statusResolver(noPeers) + tests := []tlsTrial{ + tlsTrialOf("leaf beyond 7 days", "nas.tailnet.ts.net", valid, peers, false, ""), + tlsTrialOf("leaf exactly 7 days", "nas.tailnet.ts.net", + leaf("nas.tailnet.ts.net", start.Add(-time.Hour), start.Add(tlsExpiryWindow)), peers, false, ""), + tlsTrialOf("leaf inside 7 days", "nas.tailnet.ts.net", + leaf("nas.tailnet.ts.net", start.Add(-time.Hour), start.Add(6*day)), peers, false, + "certificate expires "+start.Add(6*day).UTC().Format(time.RFC3339)), + tlsTrialOf("leaf already expired", "nas.tailnet.ts.net", + leaf("nas.tailnet.ts.net", start.Add(-48*time.Hour), start.Add(-time.Hour)), peers, false, "expired"), + tlsTrialOf("tailscale ip uses peer dns name", "100.64.0.8", valid, peers, false, ""), + tlsTrialOf("short name uses peer dns name", "nas", valid, peers, false, ""), + tlsTrialOf("full name uses peer dns name", "nas.tailnet.ts.net", valid, peers, false, ""), + tlsTrialOf("subnet route does not supply the name", "10.20.0.50", + leaf("nyc-router.tailnet.ts.net", start.Add(-time.Hour), start.Add(30*day)), peers, false, "certificate"), + tlsTrialOf("service name with no peer", "web.tailnet.ts.net", + leaf("web.tailnet.ts.net", start.Add(-time.Hour), start.Add(30*day)), none, false, ""), + tlsTrialOf("trailing dot is stripped", "web.tailnet.ts.net.", + leaf("web.tailnet.ts.net", start.Add(-time.Hour), start.Add(30*day)), none, false, ""), + tlsTrialOf("short name with no peer stays short", "nas", valid, none, false, "certificate"), + tlsTrialOf("nil resolver dials the dns name", "web.example.ts.net", + leaf("web.example.ts.net", start.Add(-time.Hour), start.Add(30*day)), nil, false, ""), + tlsTrialOf("status error still dials a dns name", "web.example.ts.net", + leaf("web.example.ts.net", start.Add(-time.Hour), start.Add(30*day)), + newTSResolver(clk, nil, func(context.Context) (*ipnstate.Status, error) { + return nil, errors.New("status down") + }), false, ""), + tlsTrialOf("insecure self-signed leaf beyond the window", "nas", + selfSignedLeaf(t, "nas", start.Add(-time.Hour), start.Add(30*day)), nil, true, ""), + tlsTrialOf("insecure self-signed leaf inside the window", "nas", + selfSignedLeaf(t, "nas", start.Add(-time.Hour), start.Add(2*day)), nil, true, + "certificate expires "+start.Add(2*day).UTC().Format(time.RFC3339)), + tlsTrialOf("insecure leaf names the subject when it has no san", "nas", + selfSignedLeaf(t, "", start.Add(-time.Hour), start.Add(2*day)), nil, true, "CN=nas-device"), + tlsTrialOf("insecure leaf not yet valid", "nas", + selfSignedLeaf(t, "nas", start.Add(time.Hour), start.Add(30*day)), nil, true, + "certificate is not valid before "+start.Add(time.Hour).UTC().Format(time.RFC3339)), + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runTLSTrial(t, clk, ca, tt) + }) + } + + t.Run("seeded presence owns the ip and ignores the subnet", func(t *testing.T) { + t.Parallel() + pres := newPresence(nil, clk, nil) + pres.apply(ipn.Notify{InitialStatus: withPeers}) + resolver := statusResolver(noPeers) + resolver.presence = pres + + env := startTLS(t, valid) + owned := mustCheck(t, "tls:443").(tlsCheck) + owned.clock = clk + owned.resolver = resolver + owned.roots = ca.pool + if _, err := owned.Run(context.Background(), env, tlsTarget("100.64.0.8", false)); err != nil { + t.Fatal(err) + } + + routed := startTLS(t, leaf("nyc-router.tailnet.ts.net", start.Add(-time.Hour), start.Add(30*day))) + miss := owned + if _, err := miss.Run(context.Background(), routed, tlsTarget("10.20.0.50", false)); err == nil { + t.Fatal("subnet route supplied the certificate name") + } + }) + + t.Run("expired leaf fails verification, not the window", func(t *testing.T) { + t.Parallel() + env := startTLS(t, leaf("nas.tailnet.ts.net", start.Add(-48*time.Hour), start.Add(-time.Hour))) + chk := mustCheck(t, "tls:443").(tlsCheck) + chk.clock = clk + chk.roots = ca.pool + _, err := chk.Run(context.Background(), env, tlsTarget("nas.tailnet.ts.net", false)) + var expiry *certExpiryError + if err == nil || errors.As(err, &expiry) { + t.Fatalf("err %v, want a verification error", err) + } + if classifyErr(err) != errTLS { + t.Fatalf("class %v, want errTLS", classifyErr(err)) + } + }) + + t.Run("seeded presence miss falls back to status for self", func(t *testing.T) { + t.Parallel() + pres := newPresence(nil, clk, nil) + pres.apply(ipn.Notify{InitialStatus: withPeers}) + withSelf := statusWith(nas) + withSelf.Self = &ipnstate.PeerStatus{ + NodeID: 1, + HostName: "collector", + DNSName: "collector.tailnet.ts.net.", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + } + resolver := statusResolver(withSelf) + resolver.presence = pres + + env := startTLS(t, leaf("collector.tailnet.ts.net", start.Add(-time.Hour), start.Add(30*day))) + chk := mustCheck(t, "tls:443").(tlsCheck) + chk.clock = clk + chk.resolver = resolver + chk.roots = ca.pool + if _, err := chk.Run(context.Background(), env, tlsTarget("100.64.0.1", false)); err != nil { + t.Fatal(err) + } + }) +} + +type tlsTrial struct { + name string + addr string + insecure bool + cert tls.Certificate + resolver *tsResolver + wantErr string +} + +func tlsTrialOf(name, addr string, cert tls.Certificate, resolver *tsResolver, insecure bool, wantErr string) tlsTrial { + return tlsTrial{ + name: name, addr: addr, insecure: insecure, + cert: cert, resolver: resolver, wantErr: wantErr, + } +} + +func runTLSTrial(t *testing.T, clk tstime.Clock, ca testCA, tt tlsTrial) { + t.Helper() + env := startTLS(t, tt.cert) + chk := mustCheck(t, "tls:443").(tlsCheck) + chk.clock = clk + chk.resolver = tt.resolver + if !tt.insecure { + chk.roots = ca.pool + } + res, err := chk.Run(context.Background(), env, tlsTarget(tt.addr, tt.insecure)) + if tt.wantErr == "" { + if err != nil { + t.Fatal(err) + } + if res.Check != "tls:443" || res.Latency <= 0 { + t.Fatalf("result %+v", res) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err %v, want %q", err, tt.wantErr) + } +} + +func statusWith(peers ...*ipnstate.PeerStatus) *ipnstate.Status { + tailnet := new(ipnstate.TailnetStatus) + tailnet.MagicDNSSuffix = "tailnet.ts.net" + st := &ipnstate.Status{ + CurrentTailnet: tailnet, + Peer: map[key.NodePublic]*ipnstate.PeerStatus{}, + } + for _, ps := range peers { + st.Peer[key.NewNode().Public()] = ps + } + return st +} + +func statusResolver(st *ipnstate.Status) *tsResolver { + return newTSResolver(tstime.StdClock{}, nil, func(context.Context) (*ipnstate.Status, error) { + return st, nil + }) +} + +func tlsTarget(addr string, insecure bool) *Target { + return &Target{ + Dynamic: false, + Name: "", + Addr: addr, + Site: "", + Severity: "", + Parent: "", + InsecureTLS: insecure, + Interval: 0, + Timeout: 0, + FailThreshold: 0, + RecoverThreshold: 0, + FlapLimit: 0, + FlapWindow: 0, + Network: "", + Checks: nil, + Implicit: false, + StableID: "", + NodeID: 0, + Route: "", + } +} + +func startTLS(t *testing.T, cert tls.Certificate) *fakeEnv { + t.Helper() + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + go func() { + defer func() { _ = conn.Close() }() + _, _ = io.Copy(io.Discard, conn) + }() + } + }() + return dialingEnv(func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, ln.Addr().String()) + }) +} + +type testCA struct { + cert *x509.Certificate + key crypto.Signer + pool *x509.CertPool +} + +func newTestCA(t *testing.T, notBefore, notAfter time.Time) testCA { + t.Helper() + key := p256Key(t) + var subject pkix.Name + subject.CommonName = "test-ca" + tmpl := new(x509.Certificate) + tmpl.SerialNumber = big.NewInt(1) + tmpl.Subject = subject + tmpl.NotBefore = notBefore + tmpl.NotAfter = notAfter + tmpl.IsCA = true + tmpl.KeyUsage = x509.KeyUsageCertSign + tmpl.BasicConstraintsValid = true + der := createCert(t, tmpl, tmpl, key.Public(), key) + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AddCert(cert) + return testCA{cert: cert, key: key, pool: pool} +} + +func (ca testCA) leaf(t *testing.T, dns string, notBefore, notAfter time.Time) tls.Certificate { + t.Helper() + key := p256Key(t) + var subject pkix.Name + subject.CommonName = dns + tmpl := new(x509.Certificate) + tmpl.SerialNumber = big.NewInt(2) + tmpl.Subject = subject + tmpl.NotBefore = notBefore + tmpl.NotAfter = notAfter + tmpl.DNSNames = []string{dns} + tmpl.KeyUsage = x509.KeyUsageDigitalSignature + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + der := createCert(t, tmpl, ca.cert, key.Public(), ca.key) + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + var out tls.Certificate + out.Certificate = [][]byte{der, ca.cert.Raw} + out.PrivateKey = key + out.Leaf = parsed + return out +} + +func selfSignedLeaf(t *testing.T, dns string, notBefore, notAfter time.Time) tls.Certificate { + t.Helper() + key := p256Key(t) + cn := dns + if cn == "" { + cn = "nas-device" + } + var subject pkix.Name + subject.CommonName = cn + tmpl := new(x509.Certificate) + tmpl.SerialNumber = big.NewInt(3) + tmpl.Subject = subject + tmpl.NotBefore = notBefore + tmpl.NotAfter = notAfter + tmpl.KeyUsage = x509.KeyUsageDigitalSignature + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + if dns != "" { + tmpl.DNSNames = []string{dns} + } + der := createCert(t, tmpl, tmpl, key.Public(), key) + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + var out tls.Certificate + out.Certificate = [][]byte{der} + out.PrivateKey = key + out.Leaf = parsed + return out +} + +func p256Key(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return key +} + +func createCert(t *testing.T, tmpl, parent *x509.Certificate, pub any, priv any) []byte { + t.Helper() + der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, pub, priv) + if err != nil { + t.Fatal(err) + } + return der +} diff --git a/cmd/tail-watch/classify.go b/cmd/tail-watch/classify.go index bafc60f..032ca6f 100644 --- a/cmd/tail-watch/classify.go +++ b/cmd/tail-watch/classify.go @@ -798,7 +798,8 @@ func classifyErr(err error) errClass { if errors.As(err, &status) || errors.As(err, &body) { return errHTTP } - if certErr(err) { + var leaf leafDateError + if errors.As(err, &leaf) || certErr(err) { return errTLS } return errOther diff --git a/cmd/tail-watch/classify_test.go b/cmd/tail-watch/classify_test.go index 8817edd..417bb5e 100644 --- a/cmd/tail-watch/classify_test.go +++ b/cmd/tail-watch/classify_test.go @@ -474,6 +474,80 @@ func TestFreshICMPSkipsSupplementalProbe(t *testing.T) { } } +func TestClassifyTLSAnswer(t *testing.T) { + t.Parallel() + leaf := new(x509.Certificate) + leaf.DNSNames = []string{"other.example"} + cases := []struct { + name string + err error + }{ + { + name: "certificate expires", + err: &certExpiryError{ + notAfter: time.Date(2026, 1, 7, 0, 0, 0, 0, time.UTC), + name: "nas.tailnet.ts.net", + }, + }, + { + name: "certificate is not valid before", + err: &certNotYetValidError{ + notBefore: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + name: "nas.tailnet.ts.net", + }, + }, + { + name: "hostname mismatch", + err: x509.HostnameError{Certificate: leaf, Host: "10.20.0.50"}, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if tt.name == "certificate expires" && !strings.Contains(tt.err.Error(), tt.name) { + t.Fatal(tt.err) + } + c, nas, icmps := tlsAnswerRig(t) + got := c.observeOne(Result{ + Target: nas, Kind: "tls", Check: "tls:443", Err: tt.err, + Start: time.Time{}, Generation: 0, Latency: 0, Ping: nil, + }) + if got.Layer != layerService || icmps.Load() != 0 { + t.Fatalf("layer %q icmps %d", got.Layer, icmps.Load()) + } + }) + } +} + +func tlsAnswerRig(t *testing.T) (*classifier, *Target, *atomic.Int32) { + t.Helper() + router := peerTarget("nyc-subnet-router", "nyc-router") + nas := &Target{ + Dynamic: false, Name: "nyc-nas", Addr: "10.20.0.50", Network: NetworkTailnet, + Interval: 30 * time.Second, Timeout: time.Second, + Site: "", Severity: "", Parent: "", InsecureTLS: false, + FailThreshold: 0, RecoverThreshold: 0, FlapLimit: 0, FlapWindow: 0, + Checks: nil, Implicit: false, StableID: "", NodeID: 0, Route: "", + } + c, pres := newTestClassifier(t, new(fakeEnv), router, nas) + seedRouter(pres) + icmps := new(atomic.Int32) + c.pingFn = func(context.Context, netip.Addr, time.Duration) (*ipnstate.PingResult, error) { + return &ipnstate.PingResult{ + Endpoint: "1.2.3.4:1", NodeIP: "100.64.0.7", + IP: "", NodeName: "", Err: "", LatencySeconds: 0.01, + PeerRelay: "", DERPRegionID: 0, DERPRegionCode: "", + PeerAPIPort: 0, PeerAPIURL: "", IsLocalIP: false, + }, nil + } + c.icmpFn = func(context.Context, netip.Addr, time.Duration) bool { + icmps.Add(1) + return false + } + c.learnRound(context.Background()) + return c, nas, icmps +} + func TestObserveLayers(t *testing.T) { t.Parallel() router := peerTarget("nyc-subnet-router", "nyc-router") diff --git a/cmd/tail-watch/config_test.go b/cmd/tail-watch/config_test.go index ca0696e..5cb88f1 100644 --- a/cmd/tail-watch/config_test.go +++ b/cmd/tail-watch/config_test.go @@ -137,6 +137,9 @@ func TestCheckParsing(t *testing.T) { "icmp", "tcp:1", "tcp:65535", + "tls:1", + "tls:443", + "tls:65535", "tsping", "http:https://10.0.0.1/|DSM", "http:http://host.example/health", @@ -154,7 +157,10 @@ func TestCheckParsing(t *testing.T) { {"tcp:0", "invalid TCP port"}, {"tcp:abc", "invalid TCP port"}, {"http:not a url", "invalid HTTP URL"}, - {"tls:10.20.0.50:443", "tls checks are not supported in this version"}, + {"tls:70000", "invalid TLS port"}, + {"tls:0", "invalid TLS port"}, + {"tls:abc", "invalid TLS port"}, + {"tls:10.20.0.50:443", "invalid TLS port"}, {"snmp", "unknown check type"}, } for _, tt := range bad { @@ -200,7 +206,12 @@ targets: { name: "tls phase 2", src: "targets: [{name: nas, addr: 10.20.0.50, checks: ['tls:10.20.0.50:443']}]", - wants: []string{"tls checks are not supported in this version"}, + wants: []string{"targets[0].checks[0]", "nas", "tls:10.20.0.50:443", "invalid TLS port"}, + }, + { + name: "tls port out of range", + src: "targets: [{name: nas, addr: 10.20.0.50, checks: ['tls:70000']}]", + wants: []string{"targets[0].checks[0]", "nas", "tls:70000", "invalid TLS port"}, }, { name: "timeout not below interval", @@ -270,6 +281,14 @@ targets: } }) } + + cfg, err := parseConfig([]byte("targets: [{name: nas, addr: nas.tailnet.ts.net, checks: ['tls:443']}]")) + if err != nil { + t.Fatal(err) + } + if got := cfg.Targets[0].Checks[0].String(); got != "tls:443" { + t.Fatalf("check %q", got) + } } func TestHTTPHostEquivalence(t *testing.T) { diff --git a/cmd/tail-watch/main.go b/cmd/tail-watch/main.go index 6168f16..44864fb 100644 --- a/cmd/tail-watch/main.go +++ b/cmd/tail-watch/main.go @@ -347,16 +347,13 @@ func (a *app) bindResolver() { } func (a *app) bindChecks(t *Target) { - if a == nil || a.resolver == nil || t == nil { + if a == nil || t == nil { return } for j, c := range t.Checks { - ts, ok := c.(tspingCheck) - if !ok { - continue + if b, ok := c.(checkBinder); ok { + t.Checks[j] = b.bind(a.clock, a.resolver) } - ts.resolver = a.resolver - t.Checks[j] = ts } } diff --git a/cmd/tail-watch/main_test.go b/cmd/tail-watch/main_test.go index 83b81a9..7cc1127 100644 --- a/cmd/tail-watch/main_test.go +++ b/cmd/tail-watch/main_test.go @@ -26,10 +26,105 @@ import ( "tailscale.com/client/local" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/tstest" "tailscale.com/tstime" "tailscale.com/types/views" ) +func TestBindChecksTLS(t *testing.T) { + t.Parallel() + clk := tstest.NewClock(tstest.ClockOpts{ + Start: time.Time{}, + Step: 0, + TimerChannelSize: 0, + FollowRealTime: false, + }) + resolver := newTSResolver(clk, nil, func(context.Context) (*ipnstate.Status, error) { + return &ipnstate.Status{}, nil + }) + wired := &app{ + cfg: nil, env: nil, log: nil, clock: clk, pub: nil, stdout: nil, + socket: "", listen: "", ui: false, + dynamic: nil, uiRoutes: nil, registry: nil, + resolver: resolver, presence: nil, class: nil, learner: nil, + alerts: nil, notify: nil, metrics: nil, + collector: CollectorInfo{Name: "", TailscaleIP: ""}, + watch: nil, probeMu: sync.Mutex{}, probes: nil, + } + target := &Target{ + Dynamic: false, Name: "nas", Addr: "nas", Checks: []check{ + tlsCheck{port: 443, clock: nil, resolver: nil, roots: nil}, + tspingCheck{resolver: nil}, + }, + Site: "", Severity: "", Parent: "", InsecureTLS: false, + Interval: 0, Timeout: 0, FailThreshold: 0, RecoverThreshold: 0, + FlapLimit: 0, FlapWindow: 0, Network: "", Implicit: false, + StableID: "", NodeID: 0, Route: "", + } + wired.bindChecks(target) + got := target.Checks[0].(tlsCheck) + if got.clock != clk || got.resolver != resolver { + t.Fatalf("clock %v resolver %v", got.clock != nil, got.resolver != nil) + } + if target.Checks[1].(tspingCheck).resolver != resolver { + t.Fatal("tsping resolver was not set") + } + + local := &app{ + cfg: nil, env: nil, log: nil, clock: clk, pub: nil, stdout: nil, + socket: "", listen: "", ui: false, + dynamic: nil, uiRoutes: nil, registry: nil, + resolver: nil, presence: nil, class: nil, learner: nil, + alerts: nil, notify: nil, metrics: nil, + collector: CollectorInfo{Name: "", TailscaleIP: ""}, + watch: nil, probeMu: sync.Mutex{}, probes: nil, + } + bare := &Target{ + Dynamic: false, Name: "web", Addr: "web.example", + Checks: []check{tlsCheck{port: 443, clock: nil, resolver: nil, roots: nil}}, + Site: "", Severity: "", Parent: "", InsecureTLS: false, + Interval: 0, Timeout: 0, FailThreshold: 0, RecoverThreshold: 0, + FlapLimit: 0, FlapWindow: 0, Network: "", Implicit: false, + StableID: "", NodeID: 0, Route: "", + } + local.bindChecks(bare) + unwired := bare.Checks[0].(tlsCheck) + if unwired.clock != clk || unwired.resolver != nil { + t.Fatalf("local clock %v resolver %v", unwired.clock != nil, unwired.resolver != nil) + } +} + +func TestBoundTLSCheckUsesAppClock(t *testing.T) { + t.Parallel() + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + clk := tstest.NewClock(tstest.ClockOpts{ + Start: start, + Step: 0, + TimerChannelSize: 0, + FollowRealTime: false, + }) + ca := newTestCA(t, start.Add(-48*time.Hour), start.Add(365*24*time.Hour)) + env := startTLS(t, ca.leaf(t, "web.example.ts.net", start.Add(-time.Hour), start.Add(6*24*time.Hour))) + local := &app{ + cfg: nil, env: nil, log: nil, clock: clk, pub: nil, stdout: nil, + socket: "", listen: "", ui: false, + dynamic: nil, uiRoutes: nil, registry: nil, + resolver: nil, presence: nil, class: nil, learner: nil, + alerts: nil, notify: nil, metrics: nil, + collector: CollectorInfo{Name: "", TailscaleIP: ""}, + watch: nil, probeMu: sync.Mutex{}, probes: nil, + } + target := tlsTarget("web.example.ts.net", false) + target.Checks = []check{mustCheck(t, "tls:443")} + local.bindChecks(target) + bound := target.Checks[0].(tlsCheck) + bound.roots = ca.pool + _, err := bound.Run(context.Background(), env, target) + if err == nil || !strings.Contains(err.Error(), "certificate expires") { + t.Fatalf("err %v, want certificate expires", err) + } +} + func TestVersionAndCheckConfig(t *testing.T) { t.Parallel() var stdout, stderr bytes.Buffer diff --git a/cmd/tail-watch/peerdns.go b/cmd/tail-watch/peerdns.go new file mode 100644 index 0000000..7391110 --- /dev/null +++ b/cmd/tail-watch/peerdns.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "net/netip" + "slices" + "strings" + + "tailscale.com/ipn/ipnstate" + "tailscale.com/types/key" +) + +// peerDNS is the MagicDNS name of the peer that owns addr, from the live peer +// view when it lists addr and from the status cache otherwise. +func (r *tsResolver) peerDNS(ctx context.Context, addr string) (string, bool) { + if r == nil { + return "", false + } + if r.presence != nil && r.presence.seededNow() { + if name, ok := presenceDNS(r.presence, addr); ok { + return name, true + } + } + st, err := r.cache.get(ctx) + if err != nil { + return "", false + } + return statusDNS(st, addr) +} + +// presenceDNS is the MagicDNS name of the peer that owns addr. A subnet +// router does not own the addresses it routes. +func presenceDNS(p *presence, addr string) (string, bool) { + if p == nil { + return "", false + } + addr = strings.TrimSpace(addr) + if ip, err := netip.ParseAddr(addr); err == nil { + info, ok := p.ByIP(ip) + if !ok { + return "", false + } + return trimmedDNS(info.DNSName) + } + info, ok := p.ByName(addr) + if !ok { + return "", false + } + return trimmedDNS(info.DNSName) +} + +// statusDNS is presenceDNS against a Status snapshot, including Self. +func statusDNS(st *ipnstate.Status, addr string) (string, bool) { + if st == nil { + return "", false + } + addr = strings.TrimSpace(addr) + if ip, err := netip.ParseAddr(addr); err == nil { + return peerDNSAt(st, ip.Unmap()) + } + ip, _, ok := matchPeer(addr, st) + if !ok { + return "", false + } + return peerDNSAt(st, ip) +} + +func peerDNSAt(st *ipnstate.Status, ip netip.Addr) (string, bool) { + peers := make([]*ipnstate.PeerStatus, 0, 1+len(st.Peer)) + if st.Self != nil { + peers = append(peers, st.Self) + } + keys := make([]key.NodePublic, 0, len(st.Peer)) + for k := range st.Peer { + keys = append(keys, k) + } + slices.SortFunc(keys, func(a, b key.NodePublic) int { return a.Compare(b) }) + for _, k := range keys { + peers = append(peers, st.Peer[k]) + } + names := make([]string, 0, 1) + for _, ps := range peers { + if ps == nil || !peerOwnsIP(ps, ip) { + continue + } + if name, ok := trimmedDNS(ps.DNSName); ok { + names = append(names, name) + } + } + if len(names) == 0 { + return "", false + } + slices.Sort(names) + return names[0], true +} + +func peerOwnsIP(ps *ipnstate.PeerStatus, ip netip.Addr) bool { + for _, cand := range ps.TailscaleIPs { + if cand.Unmap() == ip { + return true + } + } + return false +} + +func trimmedDNS(dns string) (string, bool) { + name := strings.TrimSuffix(strings.TrimSpace(dns), ".") + return name, name != "" +} diff --git a/cmd/tail-watch/tail-watch b/cmd/tail-watch/tail-watch new file mode 100755 index 0000000..8c740d4 Binary files /dev/null and b/cmd/tail-watch/tail-watch differ diff --git a/cmd/tail-watch/ui/app.js b/cmd/tail-watch/ui/app.js index 42ac3a5..2cd2bee 100644 --- a/cmd/tail-watch/ui/app.js +++ b/cmd/tail-watch/ui/app.js @@ -431,7 +431,8 @@ function checkToString(row, addr) { case "tsping": return row.type; case "tcp": - return `tcp:${row.port.trim()}`; + case "tls": + return `${row.type}:${row.port.trim()}`; case "http": { const port = row.port.trim(); const path = row.path.trim().startsWith("/") ? row.path.trim() : `/${row.path.trim()}`; @@ -448,8 +449,8 @@ function checkToString(row, addr) { // that keeps the raw string. function checkFromString(s, addr) { if (s === "icmp" || s === "tsping") return blankCheck(s); - const tcp = /^tcp:(\d{1,5})$/.exec(s); - if (tcp) return { ...blankCheck("tcp"), port: tcp[1] }; + const port = /^(tcp|tls):(\d{1,5})$/.exec(s); + if (port) return { ...blankCheck(port[1]), port: port[2] }; const http = /^http:(https?):\/\/(\[[^\]/?#|]+\]|[^/:?#|[\]]+)(?::(\d+))?(\/[^#|]*)?(?:\|([\s\S]*))?$/.exec(s); if (http && normalHost(http[2]) === normalHost(addr)) { return { ...blankCheck("http"), scheme: http[1], port: http[3] ?? "", path: http[4] ?? "", text: http[5] ?? "" }; @@ -636,6 +637,7 @@ const CHECK_TYPE_LABELS = [ ["icmp", "Ping (ICMP)"], ["tsping", "Tailscale ping"], ["tcp", "TCP port"], + ["tls", "TLS certificate"], ["http", "HTTP request"], ]; @@ -659,8 +661,9 @@ function checkRowEl(row, i) { renderChecks(i); }); box.append(type); - if (row.type === "tcp") { - box.append(checkInput(row, i, "check-port", "port", "port", { inputmode: "numeric", placeholder: "445" })); + if (row.type === "tcp" || row.type === "tls") { + const placeholder = row.type === "tls" ? "443" : "445"; + box.append(checkInput(row, i, "check-port", "port", "port", { inputmode: "numeric", placeholder })); } else if (row.type === "http") { const scheme = el("select", { class: "check-scheme", "aria-label": `Check ${i + 1} scheme` }, el("option", { value: "http", text: "http" }), el("option", { value: "https", text: "https" })); diff --git a/cmd/tail-watch/ui_checks_browser_test.go b/cmd/tail-watch/ui_checks_browser_test.go index 44820ce..1f348c2 100644 --- a/cmd/tail-watch/ui_checks_browser_test.go +++ b/cmd/tail-watch/ui_checks_browser_test.go @@ -308,8 +308,8 @@ func TestUIBrowserCustomRowKeepsUnparseableCheck(t *testing.T) { page := uiPage(t, uiPeers()) stored := `[{"name":"old","addr":"nas.lab","site":"","severity":"page","parent":"","insecure_tls":false, "interval":"30s","timeout":"5s","fail_threshold":3,"recover_threshold":2,"flap_limit":4,"flap_window":"15m0s", -"network":"tailnet","checks":["tls:443","http:http://other.lab/"],"source":"ui","readonly":false,"state":"UP", -"definition":{"name":"old","addr":"nas.lab","checks":["tls:443","http:http://other.lab/"]}}]` +"network":"tailnet","checks":["snmp:1","http:http://other.lab/"],"source":"ui","readonly":false,"state":"UP", +"definition":{"name":"old","addr":"nas.lab","checks":["snmp:1","http:http://other.lab/"]}}]` if err := page.Route("**/api/v1/targets", func(r playwright.Route) { _ = r.Fulfill(playwright.RouteFulfillOptions{ Status: new(200), ContentType: new("application/json"), Body: stored, @@ -324,7 +324,7 @@ func TestUIBrowserCustomRowKeepsUnparseableCheck(t *testing.T) { if err := page.Locator(`button[aria-label="Edit old"]`).Click(); err != nil { t.Fatal(err) } - for i, want := range []string{"tls:443", "http:http://other.lab/"} { + for i, want := range []string{"snmp:1", "http:http://other.lab/"} { row := checkRowLoc(page, i) if typ, _ := row.Locator(".check-type").InputValue(); typ != "custom" { t.Errorf("row %d type = %q, want custom", i+1, typ) @@ -340,11 +340,42 @@ func TestUIBrowserCustomRowKeepsUnparseableCheck(t *testing.T) { t.Fatal(err) } msg, _ := checkRowLoc(page, 0).Locator(".check-error").TextContent() - if !strings.Contains(msg, "tls checks are not supported") { + if !strings.Contains(msg, "unknown check type") { t.Errorf("row 1 error = %q", msg) } } +// An edited tls:443 is a TLS certificate row with its port, not a custom string. +func TestUIBrowserTLS(t *testing.T) { + t.Parallel() + page := uiPage(t, uiPeers()) + stored := `[{"name":"old","addr":"nas.lab","site":"","severity":"page","parent":"","insecure_tls":false, +"interval":"30s","timeout":"5s","fail_threshold":3,"recover_threshold":2,"flap_limit":4,"flap_window":"15m0s", +"network":"tailnet","checks":["tls:443"],"source":"ui","readonly":false,"state":"UP", +"definition":{"name":"old","addr":"nas.lab","checks":["tls:443"]}}]` + if err := page.Route("**/api/v1/targets", func(r playwright.Route) { + _ = r.Fulfill(playwright.RouteFulfillOptions{ + Status: new(200), ContentType: new("application/json"), Body: stored, + Headers: nil, Path: nil, Response: nil, + }) + }); err != nil { + t.Fatal(err) + } + if _, err := page.Reload(); err != nil { + t.Fatal(err) + } + if err := page.Locator(`button[aria-label="Edit old"]`).Click(); err != nil { + t.Fatal(err) + } + row := checkRowLoc(page, 0) + if typ, err := row.Locator(".check-type").InputValue(); err != nil || typ != "tls" { + t.Fatalf("type = %q (%v), want tls", typ, err) + } + if port, err := row.Locator(".check-port").InputValue(); err != nil || port != "443" { + t.Fatalf("port = %q (%v), want 443", port, err) + } +} + func TestUIBrowserCheckRowsAreNamedForAssistiveTech(t *testing.T) { t.Parallel() page := uiPage(t, uiPeers()) diff --git a/cmd/tail-watch/ui_checks_test.go b/cmd/tail-watch/ui_checks_test.go index 90962ad..3a7537b 100644 --- a/cmd/tail-watch/ui_checks_test.go +++ b/cmd/tail-watch/ui_checks_test.go @@ -68,6 +68,7 @@ func TestUICheckConversion(t *testing.T) { toCase("", checkRow("icmp", nil), "icmp"), toCase("", checkRow("tsping", nil), "tsping"), toCase("", checkRow("tcp", map[string]string{"port": " 445 "}), "tcp:445"), + toCase("", checkRow("tls", map[string]string{"port": " 443 "}), "tls:443"), toCase("", checkRow("tcp", nil), "tcp:"), toCase( "10.20.0.50", @@ -79,14 +80,15 @@ func TestUICheckConversion(t *testing.T) { toCase("nas2", checkRow("http", map[string]string{"path": "status"}), "http:http://nas2/status"), toCase("nas", checkRow("http", map[string]string{"path": ""}), "http:http://nas/"), toCase("nas", checkRow("http", map[string]string{"text": "a|b"}), "http:http://nas/|a|b"), - toCase("", checkRow("custom", map[string]string{"raw": " tls:443 "}), "tls:443"), + toCase("", checkRow("custom", map[string]string{"raw": " snmp:1 "}), "snmp:1"), fromCase("", "icmp", checkRow("icmp", nil)), fromCase("", "tsping", checkRow("tsping", nil)), fromCase("", "tcp:445", checkRow("tcp", map[string]string{"port": "445"})), fromCase("", "tcp:70000", checkRow("tcp", map[string]string{"port": "70000"})), fromCase("", "tcp:x", checkRow("custom", map[string]string{"raw": "tcp:x"})), - fromCase("", "tls:443", checkRow("custom", map[string]string{"raw": "tls:443"})), + fromCase("", "tls:443", checkRow("tls", map[string]string{"port": "443"})), + fromCase("", "snmp:1", checkRow("custom", map[string]string{"raw": "snmp:1"})), fromCase( "10.20.0.50", "http:https://10.20.0.50:5001/|DSM", @@ -137,7 +139,7 @@ func TestUICheckConversion(t *testing.T) { func TestUICheckRoundTrip(t *testing.T) { t.Parallel() strs := []string{ - "icmp", "tsping", "tcp:445", "tcp:70000", "tls:443", "tcp:x", + "icmp", "tsping", "tcp:445", "tcp:70000", "tls:443", "snmp:1", "tcp:x", "http:https://10.20.0.50:5001/|DSM", "http:http://10.20.0.50/", "http:http://10.20.0.50:80/status?x=1|ok", "http:http://other.lab/", "http:http://10.20.0.50/#f", } diff --git a/docs/how-to/monitor-ui.md b/docs/how-to/monitor-ui.md index 73e1e68..836ea5d 100644 --- a/docs/how-to/monitor-ui.md +++ b/docs/how-to/monitor-ui.md @@ -93,6 +93,7 @@ Steps: | Ping (ICMP) | none | | Tailscale ping | none | | TCP port | the port | + | TLS certificate | the port | | HTTP request | scheme, optional port, path, and optional text the body must contain | An HTTP check always uses the Address as its host, so there is no host diff --git a/docs/reference/checks.md b/docs/reference/checks.md index 367cf6f..9b136c9 100644 --- a/docs/reference/checks.md +++ b/docs/reference/checks.md @@ -11,7 +11,7 @@ burst. | TCP connect | `tcp:` | A TCP connection to `addr:` is established. | | HTTP request | `http:` or `http:\|` | The status is 2xx or 3xx and, if `` is given, the body contains it. | | Tailscale ping | `tsping` | tailscaled reports a disco pong. | -| TLS | `tls:...` | Rejected. Not supported in this version. | +| TLS certificate | `tls:` | The handshake succeeds and the leaf expires at least 7 days from now. | The same check string cannot appear twice in one target. Every check that fails gets a failure layer; see [Failure layers](../explanation/failure-layers.md). @@ -99,10 +99,23 @@ Failures: | `tsping returned no result` | tailscaled answered without a result. | | other text | The error reported by tailscaled. | -## `tls:` (rejected) +## `tls:` -Any check starting with `tls:` is rejected at load time with -`tls checks are not supported in this version`. +`` is an integer from 1 to 65535. `tls:443` dials `addr:443` and completes a +TLS handshake. A value that is not a port, such as `tls:10.20.0.50:443`, fails +to load and the error names the target and the check. + +The verified name is the owning peer's DNS name when `addr` is that peer's +Tailscale IP, its short name, or its full MagicDNS name. A subnet route does +not supply the name. Address a Service or Serve endpoint that is not a peer by +its full `name.tailnet.ts.net` name. A short name with no matching peer is +used as written, with one trailing dot removed. + +The check passes when the handshake succeeds and the leaf's `NotAfter` is at +least 7 days after the check. A leaf that expires sooner fails with +`certificate expires` and the expiry time in UTC. Chain and hostname checks +use the system roots. With `insecure_tls: true`, those checks are skipped and +the leaf dates still apply. The check does not consult a CRL or OCSP. ## Parent rules diff --git a/docs/reference/config.md b/docs/reference/config.md index a145eaa..fb53540 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -235,7 +235,7 @@ Constraints are checked on each target after defaults and overrides are merged. | `route` | string | no | empty | Name of a route in `routes`. That route's severity and sinks apply. Mutually exclusive with `severity`. | | `network` | string | no | `tailnet` | `tailnet` or `local`. | | `parent` | string | no | empty | Name of another target whose only check is `icmp` or `tsping`. | -| `insecure_tls` | bool | no | `false` | Skip certificate verification for `http:` checks on this target. | +| `insecure_tls` | bool | no | `false` | Skip certificate verification for `http:` and `tls:` checks on this target. A `tls:` check still enforces the leaf's dates. | | `interval` | duration | no | `defaults.interval` | Time between runs of each check. | | `timeout` | duration | no | `defaults.timeout` | Per-run cancellation time. | | `fail_threshold` | integer | no | `defaults.fail_threshold` | Consecutive failures that mark the target `DOWN`. | diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/.openspec.yaml b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/.openspec.yaml new file mode 100644 index 0000000..ac359ee --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-03 diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/design.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/design.md new file mode 100644 index 0000000..a9c2e63 --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/design.md @@ -0,0 +1,184 @@ +# Design + +## Context + +See proposal.md for why. `parseCheck` in `cmd/tail-watch/checks.go` rejects every +`tls:` string. `http:` already verifies TLS unless `insecure_tls` is set, and +its comment points expiry at this check. The original design named +`prober.TLS` as the model: handshake, hostname match, and "not close to +expiry". On the pinned `tailscale.com` that helper also downloads a CRL with +the default HTTP client and skips date checks on self-signed certificates so +DERP metacerts stay quiet. + +Checks dial through `netEnv`. `tsping` learns peers from `presence` once that +view is seeded, and otherwise from the 30s `Status()` cache on `tsResolver`. +`bindChecks` attaches that resolver. `collector.local_only` never builds one. +`classifyErr` already maps `x509` and `crypto/tls` verification errors to +`errTLS`, and `answeredByError` treats that class as the device having +answered. An expiry error raised after a successful handshake is not one of +those types today, so it would fall through to an ICMP probe. + +The check rows in `cmd/tail-watch/ui/app.js` (`checkToString` / +`checkFromString`, between the check-conversion markers) have no TLS type. +`tls:443` is the stored string the Custom-row tests use. + +## Goals / Non-Goals + +**Goals:** + +- Implement the `tls:` behavior in the delta specs, through `netEnv`, + with the leaf window and the peer DNS name as the verified name. +- Keep a soon-to-expire leaf on the existing `service` path. +- Give the check builder a TLS port row. + +**Non-Goals:** + +- Calling `prober.TLS` or fetching a CRL. See proposal.md, Non-Goals, for the + product exclusions (no new event, metric, or expiry knob). + +## Decisions + +### Dial, then handshake on that connection + +`tlsCheck.Run` dials `tcp` to `net.JoinHostPort(addr, port)` with `env.Dial` +and calls `tls.Client` on that connection. `tls.Dialer` is the wrong tool: it +uses the process dialer, which skips `netEnv` and would break `local_only` and +a future `tsnetEnv`. + +No HTTP request is sent. Latency is the time from dial start through +`HandshakeContext`. + +Alternative considered: wrap `prober.TLS`. Rejected because it dials on its +own and treats a missing Let's Encrypt CRL as a failure. + +### Leaf dates only, after verification + +Constant `tlsExpiryWindow` is `7 * 24 * time.Hour`, the same window as +`prober.TLS`, and it is not configurable. + +The system roots (a nil `RootCAs`) verify the chain during the handshake, which +already rejects an expired, not-yet-valid, or untrusted chain. After a +successful handshake the check reads `PeerCertificates[0]` and fails when +`NotAfter` is before `now + tlsExpiryWindow`. Equality with the window passes: +the leaf must be at least 7 days out. + +`insecure_tls` sets `InsecureSkipVerify`. The handshake then succeeds for a +private certificate, and the same leaf-date checks run, including `NotBefore` +after now. A self-signed leaf is checked. `prober.TLS` skips those certificates +because DERP returns them beside the real cert. tail-watch's targets are the +operator's devices, and a NAS leaf is often the certificate they want watched. + +Intermediates are left to normal verification. A CA certificate that is still +trusted and inside 7 days does not fail this check. + +The expiry error is a `certExpiryError` whose text contains `certificate +expires`, the leaf `NotAfter` in RFC3339, and the first DNS SAN or, if there is +none, the subject. A not-yet-valid leaf found under `insecure_tls` is a +separate error containing `certificate is not valid before`. `classifyErr` +returns `errTLS` for both, next to the existing `certErr` cases, so +`answeredByError` is true and no ICMP probe runs. + +Alternative considered: fail the whole chain on the 7-day window, as +`prober.TLS` does. Rejected. An intermediate the system still trusts is the +CA's renewal, and it would page every tailnet that happens to see that +intermediate. + +### Verified name from the peer that owns the address + +`ServerName` is chosen in this order: + +1. The resolver's presence view, when it is seeded. `ByName` for a DNS name, + `ByIP` for an IP. Not `Lookup` and not `Route`: a subnet router must not + become the name on a LAN device's certificate. +2. Otherwise, when the view is unseeded or has no match, the status cache + `tsping` already uses. Names go through `matchPeer`. IPs are compared to + each peer's `TailscaleIPs`, including `Self`, which the presence view does + not hold. A fetch error is ignored and the check continues. +3. The peer's `DNSName` with one trailing dot removed. An empty DNS name does + not match. +4. Otherwise a DNS `addr` with one trailing dot removed, or the IP itself. + +`bindChecks` sets `tlsCheck.resolver` from the app resolver when one exists, +the same way it sets `tspingCheck.resolver`. `collector.local_only` leaves the +resolver nil, so step 1 and 2 are skipped. A Service or Serve endpoint that is +not a peer is checked by putting its `*.ts.net` name in `addr`. A short name +with no peer stays that short name and will fail a certificate issued only for +the FQDN. That is the documented form, not a second suffix-appending rule, +which would mis-name a LAN host called `nas`. + +Tests set `tlsCheck.roots` to a fixture pool. Nil means the system pool, which +is what production uses. Tests do not replace `crypto/tls` or `x509`. + +### Clock on the check + +`tlsCheck.clock` is a `tstime.Clock`. `bindChecks` copies `app.clock` even when +there is no resolver, so `local_only` still has one. `Run` uses +`tstime.StdClock` when the field is nil. Expiry tests set a `tstest.Clock` and +build the leaf relative to it. The check interface stays +`Run(ctx, env, target)`. Adding a clock argument would touch every check for +one comparison. + +### UI row + +`CHECK_TYPE_LABELS` gains `tls` / `TLS certificate`, with the same port input +TCP uses. `tls:` round-trips. Anything else, including `snmp:1` and +`tls:host:443`, stays Custom. The browser test that saved `tls:443` as a Custom +row and expected `tls checks are not supported` moves to `snmp:1`, and a TLS +row test covers port 443. + +### Docs + +`docs/reference/checks.md` replaces the rejected section and the summary row. +`docs/reference/config.md` currently says `insecure_tls` skips verification for +`http:` only; it must mention `tls:` too. `docs/how-to/monitor-ui.md` gains the +TLS row in the type table. `CLAUDE.md` gains one sentence: the check dials +through `netEnv`, verifies the peer DNS name rather than a subnet router's, and +fails when the leaf expires within 7 days. `CHANGELOG.md` gets an +`Unreleased` entry, which the release commit renames to the version +(`docs/contributing/releases.md`). The ROADMAP box is checked in the same change. + +## Risks / Trade-offs + +- [A leaf inside 7 days pages at the target's severity] → Operators who want a + quiet warning set `severity: warn` or a route. `target.degraded` is a + different roadmap item and is not a side door for this check. +- [A Service addressed by its short name fails name verification] → The checks + reference tells the operator to use `name.tailnet.ts.net` unless that name + matches a peer. Inventing the suffix would mis-verify LAN names. +- [Hand-edited `tls:10.20.0.50:443` still will not load] → Nothing running today + can contain a `tls:` check, because load rejected it. The error changes from + "not supported" to an invalid port. `tls:443` in a hand-edited `targets.json` + starts probing on upgrade, which is the point of the check. +- [Status is briefly empty at startup] → The seeded presence view is preferred, + and the status cache covers the unseeded window, a peer the view does not + list yet, and the collector's own address. A failed + status read does not fail an otherwise good handshake. +- [Private CAs need `insecure_tls`] → The check does not take a custom CA file. + Expiry of an untrusted leaf is still reported. Trusting a private CA is out + of scope. + +## Migration Plan + +No config key, state file, or metric series changes. Deploy the binary. +Rollback is the previous binary. Configs that loaded before still load. +`tls:` starts working. A string that is not a port still fails +`check-config`. + +## Open Questions + +None. The window, the paging behavior, and the name rules are fixed by the +delta specs. + +## Boundaries + +This change does not cross a CONTEXT.md boundary. It does not add a Go +dependency, a tailnet API credential, shared collector state, or an action on +the tailnet. Dialing stays on `netEnv`. The expiry comparison takes an injected +clock. + +It does replace an existing requirement. `collector-config` "Check syntax" +requires `tls:` to be rejected as phase 2, and `monitor-ui` "Add and edit form" +uses `tls:443` as the Custom row the server rejects. Both deltas replace those +requirements in full. `failure-classification` already calls a TLS error a +device answer. The added requirement makes the new expiry error that kind of +error, so the layer walk does not change. diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/proposal.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/proposal.md new file mode 100644 index 0000000..8478d79 --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/proposal.md @@ -0,0 +1,74 @@ +# Proposal + +## Why + +`tls:` is rejected at load time, so an operator cannot alert when a handshake fails +or a certificate is about to expire. Tailscale Serve and Tailscale Services present +publicly trusted certificates for `*.ts.net` names, and those certificates renew only +while the node keeps provisioning them. An HTTPS check proves the URL answers. It does +not say that the certificate itself is close to expiry, and it fails name verification +when the target address is a Tailscale IP or a short name rather than the name on the +certificate. + +This delivers the ROADMAP.md near-term item "TLS expiry check (`tls:` is rejected +today). Also covers the `*.ts.net` certificates that Tailscale Services and Serve +use" [M]. + +## What Changes + +- Accept `tls:` on a target. The check dials `addr` on that port, completes a + TLS handshake, and verifies the certificate with the system roots. +- Fail the check when the leaf certificate expires within 7 days, or is not yet + valid, even though a client would still accept it today. An already-expired or + untrusted certificate fails the handshake. +- When the address matches a peer in the collector's tailnet status, verify the + certificate against that peer's MagicDNS name. That is the name on a Serve + certificate. A Service or Serve endpoint addressed by its full `*.ts.net` name is + verified as that name. +- `insecure_tls: true` skips chain and hostname verification and still enforces the + leaf dates, so a device with a private certificate can be watched for expiry. +- The monitor UI gains a TLS row (a port, like TCP) that reads and writes + `tls:`. `tls:443` is no longer the example of a Custom row the server rejects. +- A TLS failure, including "expires within 7 days", is a device answer. The + classifier keeps the existing `service` layer when the tunnel is up. +- The check follows the target's severity and thresholds. There is no new event type + and no new metric. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `collector-config`: `tls:` is valid check syntax. The phase-2 rejection of + `tls:` is removed. +- `reachability-checks`: a TLS check proves the handshake and that the leaf + certificate is outside the 7-day expiry window, including a `*.ts.net` name. +- `failure-classification`: a TLS expiry or verification failure counts as the + device having answered. +- `monitor-ui`: the check builder has a TLS port row, and a still-unparseable string + other than `tls:` stays a Custom row. + +## Impact + +- `cmd/tail-watch/checks.go` and `checks_test.go` gain the check. `classify.go` + treats the expiry error as a TLS error. The UI check conversion in + `cmd/tail-watch/ui/app.js`, its node test, and the browser test follow. +- `docs/reference/checks.md` replaces the "rejected" section. `config.md` only if a + key changes. No config key is added. +- Same `parseCheck` path as the config file, `POST /api/v1/targets`, and + `POST /api/v1/probe`. No new dependency. The dial goes through `netEnv`, not + `prober.TLS` (that helper uses the process dialer and fetches a CRL). + +## Non-Goals + +- PagerDuty sink, incident.io sink, `target.degraded`, `tsnetEnv`, tag discovery + (`discover:`), and Slack bot-token mode. +- A configurable expiry window, a certificate-lifetime metric, CRL or OCSP fetches, + or a new alert severity for "expires soon". +- Checking a chain certificate's remaining life. Standard verification still rejects + a chain that is already expired or untrusted. +- Resolving a Tailscale Service that is not a peer and is not addressed by its + `*.ts.net` name. The operator sets `addr` to that name. diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/collector-config/spec.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/collector-config/spec.md new file mode 100644 index 0000000..d8e91f0 --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/collector-config/spec.md @@ -0,0 +1,37 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Check syntax + +Each entry in a target's `checks` list SHALL be one of: `icmp`; `tcp:`; +`tls:`; `http:` optionally followed by `|` where `` must +appear in the response body; or `tsping`. `` SHALL be an integer from 1 to +65535. The config loader SHALL reject any other form, including a `tls:` value +that is not `tls:`. + +#### Scenario: HTTP check with body match + +- **WHEN** a target lists `"http:https://10.20.0.50:5001/|DSM"` +- **THEN** the check requests `https://10.20.0.50:5001/` and requires the body to + contain `DSM` + +#### Scenario: Invalid TCP port + +- **WHEN** a target lists `tcp:70000` +- **THEN** loading fails with an error naming the target and the check + +#### Scenario: TLS port check + +- **WHEN** a target lists `tls:443` +- **THEN** the target loads and the check string is `tls:443` + +#### Scenario: Invalid TLS port + +- **WHEN** a target lists `tls:70000` +- **THEN** loading fails with an error naming the target and the check + +#### Scenario: Phase 2 check type + +- **WHEN** a target lists `tls:10.20.0.50:443` +- **THEN** loading fails with an error naming the target and the check diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/failure-classification/spec.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/failure-classification/spec.md new file mode 100644 index 0000000..f9f2f40 --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/failure-classification/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: TLS expiry counts as a device answer + +A `tls:` check that fails certificate verification, or that fails because the +leaf expires within 7 days or is not yet valid, SHALL be a TLS error for layer +decision. The classifier SHALL treat that error the same way it treats a TCP +refusal or an HTTP status or body mismatch: the device answered, the layer is +`service` once the tunnel is known to work, and the classifier SHALL NOT send +an ICMP echo to decide the layer. + +#### Scenario: Certificate expires soon behind a working tunnel + +- **WHEN** `nyc-nas`'s `tls:443` check fails with an error containing + `certificate expires` and a disco ping to `nyc-router` succeeds +- **THEN** the layer is `service` and no ICMP probe is sent + +#### Scenario: Certificate name mismatch behind a working tunnel + +- **WHEN** `nyc-nas`'s `tls:443` check fails certificate name verification and + a disco ping to `nyc-router` succeeds +- **THEN** the layer is `service` and no ICMP probe is sent diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/monitor-ui/spec.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/monitor-ui/spec.md new file mode 100644 index 0000000..dce1ee1 --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/monitor-ui/spec.md @@ -0,0 +1,99 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Add and edit form + +The page SHALL provide a form to add a monitor and to edit a UI-created one, covering +`name`, `addr`, `site`, routing (a `route`, or a `severity` with no route), `parent`, +`network`, `insecure_tls`, `checks`, `interval`, `timeout`, `fail_threshold`, +`recover_threshold`, `flap_limit` and `flap_window`. The name field SHALL be read-only when editing. Server validation errors +SHALL be shown next to the field they name. The parent field SHALL offer only monitors +that have exactly one `icmp` or one `tsping` check. + +The `checks` field SHALL be a list of check rows, not text in the check syntax. The list +SHALL start empty when the form opens to add a monitor. The operator SHALL add a row with +an "Add check" control and remove any row with its own control. A row SHALL have a type of +ICMP, TSPing, TCP, TLS or HTTP. A TCP row and a TLS row SHALL each have a port. An HTTP +row SHALL have a scheme (`http` or `https`), an optional port, a path and an optional text +the body must contain, and its host SHALL be taken from the address field when the form is +submitted, with an IPv6 address in brackets. On submit the form SHALL send each row, in +order and without dropping any, as the existing check string (`icmp`, `tsping`, +`tcp:`, `tls:`, `http:[|]`). The form SHALL NOT reject an empty +list itself: the server's validation SHALL decide. A server error that carries a row index +SHALL be shown on that row, and one without an index SHALL be shown with the list. When +editing, the form SHALL show the monitor's existing check strings as rows. A string that +no row type can express, or an HTTP string whose host is not the address, SHALL be shown +as a Custom row holding the raw string and SHALL be sent unchanged. + +#### Scenario: Validation error shown per field + +- **WHEN** the operator submits a TCP row with port `70000` +- **THEN** the form shows the server's error on that row and creates nothing + +#### Scenario: Name is fixed on edit + +- **WHEN** the operator edits an existing UI-created monitor +- **THEN** the name field cannot be changed + +#### Scenario: Form opens with no checks + +- **WHEN** the operator opens the form to add a monitor +- **THEN** the checks list has no rows and an "Add check" control + +#### Scenario: Empty checks fail, then a check succeeds + +- **WHEN** the operator fills name and address, saves with no rows, then adds a TCP row + with port `445` and saves +- **THEN** the first save creates nothing and shows the server's "checks must not be + empty" message with the list, and the second save creates the monitor with `tcp:445` + +#### Scenario: Remove a row + +- **WHEN** the operator adds two rows and removes the first +- **THEN** only the second remains and is the one sent + +#### Scenario: Incomplete row is reported on its row + +- **WHEN** the operator adds an ICMP row and a TCP row with no port and saves +- **THEN** the error is shown on the second row and the first row is unmarked + +#### Scenario: HTTP row takes its host from the address + +- **WHEN** the address is `10.20.0.50` and the operator adds an HTTP row with scheme + `https`, port `5001`, path `/` and text `DSM` +- **THEN** the form sends `http:https://10.20.0.50:5001/|DSM` + +#### Scenario: HTTP host follows an address change + +- **WHEN** the operator adds an HTTP row and then changes the address from `nas` to `nas2` +- **THEN** the form sends a URL whose host is `nas2` + +#### Scenario: IPv6 address is bracketed + +- **WHEN** the address is `fd7a:115c:a1e0::1` and the operator adds an HTTP row with path + `/` +- **THEN** the form sends `http:http://[fd7a:115c:a1e0::1]/` + +#### Scenario: Edit shows rows + +- **WHEN** the operator edits a monitor stored with `icmp`, `tcp:445` and + `http:https://10.20.0.50:5001/|DSM` at address `10.20.0.50` +- **THEN** the form shows an ICMP row, a TCP row with port `445`, and an HTTP row with + scheme `https`, port `5001`, path `/` and text `DSM` + +#### Scenario: Edit shows a TLS row + +- **WHEN** the operator edits a monitor whose stored check is `tls:443` +- **THEN** the form shows a TLS row with port `443` and sends `tls:443` + +#### Scenario: Unparseable check kept as a Custom row + +- **WHEN** the operator edits a monitor whose stored check is `snmp:1` +- **THEN** the form shows a Custom row holding `snmp:1` and sends it unchanged, and the + server's error for it is shown on that row + +#### Scenario: HTTP check for another host kept as a Custom row + +- **WHEN** a stored check is `http:http://other.lab/` and the address is `nas.lab` +- **THEN** the form shows it as a Custom row, not as an HTTP row with a different host diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/reachability-checks/spec.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/reachability-checks/spec.md new file mode 100644 index 0000000..14119f0 --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/specs/reachability-checks/spec.md @@ -0,0 +1,103 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: TLS expiry check + +A `tls:` check SHALL dial the target's `addr` on that port through the +collector's network path, complete a TLS handshake, and send no application +data. It SHALL verify the certificate chain with the system roots unless the +target sets `insecure_tls: true`. The name it verifies SHALL be chosen as +follows: + +- When `addr` matches a peer in tailnet status, by Tailscale IP, full MagicDNS + name, short MagicDNS name, or hostname, and that peer has a DNS name, the + verified name SHALL be that DNS name with one trailing dot removed. A peer + that only routes `addr` as a subnet SHALL NOT match. +- Otherwise, when `addr` is a DNS name, the verified name SHALL be `addr` with + one trailing dot removed. +- Otherwise the verified name SHALL be the IP address. + +When tailnet status cannot be read, including a `collector.local_only` +collector, matching a peer SHALL be skipped and the name rules above still +apply. A status failure SHALL NOT by itself fail the check. + +The check SHALL pass only when the handshake and verification succeed and the +leaf certificate's `NotAfter` is at least 7 times 24 hours after the current +time. It SHALL fail when the leaf's `NotBefore` is after the current time. +With `insecure_tls: true` the check SHALL skip chain and hostname verification +and SHALL still enforce those leaf dates, and SHALL fail when the server +presents no certificate. The check SHALL NOT fetch a CRL or an OCSP response. + +On expiry the error SHALL contain the text `certificate expires` and the leaf +`NotAfter` in RFC3339, and SHALL name the leaf by its first DNS SAN, or by its +subject when it has no DNS SAN. On a not-yet-valid leaf the error SHALL contain +the text `certificate is not valid before` and the leaf `NotBefore` in RFC3339. +The result's check name SHALL be the `tls:` string. Latency SHALL be the +time to finish the handshake. + +#### Scenario: Certificate valid for longer than 7 days + +- **WHEN** the handshake succeeds and the leaf `NotAfter` is 8 days after the + current time +- **THEN** the check passes and records the handshake latency + +#### Scenario: Leaf expires in less than 7 days + +- **WHEN** the handshake succeeds and the leaf `NotAfter` is 6 days after the + current time +- **THEN** the check fails with an error containing `certificate expires` and + that `NotAfter` time in RFC3339 + +#### Scenario: Leaf already expired + +- **WHEN** the leaf `NotAfter` is before the current time and `insecure_tls` is + unset +- **THEN** the check fails + +#### Scenario: Serve certificate on a Tailscale IP + +- **WHEN** `addr` is `100.64.0.8`, tailnet status shows that IP on a peer whose + DNS name is `nas.tailnet.ts.net.`, and the server presents a chain trusted by + the system roots whose leaf is valid for `nas.tailnet.ts.net` with `NotAfter` + 30 days away +- **THEN** the check passes + +#### Scenario: Service certificate addressed by its ts.net name + +- **WHEN** `addr` is `web.tailnet.ts.net`, that name matches no peer, and the + server presents a chain trusted by the system roots whose leaf is valid for + `web.tailnet.ts.net` with `NotAfter` 30 days away +- **THEN** the check passes + +#### Scenario: Subnet route does not supply the certificate name + +- **WHEN** `addr` is `10.20.0.50`, the only peer relationship is a subnet route + advertised by a peer named `nyc-router.tailnet.ts.net`, and the certificate + is valid only for `nyc-router.tailnet.ts.net` +- **THEN** the check fails name verification + +#### Scenario: Short name with no peer keeps the short name + +- **WHEN** `addr` is `nas`, tailnet status has no matching peer, and the + certificate is valid only for `nas.tailnet.ts.net` +- **THEN** the check fails name verification + +#### Scenario: Self-signed leaf watched for expiry + +- **WHEN** the target sets `insecure_tls: true` and the server presents a + self-signed leaf whose `NotAfter` is 30 days away +- **THEN** the check passes + +#### Scenario: Self-signed leaf inside the window + +- **WHEN** the target sets `insecure_tls: true` and the self-signed leaf's + `NotAfter` is 2 days away +- **THEN** the check fails with an error containing `certificate expires` + +#### Scenario: Status unavailable still dials a DNS name + +- **WHEN** the collector is `collector.local_only`, `addr` is + `web.example.ts.net`, and the leaf is valid for that name with `NotAfter` + 30 days away +- **THEN** the check passes without reading tailnet status diff --git a/openspec/changes/archive/2026-11-03-add-tls-expiry-check/tasks.md b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/tasks.md new file mode 100644 index 0000000..05f33dd --- /dev/null +++ b/openspec/changes/archive/2026-11-03-add-tls-expiry-check/tasks.md @@ -0,0 +1,29 @@ +# Tasks + +## 1. TLS check + +- [x] 1.1 Add the failing tests for `tls:` in `cmd/tail-watch/checks_test.go` (`TestTLSCheck`) and update `TestCheckParsing` and `TestConfigValidation` in `cmd/tail-watch/config_test.go`. Cover: `tls:443` parses; `tls:70000`, `tls:0`, `tls:abc` and `tls:10.20.0.50:443` fail naming the port; a leaf at least 7 days out passes and records latency; a leaf sooner than 7 days fails with `certificate expires` and an RFC3339 `NotAfter`; an already-expired leaf fails verification; `insecure_tls` passes a self-signed leaf 30 days out and fails one 2 days out; `ServerName` is the peer DNS name for a Tailscale IP and for a short or full MagicDNS name; a subnet route does not supply that name; a full `*.ts.net` address with no peer verifies as itself; a short name with no peer does not gain the tailnet suffix; a nil resolver (the `local_only` case) still dials a DNS name. Use `fakeEnv` dialing a `tls.Listener`, a fixture CA on `tlsCheck.roots`, a `tstest.Clock`, and a `tsResolver` over an `ipnstate.Status`. Verify `mise exec -- go test ./cmd/tail-watch -run 'TestTLSCheck|TestCheckParsing|TestConfigValidation' -count=1` compiles and fails on these cases. + +- [x] 1.2 Implement `tlsCheck` in `cmd/tail-watch/checks.go` and teach `bindChecks` in `cmd/tail-watch/main.go` to set its clock from `app.clock` and its resolver when the app has one. Dial with `env.Dial`, handshake with `tls.Client`, verify with the system roots unless `roots` or `insecure_tls` is set, and apply `tlsExpiryWindow` of 7 days to the leaf only. Add `TestBindChecksTLS` in `cmd/tail-watch/main_test.go` for the clock and resolver wiring, including a nil resolver. Verify `mise exec -- go test ./cmd/tail-watch -run 'TestTLSCheck|TestCheckParsing|TestConfigValidation|TestBindChecksTLS' -count=1` passes. + +- [x] 1.3 Document the check in `docs/reference/checks.md` (summary row and a `tls:` section in place of the rejected section) and extend the `insecure_tls` row in `docs/reference/config.md` to include `tls:` checks. Add one sentence to `CLAUDE.md`: the check dials through `netEnv`, takes the verified name from the owning peer's DNS name and not from a subnet route, and fails when the leaf expires within 7 days. Verify `markdownlint-cli2 docs/reference/checks.md docs/reference/config.md CLAUDE.md` passes and the new section names `tls:443`, the 7-day window, and the `*.ts.net` address form. + +## 2. Classification + +- [x] 2.1 Add a failing case in `cmd/tail-watch/classify_test.go`: a `tls:443` failure whose error contains `certificate expires`, with the tunnel up, is layer `service` and does not send ICMP; a hostname-mismatch failure takes the same path. Verify `mise exec -- go test ./cmd/tail-watch -run TestClassify -count=1` fails on that case. + +- [x] 2.2 Teach `classifyErr` in `cmd/tail-watch/classify.go` to return `errTLS` for `certExpiryError` and the not-yet-valid leaf error. Verify `mise exec -- go test ./cmd/tail-watch -run TestClassify -count=1` passes. + +## 3. Monitor UI + +- [x] 3.1 Update `cmd/tail-watch/ui_checks_test.go` so `tls:443` round-trips as a TLS row with port `443`, and `snmp:1` stays Custom. Point `TestUIBrowserCustomRowKeepsUnparseableCheck` in `cmd/tail-watch/ui_checks_browser_test.go` at `snmp:1` and an `unknown check type` error, and add a browser case that an edited `tls:443` shows a TLS row with port `443`. Verify `mise exec -- go test ./cmd/tail-watch -run 'TestUICheckConversion|TestUICheckRoundTrip' -count=1` fails until 3.2, and the browser run is left for 3.2. + +- [x] 3.2 Add the TLS certificate row in `cmd/tail-watch/ui/app.js` (`CHECK_TYPE_LABELS`, `checkToString`, `checkFromString`, `checkRowEl`), keeping the check-conversion block free of DOM access except `checkRowEl`. Verify `mise exec -- go test ./cmd/tail-watch -run 'TestUICheckConversion|TestUICheckRoundTrip|TestUIBrowserCustomRowKeepsUnparseableCheck|TestUIBrowserTLS' -count=1` passes. The browser tests skip when the Playwright driver is missing; say so if they skip. + +- [x] 3.3 Add a TLS certificate row to the check-type table in `docs/how-to/monitor-ui.md`. Verify `markdownlint-cli2 docs/how-to/monitor-ui.md` passes. + +## 4. Validation + +- [x] 4.1 Run `mise exec -- go build ./...`, `mise exec -- go vet ./...`, `mise exec -- go test ./...`, `mise exec -- golangci-lint run ./...`, and `mise run vulncheck`. Verify each exits 0. + +- [x] 4.2 Run `markdownlint-cli2` on the changed Markdown, including this change's artifacts, and `mise exec -- openspec validate add-tls-expiry-check --strict`. Verify both exit 0. Add an `Unreleased` entry to `CHANGELOG.md` and check the ROADMAP.md box. diff --git a/openspec/specs/collector-config/spec.md b/openspec/specs/collector-config/spec.md index cff2be4..c39556d 100644 --- a/openspec/specs/collector-config/spec.md +++ b/openspec/specs/collector-config/spec.md @@ -145,9 +145,10 @@ target's severity is the route's. ### Requirement: Check syntax Each entry in a target's `checks` list SHALL be one of: `icmp`; `tcp:`; -`http:` optionally followed by `|` where `` must appear in the -response body; or `tsping`. The config loader SHALL reject any other form. It -SHALL reject `tls` checks with an error stating that they arrive in phase 2. +`tls:`; `http:` optionally followed by `|` where `` must +appear in the response body; or `tsping`. `` SHALL be an integer from 1 to +65535. The config loader SHALL reject any other form, including a `tls:` value +that is not `tls:`. #### Scenario: HTTP check with body match @@ -160,11 +161,20 @@ SHALL reject `tls` checks with an error stating that they arrive in phase 2. - **WHEN** a target lists `tcp:70000` - **THEN** loading fails with an error naming the target and the check +#### Scenario: TLS port check + +- **WHEN** a target lists `tls:443` +- **THEN** the target loads and the check string is `tls:443` + +#### Scenario: Invalid TLS port + +- **WHEN** a target lists `tls:70000` +- **THEN** loading fails with an error naming the target and the check + #### Scenario: Phase 2 check type - **WHEN** a target lists `tls:10.20.0.50:443` -- **THEN** loading fails with an error saying `tls` checks are not supported in - this version +- **THEN** loading fails with an error naming the target and the check ### Requirement: Config validation diff --git a/openspec/specs/failure-classification/spec.md b/openspec/specs/failure-classification/spec.md index 6c4efdb..1cc9e97 100644 --- a/openspec/specs/failure-classification/spec.md +++ b/openspec/specs/failure-classification/spec.md @@ -159,3 +159,24 @@ for every failing result, using the assigned layer. - **WHEN** `nyc-nas` fails three times with layer `service` - **THEN** `tailwatch_failure_layer_total{target="nyc-nas",layer="service"}` increases by 3 + +### Requirement: TLS expiry counts as a device answer + +A `tls:` check that fails certificate verification, or that fails because the +leaf expires within 7 days or is not yet valid, SHALL be a TLS error for layer +decision. The classifier SHALL treat that error the same way it treats a TCP +refusal or an HTTP status or body mismatch: the device answered, the layer is +`service` once the tunnel is known to work, and the classifier SHALL NOT send +an ICMP echo to decide the layer. + +#### Scenario: Certificate expires soon behind a working tunnel + +- **WHEN** `nyc-nas`'s `tls:443` check fails with an error containing + `certificate expires` and a disco ping to `nyc-router` succeeds +- **THEN** the layer is `service` and no ICMP probe is sent + +#### Scenario: Certificate name mismatch behind a working tunnel + +- **WHEN** `nyc-nas`'s `tls:443` check fails certificate name verification and + a disco ping to `nyc-router` succeeds +- **THEN** the layer is `service` and no ICMP probe is sent diff --git a/openspec/specs/monitor-ui/spec.md b/openspec/specs/monitor-ui/spec.md index 8c0335e..eacc20a 100644 --- a/openspec/specs/monitor-ui/spec.md +++ b/openspec/specs/monitor-ui/spec.md @@ -71,17 +71,17 @@ that have exactly one `icmp` or one `tsping` check. The `checks` field SHALL be a list of check rows, not text in the check syntax. The list SHALL start empty when the form opens to add a monitor. The operator SHALL add a row with an "Add check" control and remove any row with its own control. A row SHALL have a type of -ICMP, TSPing, TCP or HTTP. A TCP row SHALL have a port. An HTTP row SHALL have a scheme -(`http` or `https`), an optional port, a path and an optional text the body must contain, -and its host SHALL be taken from the address field when the form is submitted, with an IPv6 -address in brackets. On submit the form SHALL send each row, in order and without -dropping any, as the existing check string (`icmp`, `tsping`, `tcp:`, -`http:[|]`). The form SHALL NOT reject an empty list itself: the server's -validation SHALL decide. A server error that carries a row index SHALL be shown on that -row, and one without an index SHALL be shown with the list. When editing, the form SHALL -show the monitor's existing check strings as rows. A string that no row type can express, -or an HTTP string whose host is not the address, SHALL be shown as a Custom row holding -the raw string and SHALL be sent unchanged. +ICMP, TSPing, TCP, TLS or HTTP. A TCP row and a TLS row SHALL each have a port. An HTTP +row SHALL have a scheme (`http` or `https`), an optional port, a path and an optional text +the body must contain, and its host SHALL be taken from the address field when the form is +submitted, with an IPv6 address in brackets. On submit the form SHALL send each row, in +order and without dropping any, as the existing check string (`icmp`, `tsping`, +`tcp:`, `tls:`, `http:[|]`). The form SHALL NOT reject an empty +list itself: the server's validation SHALL decide. A server error that carries a row index +SHALL be shown on that row, and one without an index SHALL be shown with the list. When +editing, the form SHALL show the monitor's existing check strings as rows. A string that +no row type can express, or an HTTP string whose host is not the address, SHALL be shown +as a Custom row holding the raw string and SHALL be sent unchanged. #### Scenario: Validation error shown per field @@ -139,10 +139,15 @@ the raw string and SHALL be sent unchanged. - **THEN** the form shows an ICMP row, a TCP row with port `445`, and an HTTP row with scheme `https`, port `5001`, path `/` and text `DSM` -#### Scenario: Unparseable check kept as a Custom row +#### Scenario: Edit shows a TLS row - **WHEN** the operator edits a monitor whose stored check is `tls:443` -- **THEN** the form shows a Custom row holding `tls:443` and sends it unchanged, and the +- **THEN** the form shows a TLS row with port `443` and sends `tls:443` + +#### Scenario: Unparseable check kept as a Custom row + +- **WHEN** the operator edits a monitor whose stored check is `snmp:1` +- **THEN** the form shows a Custom row holding `snmp:1` and sends it unchanged, and the server's error for it is shown on that row #### Scenario: HTTP check for another host kept as a Custom row diff --git a/openspec/specs/reachability-checks/spec.md b/openspec/specs/reachability-checks/spec.md index 38d8907..c35c300 100644 --- a/openspec/specs/reachability-checks/spec.md +++ b/openspec/specs/reachability-checks/spec.md @@ -144,3 +144,103 @@ the collector's result consumers in the order checks complete for that target. - **WHEN** a `tcp:445` check fails - **THEN** a result with target, check `tcp:445` and the dial error is delivered to every registered result consumer + +### Requirement: TLS expiry check + +A `tls:` check SHALL dial the target's `addr` on that port through the +collector's network path, complete a TLS handshake, and send no application +data. It SHALL verify the certificate chain with the system roots unless the +target sets `insecure_tls: true`. The name it verifies SHALL be chosen as +follows: + +- When `addr` matches a peer in tailnet status, by Tailscale IP, full MagicDNS + name, short MagicDNS name, or hostname, and that peer has a DNS name, the + verified name SHALL be that DNS name with one trailing dot removed. A peer + that only routes `addr` as a subnet SHALL NOT match. +- Otherwise, when `addr` is a DNS name, the verified name SHALL be `addr` with + one trailing dot removed. +- Otherwise the verified name SHALL be the IP address. + +When tailnet status cannot be read, including a `collector.local_only` +collector, matching a peer SHALL be skipped and the name rules above still +apply. A status failure SHALL NOT by itself fail the check. + +The check SHALL pass only when the handshake and verification succeed and the +leaf certificate's `NotAfter` is at least 7 times 24 hours after the current +time. It SHALL fail when the leaf's `NotBefore` is after the current time. +With `insecure_tls: true` the check SHALL skip chain and hostname verification +and SHALL still enforce those leaf dates, and SHALL fail when the server +presents no certificate. The check SHALL NOT fetch a CRL or an OCSP response. + +On expiry the error SHALL contain the text `certificate expires` and the leaf +`NotAfter` in RFC3339, and SHALL name the leaf by its first DNS SAN, or by its +subject when it has no DNS SAN. On a not-yet-valid leaf the error SHALL contain +the text `certificate is not valid before` and the leaf `NotBefore` in RFC3339. +The result's check name SHALL be the `tls:` string. Latency SHALL be the +time to finish the handshake. + +#### Scenario: Certificate valid for longer than 7 days + +- **WHEN** the handshake succeeds and the leaf `NotAfter` is 8 days after the + current time +- **THEN** the check passes and records the handshake latency + +#### Scenario: Leaf expires in less than 7 days + +- **WHEN** the handshake succeeds and the leaf `NotAfter` is 6 days after the + current time +- **THEN** the check fails with an error containing `certificate expires` and + that `NotAfter` time in RFC3339 + +#### Scenario: Leaf already expired + +- **WHEN** the leaf `NotAfter` is before the current time and `insecure_tls` is + unset +- **THEN** the check fails + +#### Scenario: Serve certificate on a Tailscale IP + +- **WHEN** `addr` is `100.64.0.8`, tailnet status shows that IP on a peer whose + DNS name is `nas.tailnet.ts.net.`, and the server presents a chain trusted by + the system roots whose leaf is valid for `nas.tailnet.ts.net` with `NotAfter` + 30 days away +- **THEN** the check passes + +#### Scenario: Service certificate addressed by its ts.net name + +- **WHEN** `addr` is `web.tailnet.ts.net`, that name matches no peer, and the + server presents a chain trusted by the system roots whose leaf is valid for + `web.tailnet.ts.net` with `NotAfter` 30 days away +- **THEN** the check passes + +#### Scenario: Subnet route does not supply the certificate name + +- **WHEN** `addr` is `10.20.0.50`, the only peer relationship is a subnet route + advertised by a peer named `nyc-router.tailnet.ts.net`, and the certificate + is valid only for `nyc-router.tailnet.ts.net` +- **THEN** the check fails name verification + +#### Scenario: Short name with no peer keeps the short name + +- **WHEN** `addr` is `nas`, tailnet status has no matching peer, and the + certificate is valid only for `nas.tailnet.ts.net` +- **THEN** the check fails name verification + +#### Scenario: Self-signed leaf watched for expiry + +- **WHEN** the target sets `insecure_tls: true` and the server presents a + self-signed leaf whose `NotAfter` is 30 days away +- **THEN** the check passes + +#### Scenario: Self-signed leaf inside the window + +- **WHEN** the target sets `insecure_tls: true` and the self-signed leaf's + `NotAfter` is 2 days away +- **THEN** the check fails with an error containing `certificate expires` + +#### Scenario: Status unavailable still dials a DNS name + +- **WHEN** the collector is `collector.local_only`, `addr` is + `web.example.ts.net`, and the leaf is valid for that name with `NotAfter` + 30 days away +- **THEN** the check passes without reading tailnet status