VPN Providers
The VPN providers feature extends user access integration (internal/bridge) with a UserAccessProvider interface for external VPN provider integration. Concrete implementations for Tailscale and Netbird enable external VPN users to access mesh resources through the bridge node via their provider's overlay network.
Data Flow
External VPN Users
(Tailscale / Netbird)
|
| Provider overlay network
v
+---------------------------------------------------------------+
| Bridge Node |
| |
| +-------------------+ +-------------------+ |
| | Provider | IP fwd | Mesh WireGuard | |
| | Interface |-------->| Interface | |
| | (tailscale0 / | | (plexd0) | |
| | wt0 / etc.) | | | |
| +-------------------+ +---------+---------+ |
| ^ | |
| | v |
| +------+------------+ +------------------+ |
| | UserAccessProvider | | Mesh Peers | |
| | (Tailscale/Netbird)| | 10.42.0.0/16 | |
| +---------+----------+ +------------------+ |
| | |
| +---------+---------+ |
| | CommandExecutor | |
| | (CLI invocations) | |
| +-------------------+ |
| |
| UserAccessManager -----> provider.Start() / provider.Stop() |
+-----------------------------------------------------------------+Traffic from external VPN users arrives on the provider's network interface, is forwarded via IP forwarding to the mesh WireGuard interface (plexd0), and reaches mesh peers. The UserAccessProvider manages the provider daemon lifecycle. The CommandExecutor abstracts CLI invocations for testability.
UserAccessProvider Interface
Interface abstracting lifecycle management of external VPN providers (Tailscale, Netbird) used for user access into the mesh via a bridge node. Implementations invoke the provider's CLI or daemon to join an overlay network, then report status so the bridge can route traffic between the provider's interface and the mesh.
type UserAccessProvider interface {
Name() string
Start(ctx context.Context) error
Stop() error
Status() ProviderStatus
}| Method | Description |
|---|---|
Name | Returns the provider identifier (e.g. "tailscale", "netbird") |
Start | Launches the provider daemon/process; reads auth credentials from the environment |
Stop | Gracefully shuts down the provider; idempotent when not running |
Status | Returns the current provider status |
All methods must be safe for concurrent use.
ProviderStatus
Represents the current status of a user access VPN provider.
type ProviderStatus struct {
Running bool `json:"running"`
InterfaceName string `json:"interface_name,omitempty"`
IP string `json:"ip,omitempty"`
Error string `json:"error,omitempty"`
}| Field | Description |
|---|---|
Running | Whether the provider process is active |
InterfaceName | Network interface created by the provider |
IP | IP address assigned by the provider |
Error | Last error message, if any |
CommandExecutor Interface
Shared abstraction for CLI command execution, enabling full unit testing without invoking real binaries.
type CommandExecutor interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
Start(ctx context.Context, name string, args ...string) (CommandHandle, error)
}| Method | Description |
|---|---|
Run | Executes a command synchronously and returns its combined output |
Start | Starts a command without waiting for completion; returns a handle to stop it |
CommandHandle
type CommandHandle interface {
Stop() error
}| Method | Description |
|---|---|
Stop | Terminates the process |
TailscaleProvider
Concrete UserAccessProvider implementation wrapping the Tailscale CLI (tailscale/tailscaled). Joins a Tailscale network on a bridge node, enabling Tailscale users to access mesh resources.
- File:
internal/bridge/tailscale.go - Name:
"tailscale" - Interface name:
"tailscale0"(hardcoded) - Auth key env var:
PLEXD_TAILSCALE_AUTH_KEY - Concurrency:
sync.Mutexprotectsrunning,ip,ifaceName,daemonHandle
Constructor
func NewTailscaleProvider(exec CommandExecutor, logger *slog.Logger) *TailscaleProviderStart Sequence
- Read auth key from
PLEXD_TAILSCALE_AUTH_KEYenvironment variable - Start
tailscaled --state=mem:viaCommandExecutor.Start(long-running daemon) - Run
tailscale up --authkey=<key> --accept-routesviaCommandExecutor.Run - Run
tailscale status --jsonviaCommandExecutor.Runto discover the assigned IP - Parse
TailscaleIPs[0]from the JSON response as the provider IP - Set interface name to
"tailscale0", mark as running
Full rollback on failure at any step:
| Failure Point | Rollback Actions |
|---|---|
tailscale up fails | Stop daemon handle |
tailscale status fails | Run tailscale down, stop daemon handle |
| JSON parse fails | Run tailscale down, stop daemon handle |
Stop Sequence
- Run
tailscale down(disconnect from network) - Stop daemon handle via
CommandHandle.Stop - Clear
running,ip,ifaceName
Idempotent: calling Stop when not running returns nil.
Usage
exec := &prodCommandExecutor{} // production CommandExecutor
provider := bridge.NewTailscaleProvider(exec, logger)
// Start — joins Tailscale network
if err := provider.Start(ctx); err != nil {
log.Fatal(err) // e.g. "bridge: tailscale: PLEXD_TAILSCALE_AUTH_KEY is not set"
}
// Query status
status := provider.Status()
// ProviderStatus{Running: true, InterfaceName: "tailscale0", IP: "100.64.0.1"}
// Graceful shutdown
provider.Stop()NetbirdProvider
Concrete UserAccessProvider implementation wrapping the Netbird CLI (netbird). Joins a Netbird network using a setup key, enabling Netbird users to access mesh resources through the bridge node.
- File:
internal/bridge/netbird.go - Name:
"netbird" - Interface name: discovered from
netbird status --json(e.g."wt0") - Setup key env var:
PLEXD_NETBIRD_SETUP_KEY - Concurrency:
sync.Mutexprotectsstatus
Constructor
func NewNetbirdProvider(exec CommandExecutor, logger *slog.Logger) *NetbirdProviderStart Sequence
- Read setup key from
PLEXD_NETBIRD_SETUP_KEYenvironment variable - Run
netbird up --setup-key <key>viaCommandExecutor.Run - Run
netbird status --jsonviaCommandExecutor.Runto discover IP and interface - Parse IP and interface name from the JSON response
- Strip CIDR prefix from IP if present (e.g.
"100.119.0.1/16"becomes"100.119.0.1") - Mark as running
Stop Sequence
- Run
netbird downviaCommandExecutor.Run - Clear status to zero value
Idempotent: calling Stop when not running returns nil.
Usage
exec := &prodCommandExecutor{} // production CommandExecutor
provider := bridge.NewNetbirdProvider(exec, logger)
// Start — joins Netbird network
if err := provider.Start(ctx); err != nil {
log.Fatal(err) // e.g. "bridge: netbird: PLEXD_NETBIRD_SETUP_KEY environment variable is not set"
}
// Query status
status := provider.Status()
// ProviderStatus{Running: true, InterfaceName: "wt0", IP: "100.119.0.1"}
// Graceful shutdown
provider.Stop()Config Fields
The following fields on the bridge Config struct control VPN provider selection.
| Field | Type | Default | Description |
|---|---|---|---|
UserAccessProviderType | string | "" | External VPN provider: "tailscale", "netbird", or "" (disabled) |
AuthKeyEnv | string | — | Env var name for the auth key (e.g. "PLEXD_TAILSCALE_AUTH_KEY") |
SetupKeyEnv | string | — | Env var name for the setup key (e.g. "PLEXD_NETBIRD_SETUP_KEY") |
cfg := bridge.Config{
Enabled: true,
AccessInterface: "eth1",
AccessSubnets: []string{"10.0.0.0/24"},
UserAccessEnabled: true,
UserAccessProviderType: "tailscale",
AuthKeyEnv: "PLEXD_TAILSCALE_AUTH_KEY",
}
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
log.Fatal(err)
}Validation Rules
Provider validation runs when UserAccessProviderType is non-empty, regardless of other flags.
| Field | Rule | Error Message |
|---|---|---|
UserAccessProviderType | Must be "tailscale", "netbird", or "" | bridge: config: unsupported UserAccessProviderType "<value>" |
Integration with UserAccessManager
The UserAccessProvider integrates into the existing UserAccessManager as an optional dependency.
Constructor
func NewUserAccessManager(ctrl AccessController, routes RouteController, cfg Config, logger *slog.Logger, provider UserAccessProvider) *UserAccessManagerThe provider parameter is optional -- pass nil when no external VPN provider is used.
Setup
When a provider is configured, Setup calls provider.Start() after the WireGuard interface and forwarding are established:
AccessController.CreateInterface(interfaceName, listenPort)-- create WG interfaceRouteController.EnableForwarding(interfaceName, accessInterface)-- enable IP forwardingprovider.Start(ctx)-- launch the VPN provider (only when provider is non-nil)
If provider.Start() fails, full rollback is performed: forwarding is disabled and the interface is removed.
Teardown
When a provider is configured, Teardown calls provider.Stop() first, before removing peers and interfaces:
provider.Stop()-- stop the VPN provider (only when provider is non-nil)- Remove all tracked peers 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.
UserAccessStatus
When a provider is configured, UserAccessStatus() includes provider information in the returned api.UserAccessInfo:
| Field | Source | Values |
|---|---|---|
ProviderName | provider.Name() | "tailscale", "netbird" |
ProviderStatus | Derived from provider.Status() | "running", "error", "stopped" |
Status derivation logic:
status.Running == true=>"running"status.Error != ""=>"error"- Otherwise =>
"stopped"
UserAccessInfo API Type
type UserAccessInfo struct {
Enabled bool `json:"enabled"`
InterfaceName string `json:"interface_name"`
PeerCount int `json:"peer_count"`
ListenPort int `json:"listen_port"`
ProviderName string `json:"provider_name,omitempty"`
ProviderStatus string `json:"provider_status,omitempty"`
}| Field | Description |
|---|---|
ProviderName | Provider identifier; empty when no provider is configured |
ProviderStatus | Provider state: "running", "error", or "stopped"; empty when no provider |
Error Prefixes
| Source | Prefix |
|---|---|
TailscaleProvider.Start (no key) | bridge: tailscale: PLEXD_TAILSCALE_AUTH_KEY is not set |
TailscaleProvider.Start (daemon) | bridge: tailscale: start tailscaled: |
TailscaleProvider.Start (up) | bridge: tailscale: tailscale up: |
TailscaleProvider.Start (status) | bridge: tailscale: tailscale status: |
TailscaleProvider.Start (parse) | bridge: tailscale: parse status: |
NetbirdProvider.Start (no key) | bridge: netbird: PLEXD_NETBIRD_SETUP_KEY environment variable is not set |
NetbirdProvider.Start (up) | bridge: netbird: up failed: |
NetbirdProvider.Start (status) | bridge: netbird: status failed: |
NetbirdProvider.Start (parse) | bridge: netbird: failed to parse status JSON: |
NetbirdProvider.Stop (down) | bridge: netbird: down failed: |
UserAccessManager.Setup (provider) | bridge: user access: start provider: |
UserAccessManager.Teardown (provider) | bridge: user access: stop provider: |
Logging
All log entries use component=bridge.
| Level | Event | Keys |
|---|---|---|
Info | Tailscale provider started | ip, interface |
Info | Tailscale provider stopped | (none) |
Error | Tailscale down failed | error |
Error | Stop tailscaled failed | error |
Info | Starting netbird provider | setup_key_len |
Info | Netbird up completed | output |
Info | Netbird provider started | ip, interface |
Info | Stopping netbird provider | (none) |
Info | Netbird down completed | output |
Info | User access provider started | provider |
Lifecycle Without Provider
cfg := bridge.Config{
Enabled: true,
AccessInterface: "eth1",
AccessSubnets: []string{"10.0.0.0/24"},
UserAccessEnabled: true,
}
cfg.ApplyDefaults()
// No provider — pass nil
accessMgr := bridge.NewUserAccessManager(accessCtrl, routeCtrl, cfg, logger, nil)
// Setup — creates WG interface, enables forwarding (no provider step)
if err := accessMgr.Setup(); err != nil {
log.Fatal(err)
}
// Report status in heartbeat — no provider fields populated
status := accessMgr.UserAccessStatus()
// &api.UserAccessInfo{Enabled: true, InterfaceName: "wg-access", PeerCount: 0, ListenPort: 51822}
// Graceful shutdown
<-ctx.Done()
if err := accessMgr.Teardown(); err != nil {
logger.Warn("user access teardown failed", "error", err)
}Lifecycle With Provider
cfg := bridge.Config{
Enabled: true,
AccessInterface: "eth1",
AccessSubnets: []string{"10.0.0.0/24"},
UserAccessEnabled: true,
UserAccessProviderType: "tailscale",
}
cfg.ApplyDefaults()
// Create provider based on config
var provider bridge.UserAccessProvider
switch cfg.UserAccessProviderType {
case "tailscale":
provider = bridge.NewTailscaleProvider(exec, logger)
case "netbird":
provider = bridge.NewNetbirdProvider(exec, logger)
}
accessMgr := bridge.NewUserAccessManager(accessCtrl, routeCtrl, cfg, logger, provider)
// Setup — creates WG interface, enables forwarding, starts provider
if err := accessMgr.Setup(); err != nil {
log.Fatal(err)
}
// Report status in heartbeat — includes provider fields
status := accessMgr.UserAccessStatus()
// &api.UserAccessInfo{
// Enabled: true, InterfaceName: "wg-access", PeerCount: 0,
// ListenPort: 51822, ProviderName: "tailscale", ProviderStatus: "running",
// }
// Register SSE handlers
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventUserAccessPeerAssigned,
bridge.HandleUserAccessPeerAssigned(accessMgr, logger))
dispatcher.Register(api.EventUserAccessPeerRevoked,
bridge.HandleUserAccessPeerRevoked(accessMgr, logger))
dispatcher.Register(api.EventUserAccessConfigUpdated,
bridge.HandleUserAccessConfigUpdated(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 — stops provider first, then tears down WG interface
<-ctx.Done()
if err := accessMgr.Teardown(); err != nil {
logger.Warn("user access teardown failed", "error", err)
}Heartbeat Reporting
heartbeat := api.HeartbeatRequest{
UserAccess: accessMgr.UserAccessStatus(), // includes ProviderName, ProviderStatus
}Registration Capabilities
caps := accessMgr.UserAccessCapabilities()
// {"user_access": "true", "access_listen_port": "51822"}
// nil when user access is disabled