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) callsvpn.enrollHost(optionaltagsarray on the body); response includesvpn+ optionalvpnDetail. POST /v1/vpn/sync-aclspushes group ACL sync.DELETE /v1/hosts/{id}and MDM retire callvpn.removeHost.
