From f70f60a3f5cdfc109a197aab9928c4f193a3465d Mon Sep 17 00:00:00 2001 From: h44z Date: Thu, 19 Mar 2026 23:11:40 +0100 Subject: [PATCH] fix: configurable handshake validity interval and improved defaults (#645) * fix: support configurable rekey timeout interval for peer connectivity tracking (#641) * change default check-time to 180s --- docs/documentation/configuration/overview.md | 8 +++ internal/app/wireguard/statistics.go | 10 ++-- internal/app/wireguard/statistics_test.go | 12 +++- internal/config/backend.go | 6 +- internal/config/config.go | 1 + internal/domain/statistics.go | 4 +- internal/domain/statistics_test.go | 60 ++++++++++++++++---- 7 files changed, 79 insertions(+), 22 deletions(-) diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 295e362..cad55c6 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -28,6 +28,7 @@ core: backend: default: local + rekey_timeout_interval: 125s local_resolvconf_prefix: tun. advanced: @@ -203,6 +204,13 @@ The current MikroTik backend is in **BETA** and may not support all features. - **Description:** The default backend to use for managing WireGuard interfaces. Valid options are: `local`, or other backend id's configured in the `mikrotik` section. +### `rekey_timeout_interval` +- **Default:** `180s` +- **Environment Variable:** `WG_PORTAL_BACKEND_REKEY_TIMEOUT_INTERVAL` +- **Description:** The interval after which a WireGuard peer is considered disconnected if no handshake updates are received. + This corresponds to the WireGuard rekey timeout setting of 120 seconds plus a 60-second buffer to account for latency or retry handling. + Uses Go duration format (e.g., `10s`, `1m`). If omitted, a default of 180 seconds is used. + ### `local_resolvconf_prefix` - **Default:** `tun.` - **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX` diff --git a/internal/app/wireguard/statistics.go b/internal/app/wireguard/statistics.go index bbc0764..819bbf1 100644 --- a/internal/app/wireguard/statistics.go +++ b/internal/app/wireguard/statistics.go @@ -204,13 +204,13 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { // calculate if session was restarted p.UpdatedAt = now - p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, + p.LastSessionStart = c.getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, lastHandshake) p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server p.Endpoint = peer.Endpoint p.LastHandshake = lastHandshake - p.CalcConnected() + p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval) if wasConnected != p.IsConnected { slog.Debug("peer connection state changed", @@ -249,7 +249,7 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { } } -func getSessionStartTime( +func (c *StatisticsCollector) getSessionStartTime( oldStats domain.PeerStatus, newReceived, newTransmitted uint64, latestHandshake *time.Time, @@ -258,7 +258,7 @@ func getSessionStartTime( return nil // currently not connected } - oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected + oldestHandshakeTime := time.Now().Add(-1 * c.cfg.Backend.ReKeyTimeoutInterval) // if a handshake is older than the rekey interval + grace-period, the peer is no longer connected switch { // old session was never initiated case oldStats.BytesReceived == 0 && oldStats.BytesTransmitted == 0 && (newReceived > 0 || newTransmitted > 0): @@ -369,7 +369,7 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) { p.LastPing = nil } p.UpdatedAt = time.Now() - p.CalcConnected() + p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval) if wasConnected != p.IsConnected { connectionStateChanged = true diff --git a/internal/app/wireguard/statistics_test.go b/internal/app/wireguard/statistics_test.go index dc1ba43..64fe395 100644 --- a/internal/app/wireguard/statistics_test.go +++ b/internal/app/wireguard/statistics_test.go @@ -5,10 +5,11 @@ import ( "testing" "time" + "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" ) -func Test_getSessionStartTime(t *testing.T) { +func TestStatisticsCollector_getSessionStartTime(t *testing.T) { now := time.Now() nowMinus1 := now.Add(-1 * time.Minute) nowMinus3 := now.Add(-3 * time.Minute) @@ -133,7 +134,14 @@ func Test_getSessionStartTime(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := getSessionStartTime(tt.args.oldStats, tt.args.newReceived, tt.args.newTransmitted, + c := &StatisticsCollector{ + cfg: &config.Config{ + Backend: config.Backend{ + ReKeyTimeoutInterval: 180 * time.Second, + }, + }, + } + if got := c.getSessionStartTime(tt.args.oldStats, tt.args.newReceived, tt.args.newTransmitted, tt.args.lastHandshake); !reflect.DeepEqual(got, tt.want) { t.Errorf("getSessionStartTime() = %v, want %v", got, tt.want) } diff --git a/internal/config/backend.go b/internal/config/backend.go index c02058f..5d54058 100644 --- a/internal/config/backend.go +++ b/internal/config/backend.go @@ -10,6 +10,8 @@ const LocalBackendName = "local" type Backend struct { Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) + ReKeyTimeoutInterval time.Duration `yaml:"rekey_timeout_interval"` // Interval after which a connection is assumed dead + // Local Backend-specific configuration IgnoredLocalInterfaces []string `yaml:"ignored_local_interfaces"` // A list of interface names that should be ignored by this backend (e.g., "wg0") @@ -115,8 +117,8 @@ func (b *BackendMikrotik) GetApiTimeout() time.Duration { type BackendPfsense struct { BackendBase `yaml:",inline"` // Embed the base fields - ApiUrl string `yaml:"api_url"` // The base URL of the pfSense REST API (e.g., "https://pfsense.example.com/api/v2") - ApiKey string `yaml:"api_key"` // API key for authentication (generated in pfSense under 'System' -> 'REST API' -> 'Keys') + ApiUrl string `yaml:"api_url"` // The base URL of the pfSense REST API (e.g., "https://pfsense.example.com/api/v2") + ApiKey string `yaml:"api_key"` // API key for authentication (generated in pfSense under 'System' -> 'REST API' -> 'Keys') ApiVerifyTls bool `yaml:"api_verify_tls"` // Whether to verify the TLS certificate of the pfSense API ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds) diff --git a/internal/config/config.go b/internal/config/config.go index ee5be08..a88b2d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -139,6 +139,7 @@ func defaultConfig() *Config { cfg.Backend = Backend{ Default: LocalBackendName, // local backend is the default (using wgcrtl) + ReKeyTimeoutInterval: getEnvDuration("WG_PORTAL_BACKEND_REKEY_TIMEOUT_INTERVAL", 180*time.Second), IgnoredLocalInterfaces: getEnvStrSlice("WG_PORTAL_BACKEND_IGNORED_LOCAL_INTERFACES", nil), // Most resolconf implementations use "tun." as a prefix for interface names. // But systemd's implementation uses no prefix, for example. diff --git a/internal/domain/statistics.go b/internal/domain/statistics.go index aa205e8..7070287 100644 --- a/internal/domain/statistics.go +++ b/internal/domain/statistics.go @@ -21,8 +21,8 @@ type PeerStatus struct { LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"` } -func (s *PeerStatus) CalcConnected() { - oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected +func (s *PeerStatus) CalcConnected(timeout time.Duration) { + oldestHandshakeTime := time.Now().Add(-1 * timeout) // if a handshake is older than the rekey-interval + grace-period, the peer is no longer connected handshakeValid := false if s.LastHandshake != nil { diff --git a/internal/domain/statistics_test.go b/internal/domain/statistics_test.go index 1a43560..67aaed0 100644 --- a/internal/domain/statistics_test.go +++ b/internal/domain/statistics_test.go @@ -9,11 +9,16 @@ func TestPeerStatus_IsConnected(t *testing.T) { now := time.Now() past := now.Add(-3 * time.Minute) recent := now.Add(-1 * time.Minute) + defaultTimeout := 125 * time.Second // rekey interval of 120s + 5 seconds grace period + past126 := now.Add(-1*defaultTimeout - 1*time.Second) + past125 := now.Add(-1 * defaultTimeout) + past124 := now.Add(-1*defaultTimeout + 1*time.Second) tests := []struct { - name string - status PeerStatus - want bool + name string + status PeerStatus + timeout time.Duration + want bool }{ { name: "Pingable and recent handshake", @@ -21,7 +26,8 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: true, LastHandshake: &recent, }, - want: true, + timeout: defaultTimeout, + want: true, }, { name: "Not pingable but recent handshake", @@ -29,7 +35,8 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: false, LastHandshake: &recent, }, - want: true, + timeout: defaultTimeout, + want: true, }, { name: "Pingable but old handshake", @@ -37,15 +44,44 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: true, LastHandshake: &past, }, - want: true, + timeout: defaultTimeout, + want: true, }, { - name: "Not pingable and old handshake", + name: "Not pingable and ok handshake (-124s)", + status: PeerStatus{ + IsPingable: false, + LastHandshake: &past124, + }, + timeout: defaultTimeout, + want: true, + }, + { + name: "Not pingable and old handshake (-125s)", + status: PeerStatus{ + IsPingable: false, + LastHandshake: &past125, + }, + timeout: defaultTimeout, + want: false, + }, + { + name: "Not pingable and old handshake (-126s)", + status: PeerStatus{ + IsPingable: false, + LastHandshake: &past126, + }, + timeout: defaultTimeout, + want: false, + }, + { + name: "Not pingable and old handshake (very old)", status: PeerStatus{ IsPingable: false, LastHandshake: &past, }, - want: false, + timeout: defaultTimeout, + want: false, }, { name: "Pingable and no handshake", @@ -53,7 +89,8 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: true, LastHandshake: nil, }, - want: true, + timeout: defaultTimeout, + want: true, }, { name: "Not pingable and no handshake", @@ -61,12 +98,13 @@ func TestPeerStatus_IsConnected(t *testing.T) { IsPingable: false, LastHandshake: nil, }, - want: false, + timeout: defaultTimeout, + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.status.CalcConnected() + tt.status.CalcConnected(tt.timeout) if got := tt.status.IsConnected; got != tt.want { t.Errorf("IsConnected = %v, want %v", got, tt.want) }