Skip to content
Merged
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
38 changes: 38 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@
an empty op means unaudited and unauthorized (validate only).
- `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.
- `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` and the UI; `main.go` then skips
the LocalAPI preflight, presence view, resolver binding and dependency
learner, and takes the identity from `os.Hostname()`, so payloads carry
`collector.name` and omit `collector.tailscale_ip`. The classifier already
accepts a nil presence and classifies `network: local` failures from device
evidence alone.
- `alert.go` is one mutex-guarded state machine. Grace and flap timers hand work
to a goroutine from `AfterFunc`: `tstest.Clock.Advance` holds the clock
lock while callbacks fire, so they cannot call back into the clock.
Expand Down Expand Up @@ -151,3 +159,33 @@
- `stageRelease` lays files out like an unpacked GoReleaser archive, which keeps the
`examples/` prefix; keep the staging, the docs and `.goreleaser.yaml` in step.
- `github.com/pkg/sftp` and `golang.org/x/crypto/ssh` are direct deps only for this test.

## kind ProxyGroup test

- `TestKindProxyGroup` (`kind_proxygroup_test.go`) is gated on `TAIL_WATCH_KIND=1`,
skips when `kind`, `kubectl`, `helm` or `docker` is missing, and runs the fenced bash
blocks under each method heading in `docs/how-to/kubernetes-proxygroup.md`, so editing
those blocks changes the test. The sidecar contract the blocks produce: pods with both
`tailscale.com/parent-resource-type: proxygroup` and `tail-watch/inject: "true"` get a
local-only `tail-watch` container reading ConfigMap `tail-watch-<proxygroup>` (key
`tail-watch.yaml`, optional); sink secrets come from a same-named Secret via `envFrom`.
Every method builds that name from the pod's `tailscale.com/parent-resource` label;
never hard-code `ingress`.
- `examples/kubernetes/sidecar.yaml` is the reference container. `TestSidecarCopiesAgree`
(not gated) fails when the Kyverno, MutatingAdmissionPolicy, freeze-and-patch or
how-to webhook copy drifts from it, so edit all five together.
- `scripts/kind-stack.sh up|down` is the harness (cluster config
`cmd/tail-watch/testdata/kind-proxygroup.yaml`, fixtures in
`cmd/tail-watch/testdata/kind/`); the test runs `up` itself when the cluster or the
`ingress` ProxyGroup is missing. The operator chart is pinned to the newest chart at or
below the `tailscale.com` version in `go.mod`; the OAuth client comes from the
`tailscale-phase-2/tailscale` pulumi env as `tailscale:operatorClientId/Secret` and
reaches Helm through a mode-0600 values file, never the command line or a log.
- The test's `sidecarConfig` is the documented ConfigMap with tighter timing; keep its
mechanics (local-only, `tcp:` check, webhook sink) in step with
`examples/kubernetes/configmap.yaml`. The receiver is `alert-receiver` in `testdata/kind/`:
it stores POST bodies and returns them on `GET /events`, and the test asserts new events
by offset because the receiver accumulates.
- The test leaves the harness up. Teardown is `scripts/kind-stack.sh down`: it deletes the
cluster, the Tailscale Service `svc:tw-pg-web`, and every tailnet device with the `tw-pg`
hostname prefix.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ The [documentation index](docs/README.md) lists every page. The main ones:
systemd](docs/how-to/deploy.md#run-under-systemd)), [write the
ACLs](docs/how-to/acls.md),
[send alerts to Slack](docs/how-to/slack-sink.md),
[manage monitors in the UI](docs/how-to/monitor-ui.md), and
[manage monitors in the UI](docs/how-to/monitor-ui.md),
[watch a Kubernetes ProxyGroup](docs/how-to/kubernetes-proxygroup.md), and
[troubleshoot](docs/how-to/troubleshoot.md)
- Look up: [configuration](docs/reference/config.md),
[command line](docs/reference/cli.md), [metrics](docs/reference/metrics.md), and
Expand Down
113 changes: 80 additions & 33 deletions cmd/tail-watch/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ type CollectorConfig struct {
Listen string
StateDir string
UI bool
// LocalOnly runs the collector without tailscaled: targets default to
// network local, and tailnet targets, tsping checks and the UI are
// rejected.
LocalOnly bool
}

// Target is one monitored device with its checks and resolved timings.
Expand Down Expand Up @@ -143,9 +147,10 @@ type rawConfig struct {
}

type rawCollector struct {
Listen string `yaml:"listen"`
StateDir string `yaml:"state_dir"`
UI *bool `yaml:"ui"`
Listen string `yaml:"listen"`
StateDir string `yaml:"state_dir"`
UI *bool `yaml:"ui"`
LocalOnly *bool `yaml:"local_only"`
}

type rawDefaults struct {
Expand Down Expand Up @@ -245,16 +250,30 @@ func builtinTiming() timing {
}
}

// errUILocalOnly is returned for collector.ui and for --ui in local-only mode.
var errUILocalOnly = errors.New("the UI is unavailable in local-only mode")

// targetRules is what every target in one config resolves against.
type targetRules struct {
base timing
localOnly bool
}

func resolveConfig(raw rawConfig) (*Config, []error) {
var errs []error
localOnly := raw.Collector.LocalOnly != nil && *raw.Collector.LocalOnly
if localOnly && raw.Collector.UI != nil && *raw.Collector.UI {
errs = append(errs, fmt.Errorf("collector.ui: %w", errUILocalOnly))
}
base := resolveDefaults(&errs, raw.Defaults)
targets, targetErrs := resolveTargets(raw.Targets, base)
targets, targetErrs := resolveTargets(raw.Targets, targetRules{base: base, localOnly: localOnly})
errs = append(errs, targetErrs...)
cfg := &Config{
Collector: CollectorConfig{
Listen: raw.Collector.Listen,
StateDir: raw.Collector.StateDir,
UI: raw.Collector.UI != nil && *raw.Collector.UI,
Listen: raw.Collector.Listen,
StateDir: raw.Collector.StateDir,
UI: raw.Collector.UI != nil && *raw.Collector.UI,
LocalOnly: localOnly,
},
Targets: targets,
Sinks: nil,
Expand All @@ -280,13 +299,13 @@ func resolveDefaults(errs *[]error, d rawDefaults) timing {

// resolveTargets validates a target list as a whole: each target, then the
// parent references and parent cycles across the list.
func resolveTargets(raw []rawTarget, base timing) ([]Target, []error) {
func resolveTargets(raw []rawTarget, rules targetRules) ([]Target, []error) {
var errs []error
targets := make([]Target, len(raw))
seen := map[string]int{}
checksOK := make([]bool, len(raw))
for i, rt := range raw {
t, ok := resolveTarget(&errs, i, rt, base, seen)
t, ok := resolveTarget(&errs, i, rt, rules, seen)
targets[i] = t
checksOK[i] = ok
}
Expand Down Expand Up @@ -332,19 +351,19 @@ func resolveTargets(raw []rawTarget, base timing) ([]Target, []error) {
return targets, errs
}

func resolveTarget(errs *[]error, i int, rt rawTarget, base timing, seen map[string]int) (Target, bool) {
func resolveTarget(errs *[]error, i int, rt rawTarget, rules targetRules, seen map[string]int) (Target, bool) {
t := Target{
Name: rt.Name,
Addr: rt.Addr,
Site: rt.Site,
Parent: rt.Parent,
InsecureTLS: rt.InsecureTLS,
Interval: base.Interval,
Timeout: base.Timeout,
FailThreshold: base.FailThreshold,
RecoverThreshold: base.RecoverThreshold,
FlapLimit: base.FlapLimit,
FlapWindow: base.FlapWindow,
Interval: rules.base.Interval,
Timeout: rules.base.Timeout,
FailThreshold: rules.base.FailThreshold,
RecoverThreshold: rules.base.RecoverThreshold,
FlapLimit: rules.base.FlapLimit,
FlapWindow: rules.base.FlapWindow,
Severity: SeverityPage,
Network: NetworkTailnet,

Expand Down Expand Up @@ -378,17 +397,7 @@ func resolveTarget(errs *[]error, i int, rt rawTarget, base timing, seen map[str
fmt.Errorf("%s.severity: target %s: severity %q must be page or warn", loc, targetLabel(t), rt.Severity),
)
}
switch rt.Network {
case "", string(NetworkTailnet):
t.Network = NetworkTailnet
case string(NetworkLocal):
t.Network = NetworkLocal
default:
*errs = append(
*errs,
fmt.Errorf("%s.network: target %s: network %q must be tailnet or local", loc, targetLabel(t), rt.Network),
)
}
resolveNetwork(errs, loc, rt.Network, rules.localOnly, &t)

applyDur(errs, loc+".interval", rt.Interval, &t.Interval)
applyDur(errs, loc+".timeout", rt.Timeout, &t.Timeout)
Expand Down Expand Up @@ -427,11 +436,44 @@ func resolveTarget(errs *[]error, i int, rt rawTarget, base timing, seen map[str
*errs = append(*errs, fmt.Errorf("%s.flap_limit: target %s: must be >= 1", loc, targetLabel(t)))
}

ok := resolveChecks(errs, loc, rt.Checks, &t)
ok := resolveChecks(errs, loc, rt.Checks, &t, rules.localOnly)
return t, ok
}

func resolveChecks(errs *[]error, loc string, rawChecks []string, t *Target) bool {
// resolveNetwork sets t.Network. localOnly defaults an unset network to
// local and rejects an explicit tailnet.
func resolveNetwork(errs *[]error, loc, raw string, localOnly bool, t *Target) {
switch raw {
case "":
if localOnly {
t.Network = NetworkLocal
} else {
t.Network = NetworkTailnet
}
case string(NetworkTailnet):
if localOnly {
*errs = append(
*errs,
fmt.Errorf(
"%s.network: target %s: network %q is not valid in local-only mode",
loc,
targetLabel(*t),
raw,
),
)
}
t.Network = NetworkTailnet
case string(NetworkLocal):
t.Network = NetworkLocal
default:
*errs = append(
*errs,
fmt.Errorf("%s.network: target %s: network %q must be tailnet or local", loc, targetLabel(*t), raw),
)
}
}

func resolveChecks(errs *[]error, loc string, rawChecks []string, t *Target, localOnly bool) bool {
if len(rawChecks) == 0 {
*errs = append(*errs, fmt.Errorf("%s.checks: target %s: checks must not be empty", loc, targetLabel(*t)))
return false
Expand All @@ -455,10 +497,14 @@ func resolveChecks(errs *[]error, loc string, rawChecks []string, t *Target) boo
)
}
seenChk[c.String()] = struct{}{}
if t.Network == NetworkLocal && c.Kind() == checkTSPing {
if c.Kind() == checkTSPing && (t.Network == NetworkLocal || localOnly) {
msg := "tsping is not valid for network local"
if localOnly {
msg = "tsping is not valid in local-only mode"
}
*errs = append(
*errs,
fmt.Errorf("%s.checks[%d]: target %s: tsping is not valid for network local", loc, j, targetLabel(*t)),
fmt.Errorf("%s.checks[%d]: target %s: %s", loc, j, targetLabel(*t), msg),
)
}
if h, isHTTP := c.(httpCheck); isHTTP && t.Addr != "" && !sameHost(t.Addr, h.host) {
Expand Down Expand Up @@ -620,8 +666,9 @@ var (
func (c *Config) validateCandidate(existing []rawTarget, candidate rawTarget) (Target, []error) {
all := append(slices.Clone(existing), candidate)
idx := len(existing)
targets, errs := resolveTargets(all, c.Base)
_, baseline := resolveTargets(existing, c.Base)
rules := targetRules{base: c.Base, localOnly: c.Collector.LocalOnly}
targets, errs := resolveTargets(all, rules)
_, baseline := resolveTargets(existing, rules)
known := make(map[string]bool, len(baseline))
for _, err := range baseline {
known[err.Error()] = true
Expand Down
109 changes: 108 additions & 1 deletion cmd/tail-watch/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,117 @@ func TestExampleConfig(t *testing.T) {
}
}

//nolint:gocognit // Local-only acceptance and rejection cases keep their inputs next to the assertions.
func TestLocalOnlyConfig(t *testing.T) {
t.Parallel()
t.Run("targets default to local", func(t *testing.T) {
t.Parallel()
cfg, err := parseConfig([]byte(`
collector: {local_only: true}
targets:
- {name: web, addr: web.apps.svc.cluster.local, checks: ['tcp:443']}
- {name: api, addr: api.apps.svc.cluster.local, network: local, checks: ['tcp:80']}
`))
if err != nil {
t.Fatal(err)
}
if !cfg.Collector.LocalOnly {
t.Fatal("local_only not resolved")
}
for _, target := range cfg.Targets {
if target.Network != NetworkLocal {
t.Errorf("target %s network %s, want local", target.Name, target.Network)
}
}
})
t.Run("rejections", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
src string
wants []string
}{
{
name: "tailnet target",
src: `
collector: {local_only: true}
targets:
- {name: nas, addr: 100.64.0.1, network: tailnet, checks: [icmp]}
`,
wants: []string{"targets[0].network", "nas", "tailnet", "local-only"},
},
{
name: "tsping check",
src: `
collector: {local_only: true}
targets:
- {name: nas, addr: 100.64.0.1, checks: [tsping]}
`,
wants: []string{"targets[0].checks[0]", "nas", "tsping is not valid in local-only mode"},
},
{
name: "ui on",
src: `
collector: {local_only: true, ui: true, state_dir: /var/lib/tail-watch}
targets:
- {name: web, addr: 10.0.0.1, checks: ['tcp:443']}
`,
wants: []string{"collector.ui", "unavailable in local-only mode"},
},
{
name: "several problems together",
src: `
collector: {local_only: true, ui: true, state_dir: /var/lib/tail-watch}
targets:
- {name: nas, addr: 100.64.0.1, network: tailnet, checks: [tsping]}
- {name: web, addr: 10.0.0.1, checks: ['tcp:443']}
`,
wants: []string{
"collector.ui",
"targets[0].network",
"targets[0].checks[0]",
"tsping is not valid in local-only mode",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseConfig([]byte(tt.src))
if err == nil {
t.Fatal("expected error")
}
for _, want := range tt.wants {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q missing %q", err, want)
}
}
})
}
})
t.Run("local_only off keeps today", func(t *testing.T) {
t.Parallel()
cfg, err := parseConfig([]byte(`
collector: {local_only: false}
targets:
- {name: nas, addr: 100.64.0.1, checks: [tsping]}
`))
if err != nil {
t.Fatal(err)
}
if cfg.Collector.LocalOnly {
t.Fatal("local_only resolved on")
}
if cfg.Targets[0].Network != NetworkTailnet {
t.Fatalf("network %s, want tailnet", cfg.Targets[0].Network)
}
})
}

func TestListenFlagOverride(t *testing.T) {
t.Parallel()
cfg := &Config{
Collector: CollectorConfig{Listen: "127.0.0.1:1", StateDir: "", UI: false},
Collector: CollectorConfig{Listen: "127.0.0.1:1", StateDir: "", UI: false, LocalOnly: false},
Targets: nil,
Sinks: nil,
Heartbeat: nil,
Expand Down
Loading
Loading