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
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -18,21 +19,21 @@

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.

### Comparison Mode (Optional)

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

Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions cmd/knocker/knock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
},
Expand All @@ -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)
}
41 changes: 35 additions & 6 deletions cmd/knocker/program.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"sync"
"time"

"github.com/FarisZR/knocker-cli/internal/api"
Expand All @@ -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
Expand All @@ -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
}
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
118 changes: 118 additions & 0 deletions docs/logging.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
Loading