Skip to content

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

FieldTypeDefaultDescription
UserAccessEnabledboolfalseWhether user access integration is active
UserAccessInterfaceNamestring"wg-access"WireGuard interface name for user access
UserAccessListenPortint51822UDP port for the user access WireGuard interface
MaxAccessPeersint50Maximum number of concurrent user access peers
go
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:

FieldZero ValueDefault Applied
UserAccessInterfaceName""DefaultUserAccessInterfaceName ("wg-access")
UserAccessListenPort0DefaultUserAccessListenPort (51822)
MaxAccessPeers0DefaultMaxAccessPeers (50)

Validation Rules

User access validation is skipped when UserAccessEnabled is false. When enabled:

FieldRuleError Message
UserAccessEnabledRequires Enabled=truebridge: config: user access requires bridge mode to be enabled
UserAccessListenPortMust be 1-65535bridge: config: UserAccessListenPort must be between 1 and 65535
UserAccessInterfaceNameMust not be emptybridge: config: UserAccessInterfaceName is required when user access is enabled
MaxAccessPeersMust be > 0bridge: 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.

go
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
}
MethodDescription
CreateInterfaceCreates a WireGuard interface with the given name and port
RemoveInterfaceRemoves the WireGuard interface by name
ConfigurePeerAdds or updates a peer on the WireGuard interface
RemovePeerRemoves 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

go
func NewUserAccessManager(ctrl AccessController, routes RouteController, cfg Config, logger *slog.Logger, provider UserAccessProvider) *UserAccessManager

Methods

MethodSignatureDescription
Setup() errorCreates WG interface, enables forwarding; no-op when disabled
Teardown() errorRemoves peers, forwarding, interface; aggregates errors
AddPeer(peer api.UserAccessPeer) errorAdds a peer; rejects duplicates and max-peers overflow
RemovePeer(publicKey string)Removes a peer by public key; no-op if not found
PeerPublicKeys() []stringReturns public keys of all active peers
UserAccessStatus() *api.UserAccessInfoReturns status for heartbeat; nil when inactive
UserAccessCapabilities() map[string]stringReturns capability metadata for registration; nil when disabled

Lifecycle

go
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

  1. AccessController.CreateInterface(interfaceName, listenPort) — create WireGuard interface
  2. RouteController.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:

  1. Remove all tracked peers individually via AccessController.RemovePeer
  2. Disable forwarding via RouteController.DisableForwarding
  3. 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

  1. Rejects duplicate public keys (peer already exists)
  2. Rejects if MaxAccessPeers limit is reached (max peers reached)
  3. Calls AccessController.ConfigurePeer to apply the WireGuard peer
  4. Tracks the public key in the internal activePeers set

RemovePeer

  1. If the public key is not tracked, returns immediately (no-op)
  2. Calls AccessController.RemovePeer to remove the WireGuard peer
  3. 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.

go
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventBridgeConfigUpdated,
    bridge.HandleBridgeConfigUpdated(reconciler))

UserAccessReconcileHandler

go
func UserAccessReconcileHandler(mgr *UserAccessManager, logger *slog.Logger) reconcile.ReconcileHandler

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

  1. Reads peers from desired.Bridge.UserAccess.Peers (empty when Bridge or UserAccess is nil)
  2. Builds a desired set keyed by PublicKey
  3. Removes stale peers: current keys not in the desired set
  4. Adds missing peers: desired peers not in the current set
  5. Aggregates AddPeer errors via errors.Join

Registration

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

go
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).

go
type UserAccessPeer struct {
    PublicKey  string   `json:"public_key"`
    AllowedIPs []string `json:"allowed_ips"`
    PSK       string   `json:"psk,omitempty"`
    Label     string   `json:"label"`
}
FieldDescription
PublicKeyWireGuard public key of the external client
AllowedIPsCIDR subnets the peer is allowed to route
PSKOptional pre-shared key for additional security
LabelHuman-readable label for the peer

UserAccessInfo

Reported in heartbeats via api.HeartbeatRequest.UserAccess.

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

ConstantValue
api.EventBridgeConfigUpdated"bridge_config_updated"

Plan Deviations

The implementation deviates from the original plan in two areas:

  1. UserAccessInfo placement: Plan task 1.1 specifies BridgeInfo.UserAccess *UserAccessInfo, but the implementation places it as HeartbeatRequest.UserAccess *UserAccessInfo instead. User access is a separate capability from bridge status, and placing it at the top level of HeartbeatRequest alongside Bridge *BridgeInfo keeps concerns cleanly separated.

  2. AccessSubnets reuse: Plan task 1.2 mentions a UserAccessSubnets []string config field, but the implementation reuses the existing AccessSubnets field since user access shares the same bridge access interface and exposes the same mesh CIDRs to VPN clients. Adding a separate UserAccessSubnets field would duplicate configuration with no behavioral difference.

Error Prefixes

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

StepPrefix
Private key generationbridge: access: generate key:
Interface creationbridge: access: create interface <name>:
Address assignmentbridge: access: configure address <cidr>:
Bringing the interface upbridge: access: set interface up:
Interface removalbridge: access: remove interface:
Peer public key decodebridge: access: decode public key:
Peer public key parsebridge: access: parse public key:
Allowed IP parsebridge: access: parse allowed IP "<cidr>":
PSK decodebridge: access: decode psk:
PSK parsebridge: access: parse psk:
Peer programmingbridge: access: configure peer:
Peer removalbridge: 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.

LevelEventKeys
InfoUser access interface createdinterface, listen_port
InfoUser access interface removedinterface
InfoAccess interface createdinterface, listen_port
InfoAccess interface removedinterface
DebugAccess interface addressedinterface, address
DebugAccess peer configuredinterface
DebugAccess peer removedinterface
ErrorRemove peer failedpublic_key, error
ErrorReconcile: add peer failedpublic_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:

go
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

TypePackageUsage
api.UserAccessConfiginternal/apiDesired user access config from control plane
api.UserAccessPeerinternal/apiIndividual peer definition
api.UserAccessInfointernal/apiUser access status in heartbeats
api.BridgeSnapshotinternal/apiSnapshot bridge subtree (contains UserAccess)
api.HeartbeatRequestinternal/apiHeartbeat payload (contains UserAccessInfo)
api.Envelopeinternal/apiSSE event wrapper
api.EventBridgeConfigUpdatedinternal/apiEvent type "bridge_config_updated"

Heartbeat Reporting

go
heartbeat := api.HeartbeatRequest{
    UserAccess: accessMgr.UserAccessStatus(), // nil when inactive
}

Registration Capabilities

go
caps := accessMgr.UserAccessCapabilities()
// {"user_access": "true", "access_listen_port": "51822"}
// nil when user access is disabled

Graceful Shutdown

go
<-ctx.Done()
if err := accessMgr.Teardown(); err != nil {
    logger.Warn("user access teardown failed", "error", err)
}

Full Lifecycle

go
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()