Compare commits

...

3 Commits

Author SHA1 Message Date
Christoph
7bfbe12d5b change default check-time to 180s 2026-03-19 12:42:08 +01:00
Christoph Haas
c3defbf675 fix: support configurable rekey timeout interval for peer connectivity tracking (#641) 2026-03-18 21:55:45 +01:00
Tim
9c56e92443 Updated documentation (#640)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Include pass about systemd networkd managing foreign routes and deleting them on restart.

Signed-off-by: Tim <tim@tuimz.nl>
Co-authored-by: Tim Aerdts <tim@teaminova.nl>
2026-03-17 18:33:57 +01:00
8 changed files with 87 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

@@ -35,6 +35,14 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
> :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file. > :warning: If host networking is used, the WireGuard Portal UI will be accessible on all the host's IP addresses if the listening address is set to `:8888` in the configuration file.
To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI. To avoid this, you can bind the listening address to a specific IP address, for example, the loopback address (`127.0.0.1:8888`). It is also possible to deploy firewall rules to restrict access to the WireGuard Portal UI.
> :warning: If the host is running **systemd-networkd**, routes managed by WireGuard Portal may be removed whenever systemd-networkd restarts, as it will clean up routes it considers "foreign". To prevent this, add the following to your host's network configuration (e.g. `/etc/systemd/networkd.conf` or a drop-in file):
> ```ini
> [Network]
> ManageForeignRoutingPolicyRules=no
> ManageForeignRoutes=no
> ```
> After editing, reload the configuration with `sudo systemctl restart systemd-networkd`. For more information refer to the [systemd-networkd documentation](https://www.freedesktop.org/software/systemd/man/latest/networkd.conf.html#ManageForeignRoutes=).
- **Within the WireGuard Portal Docker container**: - **Within the WireGuard Portal Docker container**:
WireGuard interfaces can be managed directly from within the WireGuard Portal container itself. WireGuard interfaces can be managed directly from within the WireGuard Portal container itself.
This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image. This is the recommended approach when running WireGuard Portal via Docker, as it encapsulates all functionality in a single, portable container without requiring a separate WireGuard host or image.

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)
} }