Pluggable VPN architecture

Pluggable Tailscale, Headscale, WireGuard, and Pangolin.

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
pangolin PangolinVpnProvider Live API when apiKey + pangolin.orgId set; otherwise dry-run

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

Install the client agent before enroll. For Tailscale/Headscale, the host needs the tailscale binary (and a working tailscaled) before lindir-client enroll without --skip-vpn. Enroll only mints and applies an auth key; it does not install the Tailscale package for you. WireGuard needs wg-quick / wireguard-tools; Pangolin needs newt on PATH (or a unit that reads /etc/newt/newt.env).

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
newt Write Newt env (NEWT_ID / NEWT_SECRET / PANGOLIN_ENDPOINT) and start newt

Auth keys, WireGuard private keys, and Newt secrets 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.

Connectivity. Headscale (and Tailscale) fleets often reach each other via DERP relays when there is no L2 adjacency between VMs or sites. Mesh pings succeeding only through DERP is normal in cloud and lab topologies — not a failed enroll. Prefer checking tailscale status / tailscale ping before assuming VPN enroll failed.

Kernel noise. Lean cloud kernels may log IPv6 / xt_mark warnings when Tailscale starts. IPv4 mesh can still be healthy; treat those messages as informational unless IPv4 peers also fail.

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.

Config — Pangolin

Pangolin (fosrl) exposes sites via its Integration API. Lindir enrolls each host as a Newt site and returns join credentials for the Newt agent.

"vpn": {
  "provider": "pangolin",
  "apiBase": "https://api.pangolin.net/v1",
  "apiKey": "…",
  "pangolin": {
    "orgId": "org_…",
    "endpoint": "https://app.pangolin.net"
  }
}
Field Meaning
apiBase Integration API base (default https://api.pangolin.net/v1; self-hosted: https://api.your-domain/v1)
apiKey Organization (or root) API key — Bearer token
pangolin.orgId Organization id used in /org/{orgId}/… routes
pangolin.endpoint Newt --endpoint / PANGOLIN_ENDPOINT (default https://app.pangolin.net)

Create an API key under Organization → API Keys with permission to create/list/delete sites. Self-hosted deployments must enable the Integration API.

syncGroupAcls is a no-op: Pangolin authorizes via roles/users on public and private resources, not Tailscale-style ACL documents. Assign access in the Pangolin dashboard or Integration API after sites exist.

Hosts need the newt binary on PATH (or a newt.service unit that reads /etc/newt/newt.env).

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

Pangolin

Call API
Host enroll Optional delete existing site (same niceId/name) → PUT /org/{orgId}/site with type: "newt"; returns vpnDetail.kind=newt (newtId, newtSecret, newtEndpoint)
Sync ACLs No-op (log only); use Pangolin resource role/user assignments
Remove host Resolve site by niceId or name → DELETE /site/{siteId}

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.