diff --git a/README.md b/README.md index 104d301..f52a2a4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - **Cross-Platform:** Built to be cross-platform with priority for Linux and macOS. - **Docker Support:** Can be run in a Docker container. - **Manual Whitelisting:** Manually trigger a whitelist request at any time. +- **Structured journald events:** Emits machine-readable journald events alongside human logs for desktop integrations. ## How It Works @@ -18,9 +19,9 @@ This is the default and recommended mode of operation. -1. The `knocker-cli` service starts and, at a regular interval (configured by the `interval` setting), sends a "knock" request to your API server. -2. The service **does not** check its own IP address. -3. Your API server receives the "knock" request, inspects the source IP address of the request, and updates its whitelist accordingly. +1. The `knocker-cli` service starts and, at a regular interval (configured by the `interval` setting), sends a "knock" request to your API server. +2. The service **does not** check its own IP address. +3. Your API server receives the "knock" request, inspects the source IP address of the request, and updates its whitelist accordingly. In this mode, `knocker-cli` acts as a simple, periodic "pinger" to keep your whitelist entry fresh. @@ -28,11 +29,11 @@ In this mode, `knocker-cli` acts as a simple, periodic "pinger" to keep your whi This mode is for more advanced use cases where you want the client to be responsible for detecting IP changes. -1. To enable this mode, you must provide an `ip_check_url` in your configuration. This URL should point to a service that returns the client's public IP in plain text (e.g., `https://ifconfig.me`). -2. The `knocker-cli` service starts and fetches its public IP from the `ip_check_url`. It stores this IP in memory. -3. At each interval, it fetches the IP again. -4. It compares the new IP with the one stored in memory. -5. If the IP address has changed, and only if it has changed, the service will send a "knock" request to your API server to whitelist the new address. +1. To enable this mode, you must provide an `ip_check_url` in your configuration. This URL should point to a service that returns the client's public IP in plain text (e.g., `https://ifconfig.me`). +2. The `knocker-cli` service starts and fetches its public IP from the `ip_check_url`. It stores this IP in memory. +3. At each interval, it fetches the IP again. +4. It compares the new IP with the one stored in memory. +5. If the IP address has changed, and only if it has changed, the service will send a "knock" request to your API server to whitelist the new address. ## Installation @@ -85,6 +86,22 @@ When running as the packaged systemd user service, these variables can be placed Setting a non-zero `ttl` automatically shortens the knock interval so that a knock is issued when roughly 90% of the TTL has elapsed (leaving a 10% buffer before expiry) while never exceeding the configured interval. +## Structured journald events + +When the service runs under systemd (for example as `knocker.service`), every operational log is mirrored to journald with a human-friendly `MESSAGE` and a stable set of `KNOCKER_*` fields. These events let desktop integrations such as GNOME shell extensions consume Knocker state without polling a separate API. + +- **Stream the events:** `journalctl --user -u knocker.service -o json -f | jq 'select(.KNOCKER_EVENT != null)'` to follow only structured entries, or pin to a specific type with `KNOCKER_EVENT=StatusSnapshot` as needed (`journalctl` only supports `FIELD=value` comparisons per its manual). +- **Schema version:** All entries include `KNOCKER_SCHEMA_VERSION=1` for forward compatibility. +- **Event types:** + - `ServiceState` — lifecycle notifications (`started`, `stopping`, `stopped`) with optional `KNOCKER_VERSION`. + - `StatusSnapshot` — current whitelist, TTL, and next scheduled knock. + - `WhitelistApplied` / `WhitelistExpired` — whitelist changes with expiry metadata. + - `NextKnockUpdated` — upcoming knock timestamp (or `0` when cleared). + - `KnockTriggered` — manual (`cli`) and scheduled (`schedule`) knocks with success/failure result. + - `Error` — surfaced issues that should be shown in the UI. + +Manual invocations of `knocker knock` produce the same `KnockTriggered` and `WhitelistApplied` events so external consumers stay in sync even when the background service is idle. + ## Usage ### Run as a foreground process diff --git a/cmd/knocker/knock.go b/cmd/knocker/knock.go index fc0d0ea..7727509 100644 --- a/cmd/knocker/knock.go +++ b/cmd/knocker/knock.go @@ -2,8 +2,12 @@ package main import ( "fmt" + "strconv" + "time" "github.com/FarisZR/knocker-cli/internal/api" + "github.com/FarisZR/knocker-cli/internal/journald" + internalService "github.com/FarisZR/knocker-cli/internal/service" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -26,9 +30,12 @@ var knockCmd = &cobra.Command{ logger.Println("Manually knocking to whitelist IP...") knockResponse, err := client.Knock("", ttl) if err != nil { + emitManualKnockFailure(err) logger.Fatalf("Failed to knock: %v", err) } + emitManualKnockSuccess(knockResponse) + logger.Printf("Successfully knocked. Whitelisted entry: %s (ttl: %d seconds)", knockResponse.WhitelistedEntry, knockResponse.ExpiresInSeconds) fmt.Printf("Successfully knocked and whitelisted IP. TTL: %d seconds\n", knockResponse.ExpiresInSeconds) }, @@ -37,3 +44,62 @@ var knockCmd = &cobra.Command{ func init() { rootCmd.AddCommand(knockCmd) } + +func emitManualKnockFailure(err error) { + msg := fmt.Sprintf("Manual knock failed: %v", err) + _ = journald.Emit(internalService.EventKnockTriggered, msg, journald.PriErr, journald.Fields{ + "KNOCKER_TRIGGER_SOURCE": internalService.TriggerSourceCLI, + "KNOCKER_RESULT": internalService.ResultFailure, + }) + _ = journald.Emit(internalService.EventError, msg, journald.PriErr, journald.Fields{ + "KNOCKER_ERROR_CODE": internalService.ErrorCodeKnockFailed, + "KNOCKER_ERROR_MSG": msg, + "KNOCKER_CONTEXT": "cli", + }) +} + +func emitManualKnockSuccess(knockResponse *api.KnockResponse) { + whitelistIP := "" + ttlSeconds := 0 + expiresUnix := int64(0) + if knockResponse != nil { + whitelistIP = knockResponse.WhitelistedEntry + ttlSeconds = knockResponse.ExpiresInSeconds + expiresUnix = knockResponse.ExpiresAt + } + + knockFields := journald.Fields{ + "KNOCKER_TRIGGER_SOURCE": internalService.TriggerSourceCLI, + "KNOCKER_RESULT": internalService.ResultSuccess, + } + if whitelistIP != "" { + knockFields["KNOCKER_WHITELIST_IP"] = whitelistIP + } + _ = journald.Emit(internalService.EventKnockTriggered, "Manual knock succeeded", journald.PriInfo, knockFields) + + if knockResponse == nil { + return + } + + whitelistFields := journald.Fields{ + "KNOCKER_SOURCE": internalService.TriggerSourceCLI, + } + if whitelistIP != "" { + whitelistFields["KNOCKER_WHITELIST_IP"] = whitelistIP + } + if ttlSeconds > 0 { + whitelistFields["KNOCKER_TTL_SEC"] = strconv.Itoa(ttlSeconds) + } + if expiresUnix > 0 { + whitelistFields["KNOCKER_EXPIRES_UNIX"] = strconv.FormatInt(expiresUnix, 10) + } + + message := "Whitelist updated" + if whitelistIP != "" && ttlSeconds > 0 && expiresUnix > 0 { + message = fmt.Sprintf("Whitelisted %s for %ds (expires at %s)", whitelistIP, ttlSeconds, time.Unix(expiresUnix, 0).UTC().Format(time.RFC3339)) + } else if whitelistIP != "" { + message = fmt.Sprintf("Whitelisted %s", whitelistIP) + } + + _ = journald.Emit(internalService.EventWhitelistApplied, message, journald.PriInfo, whitelistFields) +} diff --git a/cmd/knocker/program.go b/cmd/knocker/program.go index 886cd61..7453e1c 100644 --- a/cmd/knocker/program.go +++ b/cmd/knocker/program.go @@ -1,6 +1,7 @@ package main import ( + "sync" "time" "github.com/FarisZR/knocker-cli/internal/api" @@ -11,16 +12,22 @@ import ( ) type program struct { - quit chan struct{} + quit chan struct{} + mu sync.RWMutex + service *internalService.Service } func (p *program) Start(s service.Service) error { logger.Println("Starting Knocker service...") + p.mu.Lock() p.quit = make(chan struct{}) - go p.run() + quit := p.quit + p.mu.Unlock() + + go p.run(quit) return nil } -func (p *program) run() { +func (p *program) run(quit <-chan struct{}) { apiClient := api.NewClient(viper.GetString("api_url"), viper.GetString("api_key")) ipGetter := util.NewIPGetter() configuredInterval := time.Duration(viper.GetInt("interval")) * time.Minute @@ -43,12 +50,34 @@ func (p *program) run() { } logger.Println("API health check successful.") - knockerService := internalService.NewService(apiClient, ipGetter, effectiveInterval, ipCheckURL, ttl, logger) + knockerService := internalService.NewService(apiClient, ipGetter, effectiveInterval, ipCheckURL, ttl, version, logger) + + p.mu.Lock() + p.service = knockerService + p.mu.Unlock() + defer func() { + p.mu.Lock() + p.service = nil + p.mu.Unlock() + }() - knockerService.Run(p.quit) + knockerService.Run(quit) } func (p *program) Stop(s service.Service) error { logger.Println("Stopping Knocker service...") - close(p.quit) + p.mu.RLock() + svc := p.service + p.mu.RUnlock() + if svc != nil { + svc.NotifyStopping() + svc.Stop() + } + + p.mu.Lock() + if p.quit != nil { + close(p.quit) + p.quit = nil + } + p.mu.Unlock() return nil } diff --git a/docs/architecture.md b/docs/architecture.md index 9f87145..fb0d588 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,6 +60,20 @@ The `internal/util` package contains a utility for fetching the public IP addres - **GoReleaser**: The project uses GoReleaser to automate the build and release process. The `.goreleaser.yml` file defines how to build binaries for different platforms, create archives, and generate release notes. - **Docker**: A multi-stage `Dockerfile` is provided to create a minimal, containerized version of the application for easy deployment. +### 8. Structured Journald Events + +Linux deployments run the service as a systemd user unit. To support UI integrations (e.g. a GNOME Shell extension) the runtime mirrors critical state changes to journald with structured metadata. The `internal/journald` package wraps `github.com/coreos/go-systemd/v22/journal` behind a small abstraction so non-Linux builds compile with a stub. + +- Every structured entry carries both the human-readable message and a machine contract based on `KNOCKER_*` fields. The schema version is frozen at `KNOCKER_SCHEMA_VERSION=1`. +- The core service emits: + - `ServiceState` when entering `started`, `stopping`, or `stopped` transitions (including `KNOCKER_VERSION`). + - `StatusSnapshot` whenever material state changes (whitelist, TTL, next knock timestamp) so consumers can seed their UI. + - `WhitelistApplied`, `WhitelistExpired`, `NextKnockUpdated`, and `KnockTriggered` as the whitelist lifecycle evolves. + - `Error` whenever a problem (IP lookup, health check, knock) should surface in the UI, tagged with `KNOCKER_ERROR_CODE`. +- Manual invocations of `knocker knock` reuse the same contract, emitting `KnockTriggered` and `WhitelistApplied` events from the CLI path to keep consumers in sync even if the background service is idle. + +Consumers can tail these events with `journalctl --user -u knocker.service KNOCKER_EVENT= -o json` and update their state using the accompanying structured fields. + ## How It Works: IP Change Detection `knocker-cli` operates in two distinct modes for handling IP changes: diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..541d140 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,118 @@ +# Knocker Structured Logging + +The Knocker service emits journald entries with a human-friendly `MESSAGE` and a consistent set of `KNOCKER_*` fields for machine consumption. This document describes the event contract so tooling (for example, a GNOME Shell extension) can parse the stream reliably. + +## General Contract + +- **Schema version:** All structured entries include `KNOCKER_SCHEMA_VERSION`. Current value: `"1"`. +- **Identifier:** Entries set `SYSLOG_IDENTIFIER=knocker` so they can be sliced from generic logs. +- **Event discriminator:** Every structured entry carries `KNOCKER_EVENT`, which determines the remaining fields. +- **Rendering:** Consume logs with `journalctl --user -u knocker.service -o json` (or `json-pretty`). Journald only supports explicit equality matches (`FIELD=value`) [per the manual](https://www.freedesktop.org/software/systemd/man/latest/journalctl.html), so to view every structured entry either: + - pipe to a filter such as `jq 'select(.KNOCKER_EVENT != null)'`, or + - specify an exact value, e.g. `journalctl --user -u knocker.service KNOCKER_EVENT=StatusSnapshot -o json`. + Every `KNOCKER_*` value is encoded as a string because journald stores field payloads as strings. + +Unless otherwise noted, fields may be absent when the corresponding value is unavailable. Consumers should treat missing fields as "unknown" rather than assuming an empty string. + +## Event Catalogue + +### `KNOCKER_EVENT=ServiceState` + +Announces lifecycle transitions for the service. + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_SERVICE_STATE` | enum | One of `"started"`, `"stopping"`, `"stopped"`, `"reloaded"` (reserved). | +| `KNOCKER_VERSION` | string (optional) | Knocker binary version, e.g. `"1.2.3"` or `"dev"`. | + +Initialisation emits `started`. A graceful shutdown sequence raises `stopping` followed by `stopped`. + +### `KNOCKER_EVENT=StatusSnapshot` + +Provides the current state snapshot. Emitted at startup and whenever the snapshot changes materially. + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_WHITELIST_IP` | string (optional) | Active whitelist IP (IPv4 or IPv6) if present. | +| `KNOCKER_WHITELIST_IPS_JSON` | JSON string (optional) | Reserved for future multi-IP responses (JSON array as a string). | +| `KNOCKER_EXPIRES_UNIX` | Unix timestamp (optional) | Expiry instant for the whitelist entry (seconds since epoch). | +| `KNOCKER_TTL_SEC` | integer string (optional) | TTL in seconds originally granted by the API. | +| `KNOCKER_NEXT_AT_UNIX` | Unix timestamp (optional) | Scheduled time for the next automatic knock. | +| `KNOCKER_PROFILE` | string (optional) | Reserved; profile name when multiple profiles are supported. | +| `KNOCKER_PORTS` | string (optional) | Comma-separated port list when known. | + +### `KNOCKER_EVENT=WhitelistApplied` + +Indicates the service (or CLI) applied a whitelist entry. + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_WHITELIST_IP` | string | Whitelisted IP. | +| `KNOCKER_TTL_SEC` | integer string (optional) | TTL granted for the whitelist. | +| `KNOCKER_EXPIRES_UNIX` | Unix timestamp (optional) | Expiry instant, when provided by the API. | +| `KNOCKER_SOURCE` | enum (optional) | `"schedule"`, `"cli"`, or other future source identifiers. | +| `KNOCKER_PROFILE` | string (optional) | Reserved profile identifier. | + +### `KNOCKER_EVENT=WhitelistExpired` + +Signals that the currently tracked whitelist has expired or been cleared. + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_WHITELIST_IP` | string (optional) | IP that expired. | +| `KNOCKER_EXPIRED_UNIX` | Unix timestamp (optional) | Time the entry expired. | + +### `KNOCKER_EVENT=NextKnockUpdated` + +Communicates a change to the scheduled next knock. + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_NEXT_AT_UNIX` | Unix timestamp | Seconds since epoch for the next knock. `"0"` indicates the schedule is cleared. | +| `KNOCKER_PROFILE` | string (optional) | Reserved profile identifier. | +| `KNOCKER_PORTS` | string (optional) | Comma-separated port list when known. | + +### `KNOCKER_EVENT=KnockTriggered` + +Emitted whenever a knock attempt is triggered (manual or automatic). + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_TRIGGER_SOURCE` | enum | `"schedule"`, `"cli"`, or `"external"` (reserved). | +| `KNOCKER_RESULT` | enum | `"success"` or `"failure"`. | +| `KNOCKER_WHITELIST_IP` | string (optional) | Whitelisted IP when the knock succeeds and returns one. | +| `KNOCKER_PROFILE` | string (optional) | Reserved profile identifier. | + +Clients should watch for a matching `WhitelistApplied` event after a `success` result to update TTL and expiry. + +### `KNOCKER_EVENT=Error` + +Represents an operational error that should be surfaced to the user. + +| Field | Type | Description | +| --- | --- | --- | +| `KNOCKER_ERROR_CODE` | enum | Machine-readable code (currently `"ip_lookup_failed"`, `"health_check_failed"`, `"knock_failed"`). | +| `KNOCKER_ERROR_MSG` | string | Human-readable context string. | +| `KNOCKER_CONTEXT` | string (optional) | Additional context (for example the IP or base URL involved). | + +## Example Entry + +```json +{ + "SYSLOG_IDENTIFIER": "knocker", + "MESSAGE": "Whitelisted 1.2.3.4 for 600s (expires at 2025-06-14T10:01:40Z)", + "PRIORITY": "6", + "KNOCKER_SCHEMA_VERSION": "1", + "KNOCKER_EVENT": "WhitelistApplied", + "KNOCKER_WHITELIST_IP": "1.2.3.4", + "KNOCKER_TTL_SEC": "600", + "KNOCKER_EXPIRES_UNIX": "1750202500", + "KNOCKER_SOURCE": "schedule" +} +``` + +## Parser Recommendations + +- Treat field absence as "unknown". New fields may appear over time—ignore what you do not recognise. +- When multiple events arrive quickly, process `StatusSnapshot` last; it represents the new steady state after individual updates. +- Use the schema version to gate behaviour when future, incompatible changes are introduced. diff --git a/go.mod b/go.mod index 2b854d2..e189045 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/FarisZR/knocker-cli go 1.24.6 require ( + github.com/coreos/go-systemd/v22 v22.5.0 github.com/kardianos/service v1.2.4 github.com/spf13/cobra v1.9.1 + github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 ) @@ -20,7 +22,6 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect diff --git a/go.sum b/go.sum index b9c7c65..6692a88 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -8,6 +10,7 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/internal/journald/journald.go b/internal/journald/journald.go new file mode 100644 index 0000000..ce102a1 --- /dev/null +++ b/internal/journald/journald.go @@ -0,0 +1,84 @@ +package journald + +import ( + "errors" + "sync/atomic" + "syscall" +) + +// Priority mirrors journald priorities so we can defer to the platform-specific +// implementation without importing go-systemd on every platform. +type Priority int + +// Supported syslog priorities. +const ( + PriEmerg Priority = 0 + PriAlert Priority = 1 + PriCrit Priority = 2 + PriErr Priority = 3 + PriWarn Priority = 4 + PriNotice Priority = 5 + PriInfo Priority = 6 + PriDebug Priority = 7 +) + +// Fields represents the additional structured metadata attached to a journald +// entry. +type Fields map[string]string + +const ( + // SchemaVersion captures the stable contract version for structured Knocker + // journald entries. + SchemaVersion = "1" + defaultIdentifier = "knocker" +) + +// Enabled reports whether structured journald logging is available on the +// current platform/runtime. +func Enabled() bool { + return enabled() +} + +var journaldDisabled atomic.Bool + +// Emit writes a journald entry that includes both a friendly human-readable +// message and structured fields that automation (like the GNOME extension) can +// consume. When journald is unavailable, Emit becomes a no-op. +func Emit(eventType, message string, priority Priority, fields Fields) error { + if journaldDisabled.Load() { + return nil + } + + payload := make(Fields, len(fields)+3) + for k, v := range fields { + payload[k] = v + } + + if eventType != "" { + payload["KNOCKER_EVENT"] = eventType + } + if _, exists := payload["SYSLOG_IDENTIFIER"]; !exists { + payload["SYSLOG_IDENTIFIER"] = defaultIdentifier + } + if _, exists := payload["KNOCKER_SCHEMA_VERSION"]; !exists { + payload["KNOCKER_SCHEMA_VERSION"] = SchemaVersion + } + + if err := emit(message, priority, payload); err != nil { + if disableOn(err) { + journaldDisabled.Store(true) + return nil + } + return err + } + + return nil +} + +func disableOn(err error) bool { + return errors.Is(err, syscall.ENOENT) || + errors.Is(err, syscall.ENOTDIR) || + errors.Is(err, syscall.ENOTCONN) || + errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, syscall.EPERM) +} diff --git a/internal/journald/journald_linux.go b/internal/journald/journald_linux.go new file mode 100644 index 0000000..e78414e --- /dev/null +++ b/internal/journald/journald_linux.go @@ -0,0 +1,20 @@ +//go:build linux + +package journald + +import ( + "github.com/coreos/go-systemd/v22/journal" +) + +func enabled() bool { + return journal.Enabled() +} + +func emit(message string, priority Priority, fields Fields) error { + payload := make(map[string]string, len(fields)) + for k, v := range fields { + payload[k] = v + } + + return journal.Send(message, journal.Priority(priority), payload) +} diff --git a/internal/journald/journald_stub.go b/internal/journald/journald_stub.go new file mode 100644 index 0000000..18f5bd1 --- /dev/null +++ b/internal/journald/journald_stub.go @@ -0,0 +1,11 @@ +//go:build !linux + +package journald + +func enabled() bool { + return false +} + +func emit(message string, priority Priority, fields Fields) error { + return nil +} diff --git a/internal/service/event_logging.go b/internal/service/event_logging.go new file mode 100644 index 0000000..1ee093b --- /dev/null +++ b/internal/service/event_logging.go @@ -0,0 +1,200 @@ +package service + +import ( + "fmt" + "strconv" + "time" + + "github.com/FarisZR/knocker-cli/internal/journald" +) + +const ( + EventServiceState = "ServiceState" + EventStatusSnapshot = "StatusSnapshot" + EventWhitelistApplied = "WhitelistApplied" + EventWhitelistExpired = "WhitelistExpired" + EventNextKnockUpdated = "NextKnockUpdated" + EventKnockTriggered = "KnockTriggered" + EventError = "Error" +) + +const ( + ServiceStateStarted = "started" + ServiceStateStopping = "stopping" + ServiceStateStopped = "stopped" +) + +const ( + TriggerSourceCLI = "cli" + TriggerSourceSchedule = "schedule" + TriggerSourceExternal = "external" +) + +const ( + ResultSuccess = "success" + ResultFailure = "failure" +) + +const ( + ErrorCodeIPLookup = "ip_lookup_failed" + ErrorCodeHealthCheck = "health_check_failed" + ErrorCodeKnockFailed = "knock_failed" +) + +type whitelistState struct { + IP string + ExpiresUnix int64 + TTLSeconds int + Source string +} + +func (s *Service) emit(eventType, message string, priority journald.Priority, fields journald.Fields) { + if err := journald.Emit(eventType, message, priority, fields); err != nil && s.Logger != nil { + s.Logger.Printf("Failed to emit journald event %s: %v", eventType, err) + } +} + +func (s *Service) emitServiceState(state string) { + message := fmt.Sprintf("Service state: %s", state) + fields := journald.Fields{ + "KNOCKER_SERVICE_STATE": state, + } + if s.version != "" { + fields["KNOCKER_VERSION"] = s.version + } + priority := journald.PriInfo + if state == ServiceStateStopping { + priority = journald.PriNotice + } + s.emit(EventServiceState, message, priority, fields) +} + +func (s *Service) emitStatusSnapshot() { + fields := journald.Fields{} + if s.currentWhitelist != nil { + if s.currentWhitelist.IP != "" { + fields["KNOCKER_WHITELIST_IP"] = s.currentWhitelist.IP + } + if s.currentWhitelist.ExpiresUnix > 0 { + fields["KNOCKER_EXPIRES_UNIX"] = strconv.FormatInt(s.currentWhitelist.ExpiresUnix, 10) + } + if s.currentWhitelist.TTLSeconds > 0 { + fields["KNOCKER_TTL_SEC"] = strconv.Itoa(s.currentWhitelist.TTLSeconds) + } + } + if s.nextKnockUnix > 0 { + fields["KNOCKER_NEXT_AT_UNIX"] = strconv.FormatInt(s.nextKnockUnix, 10) + } + s.emit(EventStatusSnapshot, "Status snapshot", journald.PriInfo, fields) +} + +func (s *Service) emitWhitelistApplied(ip string, ttlSeconds int, expiresUnix int64, source string) { + fields := journald.Fields{} + if ip != "" { + fields["KNOCKER_WHITELIST_IP"] = ip + } + if ttlSeconds > 0 { + fields["KNOCKER_TTL_SEC"] = strconv.Itoa(ttlSeconds) + } + if expiresUnix > 0 { + fields["KNOCKER_EXPIRES_UNIX"] = strconv.FormatInt(expiresUnix, 10) + } + if source != "" { + fields["KNOCKER_SOURCE"] = source + } + + var message string + if ip == "" { + message = "Whitelist updated" + } else if ttlSeconds > 0 && expiresUnix > 0 { + exp := time.Unix(expiresUnix, 0).UTC().Format(time.RFC3339) + message = fmt.Sprintf("Whitelisted %s for %ds (expires at %s)", ip, ttlSeconds, exp) + } else { + message = fmt.Sprintf("Whitelisted %s", ip) + } + + s.emit(EventWhitelistApplied, message, journald.PriInfo, fields) +} + +func (s *Service) emitWhitelistExpired(ip string, expiredUnix int64) { + fields := journald.Fields{} + if ip != "" { + fields["KNOCKER_WHITELIST_IP"] = ip + } + if expiredUnix > 0 { + fields["KNOCKER_EXPIRED_UNIX"] = strconv.FormatInt(expiredUnix, 10) + } + + message := "Whitelist expired" + if ip != "" && expiredUnix > 0 { + message = fmt.Sprintf("Whitelist expired for %s at %s", ip, time.Unix(expiredUnix, 0).UTC().Format(time.RFC3339)) + } else if ip != "" { + message = fmt.Sprintf("Whitelist expired for %s", ip) + } + + s.emit(EventWhitelistExpired, message, journald.PriNotice, fields) +} + +func (s *Service) emitNextKnockUpdated(next time.Time) { + fields := journald.Fields{} + var message string + + if next.IsZero() { + fields["KNOCKER_NEXT_AT_UNIX"] = "0" + message = "Next knock cleared" + } else { + unix := next.Unix() + fields["KNOCKER_NEXT_AT_UNIX"] = strconv.FormatInt(unix, 10) + message = fmt.Sprintf("Next knock at %s", next.UTC().Format(time.RFC3339)) + } + + s.emit(EventNextKnockUpdated, message, journald.PriInfo, fields) +} + +func (s *Service) emitKnockTriggered(source, result, ip string) { + fields := journald.Fields{ + "KNOCKER_TRIGGER_SOURCE": source, + "KNOCKER_RESULT": result, + } + if ip != "" { + fields["KNOCKER_WHITELIST_IP"] = ip + } + + priority := journald.PriInfo + if result != ResultSuccess { + priority = journald.PriErr + } + + message := fmt.Sprintf("Knock triggered via %s: %s", source, result) + s.emit(EventKnockTriggered, message, priority, fields) +} + +func (s *Service) emitError(code, msg, context string) { + fields := journald.Fields{ + "KNOCKER_ERROR_CODE": code, + "KNOCKER_ERROR_MSG": msg, + } + if context != "" { + fields["KNOCKER_CONTEXT"] = context + } + + s.emit(EventError, msg, journald.PriErr, fields) +} + +func (s *Service) updateNextKnock(next time.Time) { + var unix int64 + if !next.IsZero() { + unix = next.Unix() + } + + if s.nextKnockUnix == unix { + return + } + + s.nextKnockUnix = unix + s.emitNextKnockUpdated(next) +} + +func (s *Service) clearNextKnock() { + s.updateNextKnock(time.Time{}) +} diff --git a/internal/service/service.go b/internal/service/service.go index 424f58d..46d95c6 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,7 +1,9 @@ package service import ( + "fmt" "log" + "sync" "time" "github.com/FarisZR/knocker-cli/internal/api" @@ -20,9 +22,16 @@ type Service struct { lastIP string ipCheckURL string ttl int + + version string + currentWhitelist *whitelistState + nextKnockUnix int64 + + stopOnce sync.Once + shutdownOnce sync.Once } -func NewService(apiClient *api.Client, ipGetter IPGetter, interval time.Duration, ipCheckURL string, ttl int, logger *log.Logger) *Service { +func NewService(apiClient *api.Client, ipGetter IPGetter, interval time.Duration, ipCheckURL string, ttl int, version string, logger *log.Logger) *Service { return &Service{ APIClient: apiClient, IPGetter: ipGetter, @@ -31,6 +40,7 @@ func NewService(apiClient *api.Client, ipGetter IPGetter, interval time.Duration stop: make(chan struct{}), ipCheckURL: ipCheckURL, ttl: ttl, + version: version, } } @@ -40,59 +50,159 @@ func (s *Service) Run(quit <-chan struct{}) { } else { s.Logger.Printf("Service running. Checking for IP changes every %v.", s.Interval) } + + s.emitServiceState(ServiceStateStarted) + s.updateNextKnock(time.Now().Add(s.Interval)) + s.emitStatusSnapshot() + ticker := time.NewTicker(s.Interval) defer ticker.Stop() + defer func() { + s.clearNextKnock() + s.emitStatusSnapshot() + s.emitServiceState(ServiceStateStopped) + }() for { select { case <-ticker.C: + s.checkWhitelistExpiry(time.Now()) s.checkAndKnock() + s.updateNextKnock(time.Now().Add(s.Interval)) case <-quit: + s.NotifyStopping() + s.checkWhitelistExpiry(time.Now()) return case <-s.stop: + s.NotifyStopping() + s.checkWhitelistExpiry(time.Now()) return } } } func (s *Service) Stop() { - close(s.stop) + s.NotifyStopping() + s.stopOnce.Do(func() { + close(s.stop) + }) +} + +func (s *Service) NotifyStopping() { + s.shutdownOnce.Do(func() { + s.emitServiceState(ServiceStateStopping) + }) } func (s *Service) checkAndKnock() { - // If no IP check URL is provided, just knock without checking. - // The remote API will use the request's source IP. if s.ipCheckURL == "" { s.Logger.Println("Knocking without IP check...") - if knockResponse, err := s.APIClient.Knock("", s.ttl); err != nil { + knockResponse, err := s.performKnock("", TriggerSourceSchedule) + if err != nil { s.Logger.Printf("Knock failed: %v", err) - } else if knockResponse != nil { + return + } + if knockResponse != nil { s.Logger.Printf("Successfully knocked. Whitelisted entry: %s (ttl: %d seconds)", knockResponse.WhitelistedEntry, knockResponse.ExpiresInSeconds) } return } - // If an IP check URL is provided, perform the check and compare. ip, err := s.IPGetter.GetPublicIP(s.ipCheckURL) if err != nil { s.Logger.Printf("Error getting public IP: %v", err) + s.emitError(ErrorCodeIPLookup, fmt.Sprintf("Error getting public IP: %v", err), s.ipCheckURL) return } - if ip != s.lastIP { - s.Logger.Printf("IP changed from %s to %s. Knocking...", s.lastIP, ip) - if err := s.APIClient.HealthCheck(); err != nil { - s.Logger.Printf("Health check failed: %v", err) - return - } - if knockResponse, err := s.APIClient.Knock(ip, s.ttl); err != nil { - s.Logger.Printf("Knock failed: %v", err) - return - } else if knockResponse != nil { - s.Logger.Printf("Successfully knocked and updated IP. Whitelisted entry: %s (ttl: %d seconds)", knockResponse.WhitelistedEntry, knockResponse.ExpiresInSeconds) - } else { - s.Logger.Println("Successfully knocked and updated IP.") - } - s.lastIP = ip + if ip == s.lastIP { + return } + + s.Logger.Printf("IP changed from %s to %s. Knocking...", s.lastIP, ip) + + if err := s.APIClient.HealthCheck(); err != nil { + s.Logger.Printf("Health check failed: %v", err) + s.emitError(ErrorCodeHealthCheck, fmt.Sprintf("Health check failed: %v", err), s.APIClient.BaseURL) + return + } + + knockResponse, err := s.performKnock(ip, TriggerSourceSchedule) + if err != nil { + s.Logger.Printf("Knock failed: %v", err) + return + } + + if knockResponse != nil { + s.Logger.Printf("Successfully knocked and updated IP. Whitelisted entry: %s (ttl: %d seconds)", knockResponse.WhitelistedEntry, knockResponse.ExpiresInSeconds) + } else { + s.Logger.Println("Successfully knocked and updated IP.") + } + + s.lastIP = ip +} + +func (s *Service) performKnock(ip, source string) (*api.KnockResponse, error) { + knockResponse, err := s.APIClient.Knock(ip, s.ttl) + if err != nil { + s.emitKnockTriggered(source, ResultFailure, ip) + s.emitError(ErrorCodeKnockFailed, fmt.Sprintf("Knock failed: %v", err), ip) + return nil, err + } + + whitelistIP := ip + if knockResponse != nil && knockResponse.WhitelistedEntry != "" { + whitelistIP = knockResponse.WhitelistedEntry + } + s.emitKnockTriggered(source, ResultSuccess, whitelistIP) + + s.handleWhitelistResponse(knockResponse, source) + + return knockResponse, nil +} + +func (s *Service) handleWhitelistResponse(knockResponse *api.KnockResponse, source string) { + if knockResponse == nil { + return + } + + s.currentWhitelist = &whitelistState{ + IP: knockResponse.WhitelistedEntry, + ExpiresUnix: knockResponse.ExpiresAt, + TTLSeconds: knockResponse.ExpiresInSeconds, + Source: source, + } + + s.emitWhitelistApplied(knockResponse.WhitelistedEntry, knockResponse.ExpiresInSeconds, knockResponse.ExpiresAt, source) + s.emitStatusSnapshot() +} + +func (s *Service) checkWhitelistExpiry(now time.Time) { + if s.currentWhitelist == nil { + return + } + + if s.currentWhitelist.ExpiresUnix <= 0 { + return + } + + if now.Unix() < s.currentWhitelist.ExpiresUnix { + return + } + + ip := s.currentWhitelist.IP + expiredUnix := s.currentWhitelist.ExpiresUnix + + s.currentWhitelist = nil + + if ip != "" && expiredUnix > 0 { + s.Logger.Printf("Whitelist expired for %s at %s", ip, time.Unix(expiredUnix, 0).UTC().Format(time.RFC3339)) + } else if ip != "" { + s.Logger.Printf("Whitelist expired for %s", ip) + } else { + s.Logger.Println("Whitelist expired") + } + + s.emitWhitelistExpired(ip, expiredUnix) + s.emitStatusSnapshot() } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 68a20cc..f328503 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -40,16 +40,15 @@ func TestServiceRun(t *testing.T) { defer server.Close() // Create a new service with mocked dependencies - service := &Service{ - APIClient: api.NewClient(server.URL, "test-key"), - IPGetter: &mockIPGetter{}, - Interval: 1 * time.Millisecond, // Run once and exit - Logger: log.New(os.Stdout, "test: ", log.LstdFlags), - stop: make(chan struct{}), - lastIP: "", - ipCheckURL: server.URL, - ttl: 3600, - } + service := NewService( + api.NewClient(server.URL, "test-key"), + &mockIPGetter{}, + 1*time.Millisecond, + server.URL, + 3600, + "test", + log.New(os.Stdout, "test: ", log.LstdFlags), + ) // Create a quit channel for the test quit := make(chan struct{}) @@ -61,8 +60,8 @@ func TestServiceRun(t *testing.T) { time.Sleep(10 * time.Millisecond) // Stop the service - close(service.stop) + service.Stop() // Assert that the IP was updated assert.Equal(t, "1.2.3.4", service.lastIP) -} \ No newline at end of file +}