Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

<!-- markdownlint-disable MD024 -->

## Unreleased

### Added

- `tls:<port>` 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
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 172 additions & 6 deletions cmd/tail-watch/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -32,27 +33,46 @@ 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:
return icmpCheck{}, nil
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:"):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading