User Access Integration
The user access integration extends bridge mode (internal/bridge) to allow external VPN clients (Tailscale, Netbird, WireGuard) to connect to the mesh network via a dedicated WireGuard interface on the bridge node. The control plane manages peer assignments; the bridge node creates the interface, configures peers, and forwards traffic into the mesh.
Data Flow
External VPN Clients
(Tailscale / Netbird / WireGuard)
│
│ WireGuard tunnel
▼
┌─────────────────────────────────────────────────────────────────┐
│ Bridge Node │
│ │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ Access WireGuard │ IP fwd │ Mesh WireGuard │ │
│ │ Interface │────────▶│ Interface │ │
│ │ (wg-access) │ │ (plexd0) │ │
│ │ port 51822 │ │ │ │
│ └───────────────────┘ └─────────┬─────────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌──────┴──────────┐ ┌──────────────────┐ │
│ │ AccessController│ │ Mesh Peers │ │
│ │ (WG operations) │ │ 10.42.0.0/16 │ │
│ └─────────────────┘ └──────────────────┘ │
│ │
│ Control Plane ──SSE──▶ bridge_config_updated ─▶ reconcile │
│ ──Rec──▶ UserAccessReconcileHandler │
└─────────────────────────────────────────────────────────────────┘Traffic from external VPN clients arrives on the access WireGuard interface (wg-access), is forwarded via IP forwarding to the mesh WireGuard interface (plexd0), and reaches mesh peers. The RouteController manages forwarding rules; the AccessController manages the WireGuard interface and peer configuration.
Config
User access fields extend the existing bridge Config struct. User access requires bridge mode to be enabled (Enabled=true).
| Field | Type | Default | Description |
|---|---|---|---|
UserAccessEnabled | bool | false | Whether user access integration is active |
UserAccessInterfaceName | string | "wg-access" | WireGuard interface name for user access |
UserAccessListenPort | int | 51822 | UDP port for the user access WireGuard interface |
MaxAccessPeers | int | 50 | Maximum number of concurrent user access peers |
cfg := bridge.Config{
Enabled: true,
AccessInterface: "eth1",
AccessSubnets: []string{"10.0.0.0/24"},
UserAccessEnabled: true,
}
cfg.ApplyDefaults() // sets UserAccessInterfaceName, UserAccessListenPort, MaxAccessPeers
if err := cfg.Validate(); err != nil {
log.Fatal(err)
}Defaults
ApplyDefaults() sets zero-valued user access fields:
| Field | Zero Value | Default Applied |
|---|---|---|
UserAccessInterfaceName | "" | DefaultUserAccessInterfaceName ("wg-access") |
UserAccessListenPort | 0 | DefaultUserAccessListenPort (51822) |
MaxAccessPeers | 0 | DefaultMaxAccessPeers (50) |
Validation Rules
User access validation is skipped when UserAccessEnabled is false. When enabled:
| Field | Rule | Error Message |
|---|---|---|
UserAccessEnabled | Requires Enabled=true | bridge: config: user access requires bridge mode to be enabled |
UserAccessListenPort | Must be 1-65535 | bridge: config: UserAccessListenPort must be between 1 and 65535 |
UserAccessInterfaceName | Must not be empty | bridge: config: UserAccessInterfaceName is required when user access is enabled |
MaxAccessPeers | Must be > 0 | bridge: config: MaxAccessPeers must be positive when user access is enabled |
AccessController
Interface abstracting WireGuard interface operations for user access. Two implementations exist. NetlinkAccessController (access_controller_linux.go) drives the Linux kernel through netlink and wgctrl. WGAccessController (access_controller_wg.go) covers macOS and Windows by wrapping the platform WGController: DarwinController on a utun device, WindowsController on a Wintun adapter, both running on the userspace backend.
Every platform generates a fresh private key per interface and assigns no address. User access forwards between the access and the mesh interface, and no route is installed over the device. macOS requires root and Windows Administrator, which the LocalSystem service satisfies. The interface answers the WireGuard UAPI, so wg show wg-access works through /var/run/wireguard/wg-access.sock on macOS and through the named pipe \\.\pipe\ProtectedPrefix\Administrators\WireGuard\wg-access on Windows. The macOS kernel calls the device utunN; the utun device created log line pairs that name with wg-access, while the Wintun adapter carries wg-access itself.
A second CreateInterface for a name that already exists fails with an error wrapping os.ErrExist on macOS and Windows, which is what EEXIST is on Linux. Forwarding on those two platforms behaves as macOS & Windows Route Controllers describes. WinNAT is scoped to the mesh prefix, so a user-access source is not translated on Windows; see pf & WFP Firewall Controllers.
type AccessController interface {
CreateInterface(name string, listenPort int) error
RemoveInterface(name string) error
ConfigurePeer(iface string, publicKey string, allowedIPs []string, psk string) error
RemovePeer(iface string, publicKey string) error
}| Method | Description |
|---|---|
CreateInterface | Creates a WireGuard interface with the given name and port |
RemoveInterface | Removes the WireGuard interface by name |
ConfigurePeer | Adds or updates a peer on the WireGuard interface |
RemovePeer | Removes a peer from the WireGuard interface by public key |
Every method except CreateInterface must be idempotent: repeating an already-applied operation returns nil. The create is not, on any platform: a name that already exists fails with an error wrapping os.ErrExist.
UserAccessManager
Central coordinator for user access lifecycle. Concurrent-safe via sync.Mutex — the reconcile handler and status readers may invoke methods concurrently.
Constructor
func NewUserAccessManager(ctrl AccessController, routes RouteController, cfg Config, logger *slog.Logger, provider UserAccessProvider) *UserAccessManagerMethods
| Method | Signature | Description |
|---|---|---|
Setup | () error | Creates WG interface, enables forwarding; no-op when disabled |
Teardown | () error | Removes peers, forwarding, interface; aggregates errors |
AddPeer | (peer api.UserAccessPeer) error | Adds a peer; rejects duplicates and max-peers overflow |
RemovePeer | (publicKey string) | Removes a peer by public key; no-op if not found |
PeerPublicKeys | () []string | Returns public keys of all active peers |
UserAccessStatus | () *api.UserAccessInfo | Returns status for heartbeat; nil when inactive |
UserAccessCapabilities | () map[string]string | Returns capability metadata for registration; nil when disabled |
Lifecycle
mgr := bridge.NewUserAccessManager(accessCtrl, routeCtrl, cfg, logger)
// Setup — creates interface, enables forwarding
if err := mgr.Setup(); err != nil {
log.Fatal(err)
}
// Add a peer (driven by the reconcile handler)
err := mgr.AddPeer(api.UserAccessPeer{
PublicKey: "pk-abc123",
AllowedIPs: []string{"10.99.0.1/32"},
PSK: "optional-psk",
Label: "alice-laptop",
})
// Remove a peer
mgr.RemovePeer("pk-abc123")
// Report status in heartbeat
status := mgr.UserAccessStatus()
// Capabilities for registration
caps := mgr.UserAccessCapabilities()
// {"user_access": "true", "access_listen_port": "51822"}
// Graceful shutdown
if err := mgr.Teardown(); err != nil {
logger.Warn("teardown failed", "error", err)
}Setup Sequence
AccessController.CreateInterface(interfaceName, listenPort)— create WireGuard interfaceRouteController.EnableForwarding(interfaceName, accessInterface)— enable IP forwarding
When UserAccessEnabled is false, Setup is a no-op.
Setup Rollback
If EnableForwarding fails after CreateInterface succeeds, the interface is rolled back via RemoveInterface.
Teardown
Teardown removes all state regardless of individual failures:
- Remove all tracked peers individually via
AccessController.RemovePeer - Disable forwarding via
RouteController.DisableForwarding - Remove interface via
AccessController.RemoveInterface
Errors are aggregated via errors.Join — cleanup continues even when individual operations fail. Calling Teardown when the manager is inactive is a no-op.
AddPeer
- Rejects duplicate public keys (
peer already exists) - Rejects if
MaxAccessPeerslimit is reached (max peers reached) - Calls
AccessController.ConfigurePeerto apply the WireGuard peer - Tracks the public key in the internal
activePeersset
RemovePeer
- If the public key is not tracked, returns immediately (no-op)
- Calls
AccessController.RemovePeerto remove the WireGuard peer - On success, removes the key from internal tracking
SSE Event Handling
There are no user-access-specific SSE handlers. The control plane emits a single bridge_config_updated event with an opaque payload; bridge.HandleBridgeConfigUpdated dispatches it to TriggerReconcile(), and the UserAccessReconcileHandler below applies the desired user-access subtree from the authoritative state snapshot.
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
bridge.HandleBridgeConfigUpdated(reconciler))UserAccessReconcileHandler
func UserAccessReconcileHandler(mgr *UserAccessManager, logger *slog.Logger) reconcile.ReconcileHandlerReturns a reconcile.ReconcileHandler that synchronizes user access peers to match the desired bridge user-access subtree. The handler is presence-aware: a null Bridge or null UserAccess child means "not populated", so it reconciles against an empty desired set and tears down stale peers.
- Reads peers from
desired.Bridge.UserAccess.Peers(empty whenBridgeorUserAccessis nil) - Builds a desired set keyed by
PublicKey - Removes stale peers: current keys not in the desired set
- Adds missing peers: desired peers not in the current set
- Aggregates
AddPeererrors viaerrors.Join
Registration
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(bridge.UserAccessReconcileHandler(accessMgr, logger))API Types
UserAccessConfig
Pushed from the control plane in the snapshot bridge.user_access subtree (api.BridgeSnapshot.UserAccess), present-but-nullable — a null value tears down active peers.
type UserAccessConfig struct {
Enabled bool `json:"enabled"`
InterfaceName string `json:"interface_name"`
ListenPort int `json:"listen_port"`
Peers []UserAccessPeer `json:"peers"`
}UserAccessPeer
Represents a single user access peer (external VPN client).
type UserAccessPeer struct {
PublicKey string `json:"public_key"`
AllowedIPs []string `json:"allowed_ips"`
PSK string `json:"psk,omitempty"`
Label string `json:"label"`
}| Field | Description |
|---|---|
PublicKey | WireGuard public key of the external client |
AllowedIPs | CIDR subnets the peer is allowed to route |
PSK | Optional pre-shared key for additional security |
Label | Human-readable label for the peer |
UserAccessInfo
Reported in heartbeats via api.HeartbeatRequest.UserAccess.
type UserAccessInfo struct {
Enabled bool `json:"enabled"`
InterfaceName string `json:"interface_name"`
PeerCount int `json:"peer_count"`
ListenPort int `json:"listen_port"`
}SSE Event Constants
User-access changes are delivered through the single bridge event constant; the fine-grained user_access_* constants have been removed.
| Constant | Value |
|---|---|
api.EventBridgeConfigUpdated | "bridge_config_updated" |
Plan Deviations
The implementation deviates from the original plan in two areas:
UserAccessInfo placement: Plan task 1.1 specifies
BridgeInfo.UserAccess *UserAccessInfo, but the implementation places it asHeartbeatRequest.UserAccess *UserAccessInfoinstead. User access is a separate capability from bridge status, and placing it at the top level ofHeartbeatRequestalongsideBridge *BridgeInfokeeps concerns cleanly separated.AccessSubnets reuse: Plan task 1.2 mentions a
UserAccessSubnets []stringconfig field, but the implementation reuses the existingAccessSubnetsfield since user access shares the same bridge access interface and exposes the same mesh CIDRs to VPN clients. Adding a separateUserAccessSubnetsfield would duplicate configuration with no behavioral difference.
Error Prefixes
| Source | Prefix |
|---|---|
UserAccessManager.Setup (create) | bridge: user access: create interface: |
UserAccessManager.Setup (fwd) | bridge: user access: enable forwarding: |
UserAccessManager.AddPeer (dup) | bridge: user access: peer already exists: |
UserAccessManager.AddPeer (max) | bridge: user access: max peers reached ( |
UserAccessManager.AddPeer (ctrl) | bridge: user access: configure peer: |
The controller behind those calls carries prefixes of its own:
| Step | Prefix |
|---|---|
| Private key generation | bridge: access: generate key: |
| Interface creation | bridge: access: create interface <name>: |
| Address assignment | bridge: access: configure address <cidr>: |
| Bringing the interface up | bridge: access: set interface up: |
| Interface removal | bridge: access: remove interface: |
| Peer public key decode | bridge: access: decode public key: |
| Peer public key parse | bridge: access: parse public key: |
| Allowed IP parse | bridge: access: parse allowed IP "<cidr>": |
| PSK decode | bridge: access: decode psk: |
| PSK parse | bridge: access: parse psk: |
| Peer programming | bridge: access: configure peer: |
| Peer removal | bridge: access: remove peer: |
Both implementations use these prefixes, so a rejected peer reads the same on every platform. NetlinkAccessController adds bridge: access: open wgctrl: and bridge: access: configure device: for the two steps only it has. On macOS and Windows the text after the prefix is the wireguard: controller's own message.
Logging
All user access log entries use component=bridge.
| Level | Event | Keys |
|---|---|---|
Info | User access interface created | interface, listen_port |
Info | User access interface removed | interface |
Info | Access interface created | interface, listen_port |
Info | Access interface removed | interface |
Debug | Access interface addressed | interface, address |
Debug | Access peer configured | interface |
Debug | Access peer removed | interface |
Error | Remove peer failed | public_key, error |
Error | Reconcile: add peer failed | public_key, error |
The four controller lines access interface created, access interface removed, access peer configured and access peer removed are the same on Linux, macOS and Windows. access interface addressed comes from the WireGuard-backed controller and appears only when it is built with an address; the access interface is unnumbered on every platform.
Integration Points
Reconciliation Loop
The user access reconcile handler plugs into internal/reconcile alongside existing handlers:
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(wireguard.ReconcileHandler(wgMgr))
r.RegisterHandler(policy.ReconcileHandler(enforcer, "plexd0"))
r.RegisterHandler(bridge.RelayReconcileHandler(bridgeMgr.Relay(), logger))
r.RegisterHandler(bridge.UserAccessReconcileHandler(accessMgr, logger))SSE Real-Time Updates
The bridge_config_updated event triggers a full reconcile; the UserAccessReconcileHandler then applies the desired peer set from the state snapshot. There are no per-peer events.
Control Plane Types
| Type | Package | Usage |
|---|---|---|
api.UserAccessConfig | internal/api | Desired user access config from control plane |
api.UserAccessPeer | internal/api | Individual peer definition |
api.UserAccessInfo | internal/api | User access status in heartbeats |
api.BridgeSnapshot | internal/api | Snapshot bridge subtree (contains UserAccess) |
api.HeartbeatRequest | internal/api | Heartbeat payload (contains UserAccessInfo) |
api.Envelope | internal/api | SSE event wrapper |
api.EventBridgeConfigUpdated | internal/api | Event type "bridge_config_updated" |
Heartbeat Reporting
heartbeat := api.HeartbeatRequest{
UserAccess: accessMgr.UserAccessStatus(), // nil when inactive
}Registration Capabilities
caps := accessMgr.UserAccessCapabilities()
// {"user_access": "true", "access_listen_port": "51822"}
// nil when user access is disabledGraceful Shutdown
<-ctx.Done()
if err := accessMgr.Teardown(); err != nil {
logger.Warn("user access teardown failed", "error", err)
}Full Lifecycle
cfg := bridge.Config{
Enabled: true,
AccessInterface: "eth1",
AccessSubnets: []string{"10.0.0.0/24"},
UserAccessEnabled: true,
}
cfg.ApplyDefaults()
accessMgr := bridge.NewUserAccessManager(accessCtrl, routeCtrl, cfg, logger)
// Setup user access interface and forwarding
accessMgr.Setup()
// Register the bridge SSE handler
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
bridge.HandleBridgeConfigUpdated(reconciler))
// Register reconcile handler
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(bridge.UserAccessReconcileHandler(accessMgr, logger))
// Run reconciler
go r.Run(ctx, nodeID)
// Graceful shutdown
<-ctx.Done()
accessMgr.Teardown()