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:
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`

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.
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**:
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.

View File

@@ -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

View File

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

View File

@@ -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")

View File

@@ -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.

View File

@@ -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 {

View File

@@ -9,10 +9,15 @@ 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
timeout time.Duration
want bool
}{
{
@@ -21,6 +26,7 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: true,
LastHandshake: &recent,
},
timeout: defaultTimeout,
want: true,
},
{
@@ -29,6 +35,7 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: false,
LastHandshake: &recent,
},
timeout: defaultTimeout,
want: true,
},
{
@@ -37,14 +44,43 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: true,
LastHandshake: &past,
},
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,
},
timeout: defaultTimeout,
want: false,
},
{
@@ -53,6 +89,7 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: true,
LastHandshake: nil,
},
timeout: defaultTimeout,
want: true,
},
{
@@ -61,12 +98,13 @@ func TestPeerStatus_IsConnected(t *testing.T) {
IsPingable: false,
LastHandshake: nil,
},
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)
}