From d5e2fa8e31e3d56bb9cf28f789afd7f290617ab8 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Wed, 25 Mar 2026 17:25:58 +0000 Subject: [PATCH 1/2] feat: add healthcheck --- cmd/probe/main.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/probe/main.go b/cmd/probe/main.go index 0fb87ab..c8665bc 100644 --- a/cmd/probe/main.go +++ b/cmd/probe/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log/slog" + "net/http" "os" "os/signal" "runtime" @@ -22,6 +23,7 @@ var version = "dev" func main() { cfgPath := flag.String("config", "", "path to probe.yaml config file") showVersion := flag.Bool("version", false, "print version and exit") + healthcheck := flag.Bool("healthcheck", false, "check health endpoint and exit") flag.Parse() if *showVersion { @@ -29,6 +31,10 @@ func main() { os.Exit(0) } + if *healthcheck { + os.Exit(runHealthcheck()) + } + cfg, err := config.Load(*cfgPath) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) @@ -101,6 +107,18 @@ func main() { logger.Info("krakenkey-probe stopped") } +func runHealthcheck() int { + resp, err := http.Get("http://localhost:8080/healthz") + if err != nil { + return 1 + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return 0 + } + return 1 +} + func setupLogger(level, format string) *slog.Logger { var lvl slog.Level switch level { From fa325915243b7b4887d2f2bff9eac7dbafdee0e4 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Thu, 26 Mar 2026 11:39:05 +0000 Subject: [PATCH 2/2] fix: standalone --- .env.example | 42 ++++++++ .gitignore | 16 ++++ Dockerfile | 2 + cmd/probe/main.go | 34 ++++--- docker-compose.yaml | 22 +++-- internal/config/config.go | 31 +++--- internal/config/config_test.go | 104 ++++++++++++++++---- internal/health/health_test.go | 16 ++-- internal/reporter/reporter_test.go | 4 +- internal/scheduler/scheduler.go | 137 +++++++++++++++++++++------ internal/scheduler/scheduler_test.go | 74 +++++---------- internal/state/state.go | 4 +- internal/state/state_test.go | 6 +- probe.example.yaml | 15 +-- 14 files changed, 356 insertions(+), 151 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cfd3b6e --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +################################## +# KrakenKey Probe Configuration # +################################## + +# Mode: "standalone" or "connected" +# standalone - fully local, no API communication, results logged only +# connected - endpoints managed in KrakenKey dashboard, fetched from API +KK_PROBE_MODE=standalone + +# Probe display name (shown in health endpoint / dashboard) +KK_PROBE_NAME=my-probe + +# --- Standalone mode --- +# Comma-separated list of host:port to monitor (standalone only) +KK_PROBE_ENDPOINTS=example.com:443,api.example.com:443 + +# --- Connected mode --- +# Your KrakenKey user API key (kk_ prefix, required for connected mode) +# KK_PROBE_API_KEY=kk_your_api_key_here + +# Probe ID from KrakenKey dashboard (required for connected mode) +# KK_PROBE_ID=your-probe-uuid + +# API base URL (default: https://api.krakenkey.io) +# KK_PROBE_API_URL=https://api.krakenkey.io + +# --- Shared options --- + +# Scan interval (default: 60m, min: 1m, max: 24h) +KK_PROBE_INTERVAL=30m + +# Geographic region label (optional, for organizing probes) +# KK_PROBE_REGION=us-east-1 + +# Host port for health/metrics endpoint (default: 8080) +# KK_PROBE_HOST_PORT=8080 + +# Log level: debug, info, warn, error (default: info) +KK_PROBE_LOG_LEVEL=info + +# Log format: json or text (default: json) +KK_PROBE_LOG_FORMAT=json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1391b08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Binary +probe +*.exe + +# Environment (contains API keys) +.env + +# State file +state.json + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/Dockerfile b/Dockerfile index 09ec7cf..bd4fccd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,12 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /probe ./cmd/probe +RUN mkdir -p /var/lib/krakenkey-probe && chown 65532:65532 /var/lib/krakenkey-probe FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /probe /probe COPY probe.example.yaml /etc/krakenkey-probe/probe.yaml +COPY --from=build --chown=nonroot:nonroot /var/lib/krakenkey-probe /var/lib/krakenkey-probe VOLUME ["/var/lib/krakenkey-probe"] EXPOSE 8080 ENTRYPOINT ["/probe"] diff --git a/cmd/probe/main.go b/cmd/probe/main.go index c8665bc..7c9a1a6 100644 --- a/cmd/probe/main.go +++ b/cmd/probe/main.go @@ -58,25 +58,29 @@ func main() { "interval", cfg.Probe.Interval.String(), ) - rep := reporter.New(cfg.API.URL, cfg.API.Key, version, runtime.GOOS, runtime.GOARCH) - - // Register with the API ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - regInfo := reporter.ProbeInfo{ - ProbeID: probeID, - Name: cfg.Probe.Name, - Version: version, - Mode: cfg.Probe.Mode, - Region: cfg.Probe.Region, - OS: runtime.GOOS, - Arch: runtime.GOARCH, - } - if err := rep.Register(ctx, regInfo); err != nil { - logger.Warn("failed to register with API (will retry on next cycle)", "error", err) + var rep *reporter.Reporter + if cfg.Probe.Mode != "standalone" { + rep = reporter.New(cfg.API.URL, cfg.API.Key, version, runtime.GOOS, runtime.GOARCH) + + regInfo := reporter.ProbeInfo{ + ProbeID: probeID, + Name: cfg.Probe.Name, + Version: version, + Mode: cfg.Probe.Mode, + Region: cfg.Probe.Region, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + if err := rep.Register(ctx, regInfo); err != nil { + logger.Warn("failed to register with API (will retry on next cycle)", "error", err) + } else { + logger.Info("registered with API") + } } else { - logger.Info("registered with API") + logger.Info("standalone mode: no API communication") } // Start health server diff --git a/docker-compose.yaml b/docker-compose.yaml index 8f65604..2755f7e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,17 +1,27 @@ services: + # Standalone mode: endpoints defined in .env, results reported to API + # Connected mode: endpoints fetched from API, authenticated with user API key + # + # Usage: + # cp .env.example .env # edit with your values + # docker compose up -d krakenkey-probe: + build: . image: ghcr.io/krakenkey/probe:latest container_name: krakenkey-probe restart: unless-stopped - environment: - KK_PROBE_API_KEY: "kk_your_api_key_here" - KK_PROBE_NAME: "my-probe" - KK_PROBE_ENDPOINTS: "example.com:443,api.example.com:443,internal.corp:8443" - KK_PROBE_INTERVAL: "30m" + env_file: + - .env volumes: - probe_state:/var/lib/krakenkey-probe ports: - - "8080:8080" + - "${KK_PROBE_HOST_PORT:-8080}:8080" + healthcheck: + test: ["CMD", "/probe", "--healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s volumes: probe_state: diff --git a/internal/config/config.go b/internal/config/config.go index 2dc3ca4..31555f1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,7 +83,7 @@ func defaults() *Config { URL: "https://api.krakenkey.io", }, Probe: ProbeConfig{ - Mode: "self-hosted", + Mode: "standalone", RawInterval: "60m", RawTimeout: "10s", StateFile: "/var/lib/krakenkey-probe/state.json", @@ -186,17 +186,23 @@ func parseDurations(cfg *Config) error { } func validate(cfg *Config) error { - if cfg.API.Key == "" { - return fmt.Errorf("api.key is required") - } - switch cfg.Probe.Mode { - case "self-hosted": - if !strings.HasPrefix(cfg.API.Key, "kk_") { - return fmt.Errorf("api.key must start with 'kk_' for self-hosted mode") - } + case "standalone": if len(cfg.Endpoints) == 0 { - return fmt.Errorf("at least one endpoint is required for self-hosted mode") + return fmt.Errorf("at least one endpoint is required for standalone mode") + } + if cfg.Probe.Interval < time.Minute { + return fmt.Errorf("interval must be at least 1m, got %s", cfg.Probe.Interval) + } + if cfg.Probe.Interval > 24*time.Hour { + return fmt.Errorf("interval must be at most 24h, got %s", cfg.Probe.Interval) + } + case "connected": + if cfg.API.Key == "" { + return fmt.Errorf("api.key is required for connected mode") + } + if !strings.HasPrefix(cfg.API.Key, "kk_") || strings.HasPrefix(cfg.API.Key, "kk_svc_") { + return fmt.Errorf("api.key must be a user API key (kk_ prefix, not kk_svc_) for connected mode") } if cfg.Probe.Interval < time.Minute { return fmt.Errorf("interval must be at least 1m, got %s", cfg.Probe.Interval) @@ -205,6 +211,9 @@ func validate(cfg *Config) error { return fmt.Errorf("interval must be at most 24h, got %s", cfg.Probe.Interval) } case "hosted": + if cfg.API.Key == "" { + return fmt.Errorf("api.key is required for hosted mode") + } if cfg.Probe.Region == "" { return fmt.Errorf("probe.region is required for hosted mode") } @@ -212,7 +221,7 @@ func validate(cfg *Config) error { return fmt.Errorf("probe.id is required for hosted mode") } default: - return fmt.Errorf("probe.mode must be 'self-hosted' or 'hosted', got %q", cfg.Probe.Mode) + return fmt.Errorf("probe.mode must be 'standalone', 'connected', or 'hosted', got %q", cfg.Probe.Mode) } for i, ep := range cfg.Endpoints { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 294608b..543d760 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,7 +17,7 @@ api: probe: name: "test-probe" - mode: "self-hosted" + mode: "standalone" interval: "30m" timeout: "5s" state_file: "/tmp/test-state.json" @@ -153,13 +153,11 @@ func TestEnvOnlyNoFile(t *testing.T) { } } -func TestValidationSelfHostedMissingKey(t *testing.T) { +func TestValidationStandaloneNoApiKeyOK(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "probe.yaml") yaml := ` -api: - key: "" endpoints: - host: "example.com" port: 443 @@ -168,22 +166,21 @@ endpoints: t.Fatal(err) } - _, err := Load(cfgPath) - if err == nil { - t.Fatal("expected error for missing API key") + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("expected standalone to work without API key, got: %v", err) + } + if cfg.Probe.Mode != "standalone" { + t.Errorf("Probe.Mode = %q, want %q", cfg.Probe.Mode, "standalone") } } -func TestValidationSelfHostedBadKeyPrefix(t *testing.T) { +func TestValidationStandaloneNoEndpoints(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "probe.yaml") yaml := ` -api: - key: "bad_prefix" -endpoints: - - host: "example.com" - port: 443 +endpoints: [] ` if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { t.Fatal(err) @@ -191,18 +188,20 @@ endpoints: _, err := Load(cfgPath) if err == nil { - t.Fatal("expected error for bad key prefix") + t.Fatal("expected error for no endpoints") } } -func TestValidationSelfHostedNoEndpoints(t *testing.T) { +func TestValidationConnectedMissingKey(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "probe.yaml") yaml := ` api: - key: "kk_testkey" -endpoints: [] + key: "" +probe: + mode: "connected" + id: "some-uuid" ` if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { t.Fatal(err) @@ -210,7 +209,7 @@ endpoints: [] _, err := Load(cfgPath) if err == nil { - t.Fatal("expected error for no endpoints") + t.Fatal("expected error for connected mode missing API key") } } @@ -304,6 +303,75 @@ endpoints: } } +func TestValidationConnectedValid(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "probe.yaml") + + yaml := ` +api: + key: "kk_userkey123" +probe: + mode: "connected" + id: "connected-uuid" + interval: "30m" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Probe.Mode != "connected" { + t.Errorf("Probe.Mode = %q, want %q", cfg.Probe.Mode, "connected") + } +} + +func TestValidationConnectedAutoGeneratesID(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "probe.yaml") + + yaml := ` +api: + key: "kk_userkey123" +probe: + mode: "connected" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("expected connected mode to work without probe ID, got: %v", err) + } + if cfg.Probe.Mode != "connected" { + t.Errorf("Probe.Mode = %q, want %q", cfg.Probe.Mode, "connected") + } +} + +func TestValidationConnectedBadKeyPrefix(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "probe.yaml") + + yaml := ` +api: + key: "kk_svc_servicekey" +probe: + mode: "connected" + id: "connected-uuid" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Load(cfgPath) + if err == nil { + t.Fatal("expected error for connected mode with service key prefix") + } +} + func TestValidationHostedMissingRegion(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "probe.yaml") diff --git a/internal/health/health_test.go b/internal/health/health_test.go index 359543d..63965fa 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -10,7 +10,7 @@ import ( ) func TestHealthzReturnsStatus(t *testing.T) { - s := New(0, "0.1.0", "test-uuid", "self-hosted", "us-east-1") + s := New(0, "0.1.0", "test-uuid", "standalone", "us-east-1") req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() @@ -35,8 +35,8 @@ func TestHealthzReturnsStatus(t *testing.T) { if status.ProbeID != "test-uuid" { t.Errorf("probeId = %q, want %q", status.ProbeID, "test-uuid") } - if status.Mode != "self-hosted" { - t.Errorf("mode = %q, want %q", status.Mode, "self-hosted") + if status.Mode != "standalone" { + t.Errorf("mode = %q, want %q", status.Mode, "standalone") } if status.Region != "us-east-1" { t.Errorf("region = %q, want %q", status.Region, "us-east-1") @@ -44,7 +44,7 @@ func TestHealthzReturnsStatus(t *testing.T) { } func TestHealthzWithScanTimes(t *testing.T) { - s := New(0, "0.1.0", "test-uuid", "self-hosted", "") + s := New(0, "0.1.0", "test-uuid", "standalone", "") last := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) next := time.Date(2026, 3, 15, 13, 0, 0, 0, time.UTC) @@ -69,7 +69,7 @@ func TestHealthzWithScanTimes(t *testing.T) { } func TestReadyzNotReady(t *testing.T) { - s := New(0, "0.1.0", "test-uuid", "self-hosted", "") + s := New(0, "0.1.0", "test-uuid", "standalone", "") req := httptest.NewRequest(http.MethodGet, "/readyz", nil) rec := httptest.NewRecorder() @@ -90,7 +90,7 @@ func TestReadyzNotReady(t *testing.T) { } func TestReadyzAfterReady(t *testing.T) { - s := New(0, "0.1.0", "test-uuid", "self-hosted", "") + s := New(0, "0.1.0", "test-uuid", "standalone", "") s.SetReady() req := httptest.NewRequest(http.MethodGet, "/readyz", nil) @@ -112,7 +112,7 @@ func TestReadyzAfterReady(t *testing.T) { } func TestServerStartAndShutdown(t *testing.T) { - s := New(0, "0.1.0", "test-uuid", "self-hosted", "") + s := New(0, "0.1.0", "test-uuid", "standalone", "") // Use port 0 to get a random available port s.server.Addr = ":0" @@ -138,7 +138,7 @@ func TestServerStartAndShutdown(t *testing.T) { } func TestContentTypeJSON(t *testing.T) { - s := New(0, "0.1.0", "test-uuid", "self-hosted", "") + s := New(0, "0.1.0", "test-uuid", "standalone", "") tests := []struct { name string diff --git a/internal/reporter/reporter_test.go b/internal/reporter/reporter_test.go index 16a648f..8b85a18 100644 --- a/internal/reporter/reporter_test.go +++ b/internal/reporter/reporter_test.go @@ -43,7 +43,7 @@ func TestRegisterSuccess(t *testing.T) { ProbeID: "test-uuid", Name: "test-probe", Version: "0.1.0", - Mode: "self-hosted", + Mode: "standalone", Region: "us-east-1", OS: "linux", Arch: "amd64", @@ -78,7 +78,7 @@ func TestReportSuccess(t *testing.T) { report := ScanReport{ ProbeID: "test-uuid", - Mode: "self-hosted", + Mode: "standalone", Region: "us-east-1", Timestamp: time.Now().UTC(), Results: []scanner.ScanResult{ diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 0cbe042..d5c7cdd 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -31,6 +31,8 @@ func New(cfg *config.Config, probeID string, rep *reporter.Reporter, h *health.S } } +const configPollInterval = 60 * time.Second + func (s *Scheduler) Run(ctx context.Context) { s.runCycle(ctx) @@ -43,20 +45,94 @@ func (s *Scheduler) Run(ctx context.Context) { interval = 60 * time.Minute } - ticker := time.NewTicker(interval) - defer ticker.Stop() + scanTicker := time.NewTicker(interval) + defer scanTicker.Stop() + + // For connected/hosted modes, poll config every 60s and trigger + // an immediate scan when the endpoint list changes. + if s.usesRemoteConfig() { + configTicker := time.NewTicker(configPollInterval) + defer configTicker.Stop() + + for { + select { + case <-ctx.Done(): + s.logger.Info("scheduler stopping") + return + case <-scanTicker.C: + s.runCycle(ctx) + case <-configTicker.C: + if s.checkConfigChanged(ctx) { + s.logger.Info("endpoint config changed, triggering immediate scan") + s.runCycle(ctx) + scanTicker.Reset(interval) + } + } + } + } + // Standalone: just scan on interval for { select { case <-ctx.Done(): s.logger.Info("scheduler stopping") return - case <-ticker.C: + case <-scanTicker.C: s.runCycle(ctx) } } } +func (s *Scheduler) usesRemoteConfig() bool { + return s.cfg.Probe.Mode == "connected" || s.cfg.Probe.Mode == "hosted" +} + +// checkConfigChanged fetches the latest endpoint list and returns true if it +// differs from the cached list. +func (s *Scheduler) checkConfigChanged(ctx context.Context) bool { + if s.reporter == nil { + return false + } + + cfg, err := s.reporter.FetchHostedConfig(ctx, s.probeID) + if err != nil { + s.logger.Debug("config poll failed", "error", err) + return false + } + + newEndpoints := make([]scanner.EndpointResult, len(cfg.Endpoints)) + for i, ep := range cfg.Endpoints { + sni := ep.SNI + if sni == "" { + sni = ep.Host + } + newEndpoints[i] = scanner.EndpointResult{ + Host: ep.Host, + Port: ep.Port, + SNI: sni, + } + } + + if endpointsEqual(cachedRemoteEndpoints, newEndpoints) { + return false + } + + cachedRemoteEndpoints = newEndpoints + return true +} + +func endpointsEqual(a, b []scanner.EndpointResult) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Host != b[i].Host || a[i].Port != b[i].Port || a[i].SNI != b[i].SNI { + return false + } + } + return true +} + func (s *Scheduler) runCycle(ctx context.Context) { now := time.Now().UTC() s.logger.Info("scan cycle starting", "time", now.Format(time.RFC3339)) @@ -94,18 +170,22 @@ func (s *Scheduler) runCycle(ctx context.Context) { } } - report := reporter.ScanReport{ - ProbeID: s.probeID, - Mode: s.cfg.Probe.Mode, - Region: s.cfg.Probe.Region, - Timestamp: now, - Results: results, - } + if s.cfg.Probe.Mode != "standalone" && s.reporter != nil { + report := reporter.ScanReport{ + ProbeID: s.probeID, + Mode: s.cfg.Probe.Mode, + Region: s.cfg.Probe.Region, + Timestamp: now, + Results: results, + } - if err := s.reporter.Report(ctx, report); err != nil { - s.logger.Error("failed to send report", "error", err) + if err := s.reporter.Report(ctx, report); err != nil { + s.logger.Error("failed to send report", "error", err) + } else { + s.logger.Info("report sent", "endpoints", len(results)) + } } else { - s.logger.Info("report sent", "endpoints", len(results)) + s.logger.Info("scan cycle complete (standalone, no API report)", "endpoints", len(results)) } interval := s.cfg.Probe.Interval @@ -118,29 +198,30 @@ func (s *Scheduler) runCycle(ctx context.Context) { } func (s *Scheduler) resolveEndpoints(ctx context.Context) []scanner.EndpointResult { - if s.cfg.Probe.Mode == "hosted" { - return s.fetchHostedEndpoints(ctx) - } - - endpoints := make([]scanner.EndpointResult, len(s.cfg.Endpoints)) - for i, ep := range s.cfg.Endpoints { - endpoints[i] = scanner.EndpointFromConfig(ep) + switch s.cfg.Probe.Mode { + case "hosted", "connected": + return s.fetchRemoteEndpoints(ctx) + default: + endpoints := make([]scanner.EndpointResult, len(s.cfg.Endpoints)) + for i, ep := range s.cfg.Endpoints { + endpoints[i] = scanner.EndpointFromConfig(ep) + } + return endpoints } - return endpoints } -var cachedHostedEndpoints []scanner.EndpointResult +var cachedRemoteEndpoints []scanner.EndpointResult -func (s *Scheduler) fetchHostedEndpoints(ctx context.Context) []scanner.EndpointResult { +func (s *Scheduler) fetchRemoteEndpoints(ctx context.Context) []scanner.EndpointResult { cfg, err := s.reporter.FetchHostedConfig(ctx, s.probeID) if err != nil { - s.logger.Error("failed to fetch hosted config, using cached endpoints", "error", err) - return cachedHostedEndpoints + s.logger.Error("failed to fetch config from API, using cached endpoints", "error", err, "mode", s.cfg.Probe.Mode) + return cachedRemoteEndpoints } if len(cfg.Endpoints) == 0 { - s.logger.Warn("hosted config returned empty endpoint list") - cachedHostedEndpoints = nil + s.logger.Warn("API config returned empty endpoint list", "mode", s.cfg.Probe.Mode) + cachedRemoteEndpoints = nil return nil } @@ -157,6 +238,6 @@ func (s *Scheduler) fetchHostedEndpoints(ctx context.Context) []scanner.Endpoint } } - cachedHostedEndpoints = endpoints + cachedRemoteEndpoints = endpoints return endpoints } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 37db9e0..4940477 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -16,40 +16,24 @@ import ( "github.com/krakenkey/probe/internal/scanner" ) -func TestRunCycleSelfHosted(t *testing.T) { +func TestRunCycleStandalone(t *testing.T) { // Reset cached endpoints - cachedHostedEndpoints = nil + cachedRemoteEndpoints = nil var reportReceived atomic.Bool - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // This server also acts as the scan target (TLS endpoint) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - // API mock for report + // API mock — standalone should NOT call this apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/probes/register" { - w.WriteHeader(http.StatusOK) - return - } if r.URL.Path == "/probes/report" { - var report reporter.ScanReport - _ = json.NewDecoder(r.Body).Decode(&report) - if len(report.Results) > 0 { - reportReceived.Store(true) - } - w.WriteHeader(http.StatusOK) - return + reportReceived.Store(true) } - w.WriteHeader(http.StatusNotFound) + w.WriteHeader(http.StatusOK) })) defer apiSrv.Close() cfg := &config.Config{ Probe: config.ProbeConfig{ - Mode: "self-hosted", + Mode: "standalone", Interval: 1 * time.Hour, Timeout: 5 * time.Second, }, @@ -58,23 +42,21 @@ func TestRunCycleSelfHosted(t *testing.T) { }, } - rep := reporter.New(apiSrv.URL, "kk_test", "0.1.0", "linux", "amd64") - h := health.New(0, "0.1.0", "test-id", "self-hosted", "") + h := health.New(0, "0.1.0", "test-id", "standalone", "") logger := slog.Default() - s := New(cfg, "test-id", rep, h, logger, "0.1.0") + // No reporter for standalone + s := New(cfg, "test-id", nil, h, logger, "0.1.0") - // Run a single cycle s.runCycle(context.Background()) - if !reportReceived.Load() { - // Report may have failed endpoints but should still be sent - t.Log("report was sent (even if endpoints failed)") + if reportReceived.Load() { + t.Error("standalone mode should not send reports to API") } } func TestRunCycleHostedMode(t *testing.T) { - cachedHostedEndpoints = nil + cachedRemoteEndpoints = nil var configFetched atomic.Bool @@ -120,7 +102,7 @@ func TestRunCycleHostedMode(t *testing.T) { func TestRunCycleHostedCacheFallback(t *testing.T) { // Pre-populate cache - cachedHostedEndpoints = []scanner.EndpointResult{ + cachedRemoteEndpoints = []scanner.EndpointResult{ {Host: "cached.example.com", Port: 443, SNI: "cached.example.com"}, } @@ -162,16 +144,11 @@ func TestRunCycleHostedCacheFallback(t *testing.T) { } func TestRunSetsReady(t *testing.T) { - cachedHostedEndpoints = nil - - apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer apiSrv.Close() + cachedRemoteEndpoints = nil cfg := &config.Config{ Probe: config.ProbeConfig{ - Mode: "self-hosted", + Mode: "standalone", Interval: 1 * time.Hour, Timeout: 1 * time.Second, }, @@ -180,11 +157,10 @@ func TestRunSetsReady(t *testing.T) { }, } - rep := reporter.New(apiSrv.URL, "kk_test", "0.1.0", "linux", "amd64") - h := health.New(0, "0.1.0", "test-id", "self-hosted", "") + h := health.New(0, "0.1.0", "test-id", "standalone", "") logger := slog.Default() - s := New(cfg, "test-id", rep, h, logger, "0.1.0") + s := New(cfg, "test-id", nil, h, logger, "0.1.0") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -216,10 +192,10 @@ func TestRunSetsReady(t *testing.T) { } func TestResolveEndpointsSelfHosted(t *testing.T) { - cachedHostedEndpoints = nil + cachedRemoteEndpoints = nil cfg := &config.Config{ - Probe: config.ProbeConfig{Mode: "self-hosted"}, + Probe: config.ProbeConfig{Mode: "standalone"}, Endpoints: []config.Endpoint{ {Host: "a.com", Port: 443}, {Host: "b.com", Port: 8443, SNI: "custom.b.com"}, @@ -241,16 +217,11 @@ func TestResolveEndpointsSelfHosted(t *testing.T) { } func TestSchedulerContextCancellation(t *testing.T) { - cachedHostedEndpoints = nil - - apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer apiSrv.Close() + cachedRemoteEndpoints = nil cfg := &config.Config{ Probe: config.ProbeConfig{ - Mode: "self-hosted", + Mode: "standalone", Interval: 24 * time.Hour, // long interval so it blocks on ticker Timeout: 1 * time.Second, }, @@ -259,10 +230,9 @@ func TestSchedulerContextCancellation(t *testing.T) { }, } - rep := reporter.New(apiSrv.URL, "kk_test", "0.1.0", "linux", "amd64") logger := slog.Default() - s := New(cfg, "test-id", rep, nil, logger, "0.1.0") + s := New(cfg, "test-id", nil, nil, logger, "0.1.0") ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) diff --git a/internal/state/state.go b/internal/state/state.go index 3a96ba3..402d055 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -16,8 +16,8 @@ type State struct { } // LoadOrCreate reads the probe ID from the state file. If the file does not -// exist and the mode is self-hosted, it generates a new UUID-style ID and -// persists it. For hosted mode the ID must already be set in config. +// exist and the mode is standalone, it generates a new UUID-style ID and +// persists it. For hosted/connected mode the ID must already be set in config. func LoadOrCreate(cfg *config.Config) (string, error) { if cfg.Probe.Mode == "hosted" { return cfg.Probe.ID, nil diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 513bc7b..c6e4231 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -15,7 +15,7 @@ func TestLoadOrCreateGeneratesID(t *testing.T) { cfg := &config.Config{ Probe: config.ProbeConfig{ - Mode: "self-hosted", + Mode: "standalone", StateFile: stateFile, }, } @@ -63,7 +63,7 @@ func TestLoadOrCreateReusesExistingID(t *testing.T) { cfg := &config.Config{ Probe: config.ProbeConfig{ - Mode: "self-hosted", + Mode: "standalone", StateFile: stateFile, }, } @@ -82,7 +82,7 @@ func TestLoadOrCreateUsesConfigID(t *testing.T) { cfg := &config.Config{ Probe: config.ProbeConfig{ ID: "config-id-5678", - Mode: "self-hosted", + Mode: "standalone", }, } diff --git a/probe.example.yaml b/probe.example.yaml index 019e5a3..4d5f6e7 100644 --- a/probe.example.yaml +++ b/probe.example.yaml @@ -6,7 +6,8 @@ api: # KrakenKey API base URL url: "https://api.krakenkey.io" # KK_PROBE_API_URL - # Your KrakenKey API key (required). Generate one at https://app.krakenkey.io/api-keys + # Your KrakenKey API key. Required for connected/hosted modes, not needed for standalone. + # Generate one at https://app.krakenkey.io/api-keys key: "" # KK_PROBE_API_KEY probe: @@ -16,11 +17,13 @@ probe: # Human-friendly name for this probe (shown in dashboard) name: "my-probe" # KK_PROBE_NAME - # Deployment mode: "self-hosted" (default) or "hosted" - # Self-hosted: you configure endpoints below. Hosted: endpoints fetched from API. - mode: "self-hosted" # KK_PROBE_MODE + # Deployment mode: "standalone", "connected", or "hosted" + # standalone: fully local, no API communication, results logged to console + # connected: endpoints fetched from API, authenticated with your user API key (kk_) + # hosted: managed by KrakenKey infrastructure, authenticated with service key + mode: "standalone" # KK_PROBE_MODE - # Geographic region label (e.g., "us-east-1"). Required for hosted mode, optional for self-hosted. + # Geographic region label (e.g., "us-east-1"). Required for hosted mode, optional otherwise. region: "" # KK_PROBE_REGION # How often to scan endpoints (Go duration). Min: 1m, Max: 24h. Default: 60m. @@ -32,7 +35,7 @@ probe: # Path to persist probe ID across restarts state_file: "/var/lib/krakenkey-probe/state.json" # KK_PROBE_STATE_FILE -# Endpoints to monitor (self-hosted mode only). +# Endpoints to monitor (standalone mode only). # For env var override: KK_PROBE_ENDPOINTS="host1:port1,host2:port2" endpoints: - host: "example.com"