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
This commit is contained in:
h44z
2026-03-19 23:11:40 +01:00
committed by GitHub
parent 2585be118f
commit f70f60a3f5
7 changed files with 79 additions and 22 deletions

View File

@@ -28,6 +28,7 @@ core:
backend: backend:
default: local default: local
rekey_timeout_interval: 125s
local_resolvconf_prefix: tun. local_resolvconf_prefix: tun.
advanced: 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. - **Description:** The default backend to use for managing WireGuard interfaces.
Valid options are: `local`, or other backend id's configured in the `mikrotik` section. 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` ### `local_resolvconf_prefix`
- **Default:** `tun.` - **Default:** `tun.`
- **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX` - **Environment Variable:** `WG_PORTAL_BACKEND_LOCAL_RESOLVCONF_PREFIX`

View File

@@ -204,13 +204,13 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
// calculate if session was restarted // calculate if session was restarted
p.UpdatedAt = now p.UpdatedAt = now
p.LastSessionStart = getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload, p.LastSessionStart = c.getSessionStartTime(*p, peer.BytesUpload, peer.BytesDownload,
lastHandshake) lastHandshake)
p.BytesReceived = peer.BytesUpload // store bytes that where uploaded from the peer and received by the server 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.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
p.Endpoint = peer.Endpoint p.Endpoint = peer.Endpoint
p.LastHandshake = lastHandshake p.LastHandshake = lastHandshake
p.CalcConnected() p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval)
if wasConnected != p.IsConnected { if wasConnected != p.IsConnected {
slog.Debug("peer connection state changed", 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, oldStats domain.PeerStatus,
newReceived, newTransmitted uint64, newReceived, newTransmitted uint64,
latestHandshake *time.Time, latestHandshake *time.Time,
@@ -258,7 +258,7 @@ func getSessionStartTime(
return nil // currently not connected 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 { switch {
// old session was never initiated // old session was never initiated
case oldStats.BytesReceived == 0 && oldStats.BytesTransmitted == 0 && (newReceived > 0 || newTransmitted > 0): 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.LastPing = nil
} }
p.UpdatedAt = time.Now() p.UpdatedAt = time.Now()
p.CalcConnected() p.CalcConnected(c.cfg.Backend.ReKeyTimeoutInterval)
if wasConnected != p.IsConnected { if wasConnected != p.IsConnected {
connectionStateChanged = true connectionStateChanged = true

View File

@@ -5,10 +5,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
func Test_getSessionStartTime(t *testing.T) { func TestStatisticsCollector_getSessionStartTime(t *testing.T) {
now := time.Now() now := time.Now()
nowMinus1 := now.Add(-1 * time.Minute) nowMinus1 := now.Add(-1 * time.Minute)
nowMinus3 := now.Add(-3 * time.Minute) nowMinus3 := now.Add(-3 * time.Minute)
@@ -133,7 +134,14 @@ func Test_getSessionStartTime(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) { tt.args.lastHandshake); !reflect.DeepEqual(got, tt.want) {
t.Errorf("getSessionStartTime() = %v, want %v", got, tt.want) t.Errorf("getSessionStartTime() = %v, want %v", got, tt.want)
} }

View File

@@ -10,6 +10,8 @@ const LocalBackendName = "local"
type Backend struct { type Backend struct {
Default string `yaml:"default"` // The default backend to use (defaults to the internal backend) 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 // 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") 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 { type BackendPfsense struct {
BackendBase `yaml:",inline"` // Embed the base fields 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") 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') 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 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) ApiTimeout time.Duration `yaml:"api_timeout"` // Timeout for API requests (default: 30 seconds)

View File

@@ -139,6 +139,7 @@ func defaultConfig() *Config {
cfg.Backend = Backend{ cfg.Backend = Backend{
Default: LocalBackendName, // local backend is the default (using wgcrtl) 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), IgnoredLocalInterfaces: getEnvStrSlice("WG_PORTAL_BACKEND_IGNORED_LOCAL_INTERFACES", nil),
// Most resolconf implementations use "tun." as a prefix for interface names. // Most resolconf implementations use "tun." as a prefix for interface names.
// But systemd's implementation uses no prefix, for example. // But systemd's implementation uses no prefix, for example.

View File

@@ -21,8 +21,8 @@ type PeerStatus struct {
LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"` LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"`
} }
func (s *PeerStatus) CalcConnected() { func (s *PeerStatus) CalcConnected(timeout time.Duration) {
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 * timeout) // if a handshake is older than the rekey-interval + grace-period, the peer is no longer connected
handshakeValid := false handshakeValid := false
if s.LastHandshake != nil { if s.LastHandshake != nil {

View File

@@ -9,11 +9,16 @@ func TestPeerStatus_IsConnected(t *testing.T) {
now := time.Now() now := time.Now()
past := now.Add(-3 * time.Minute) past := now.Add(-3 * time.Minute)
recent := now.Add(-1 * 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 { tests := []struct {
name string name string
status PeerStatus status PeerStatus
want bool timeout time.Duration
want bool
}{ }{
{ {
name: "Pingable and recent handshake", name: "Pingable and recent handshake",
@@ -21,7 +26,8 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: true, IsPingable: true,
LastHandshake: &recent, LastHandshake: &recent,
}, },
want: true, timeout: defaultTimeout,
want: true,
}, },
{ {
name: "Not pingable but recent handshake", name: "Not pingable but recent handshake",
@@ -29,7 +35,8 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: false, IsPingable: false,
LastHandshake: &recent, LastHandshake: &recent,
}, },
want: true, timeout: defaultTimeout,
want: true,
}, },
{ {
name: "Pingable but old handshake", name: "Pingable but old handshake",
@@ -37,15 +44,44 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: true, IsPingable: true,
LastHandshake: &past, 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{ status: PeerStatus{
IsPingable: false, IsPingable: false,
LastHandshake: &past, LastHandshake: &past,
}, },
want: false, timeout: defaultTimeout,
want: false,
}, },
{ {
name: "Pingable and no handshake", name: "Pingable and no handshake",
@@ -53,7 +89,8 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: true, IsPingable: true,
LastHandshake: nil, LastHandshake: nil,
}, },
want: true, timeout: defaultTimeout,
want: true,
}, },
{ {
name: "Not pingable and no handshake", name: "Not pingable and no handshake",
@@ -61,12 +98,13 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: false, IsPingable: false,
LastHandshake: nil, LastHandshake: nil,
}, },
want: false, timeout: defaultTimeout,
want: false,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tt.status.CalcConnected() tt.status.CalcConnected(tt.timeout)
if got := tt.status.IsConnected; got != tt.want { if got := tt.status.IsConnected; got != tt.want {
t.Errorf("IsConnected = %v, want %v", got, tt.want) t.Errorf("IsConnected = %v, want %v", got, tt.want)
} }