Pluggable VPN architecture

Pluggable Tailscale, Headscale, and WireGuard.

Pluggable VPN architecture

VPN/mesh management is optional. LAN-only deployments use vpn.provider: "null".

Interface

D interface VpnProvider (source/lindir/vpn/provider.d):

Method Purpose
enrollHost(hostname, tags) Create a mesh auth / preauth key or WireGuard peer; return status
syncGroupAcls(group → members) Merge IdM groups into control-plane ACL / policy / sidecar map
removeHost(hostname) Delete matching device/node/peer from the mesh

Backends

Provider config Implementation Status
null / none NullVpnProvider Done — no-op
tailscale TailscaleVpnProvider Live API when apiKey + tailnet set; otherwise dry-run
headscale HeadscaleVpnProvider Live API when apiBase + apiKey + tailnet (user) set; otherwise dry-run
wireguard WireGuardVpnProvider Local peer registry + config files; optional wg set

Config — Tailscale

"vpn": {
  "provider": "tailscale",
  "apiBase": "https://api.tailscale.com",
  "apiKey": "tskey-api-…",
  "tailnet": "example.ts.net"
}

Create an API access token in the Tailscale admin console with scopes to manage auth keys, devices, and the policy file (ACL). Tags used at enroll (default tag:lindir) must be defined in the ACL tagOwners and allowed for that token / OAuth client.

Hosts already on the mesh do not need a new auth key. Pass skipVpn: true (CLI --skip-vpn, Admin UI checkbox). IdM enroll succeeds even if VPN key creation fails (vpn response may start with error:).

Client join

lindir-client enroll consumes structured vpnDetail from the enroll response:

vpnDetail.kind Client action
auth-key tailscale up --authkey=… (and --login-server=… for Headscale)
wireguard Write wg-quick config (mode 600) and bring the interface up

Auth keys and WireGuard private keys are never written to audit detail (only kind / address).

Config — Headscale

"vpn": {
  "provider": "headscale",
  "apiBase": "https://headscale.example.com",
  "apiKey": "…",
  "tailnet": "lindir"
}
Field Meaning for Headscale
apiBase Headscale base URL ( /api/v1 appended if missing)
apiKey From headscale apikeys create
tailnet Headscale user name (or numeric user id). Created via API if missing

Policy sync requires Headscale policy.mode: database. File-based policy is read-only via the API.

Config — WireGuard

"vpn": {
  "provider": "wireguard",
  "wireguard": {
    "endpoint": "vpn.example.com:51820",
    "iface": "wg0",
    "serverAddress": "10.66.0.1/24",
    "peerNetwork": "10.66.0.0/24",
    "dns": "10.66.0.1",
    "configDir": "/var/lib/lindir/wireguard",
    "serverPrivateKeyPath": "",
    "applyLive": false
  }
}
Field Meaning
endpoint Written into client configs as Endpoint
iface Interface name for generated wg0.conf and optional wg set
serverAddress / peerNetwork Server address and IP pool for peers
configDir Server key, wg0.conf, clients/<host>.conf, groups.json
applyLive If true, run wg set after enroll/remove (needs root + wireguard-tools)

WireGuard has no cloud ACL API: syncGroupAcls writes groups.json and refreshes the server config. Bring the interface up yourself, e.g. wg-quick up /var/lib/lindir/wireguard/wg0.conf.

Live behavior

Tailscale

Call API
Host enroll POST /api/v2/tailnet/{tailnet}/keys — one-shot preauthorized auth key; response vpn field is auth-key:tskey-auth-…
Sync ACLs GET then POST /api/v2/tailnet/{tailnet}/acl with If-Match ETag; merges group:<name> → member emails (falls back to username)
Remove host GET …/devices, match hostname, DELETE /api/v2/device/{id}

Headscale

Call API
Host enroll Resolve/create user → POST /api/v1/preauthkey (user name); returns auth-key:… + vpnDetail.loginServer
Sync ACLs GET /api/v1/policy → merge groups → PUT /api/v1/policy ({"policy":"…"}); seeds a default policy if the DB has none
Remove host GET /api/v1/node, match hostname/givenName/name, DELETE /api/v1/node/{id}

WireGuard

Call Behavior
Host enroll Generate X25519 keypair, allocate IP, store peer, write client + server configs; returns wg-peer:… plus vpnDetail.wireguardConfig (full client conf, one-shot)
Sync ACLs Write groups.json from IdM groups; regenerate server config
Remove host Revoke peer, regenerate config, optional wg set … remove / wg syncconf

applyLive: true brings the interface up with wg-quick when needed and syncs peers with wg syncconf. Generated server conf includes PostUp FORWARD accepts for hub-and-spoke; set net.ipv4.ip_forward=1 on the host (or via the container runtime with --sysctl / privileged).

API

  • Host enroll (POST /v1/hosts/enroll) calls vpn.enrollHost (optional tags array on the body); response includes vpn + optional vpnDetail.
  • POST /v1/vpn/sync-acls pushes group ACL sync.
  • DELETE /v1/hosts/{id} and MDM retire call vpn.removeHost.