Linux client
lindir-client joins a host to the domain, syncs POSIX accounts/policy, applies fleet package and energy jobs, and hooks into PAM/SSH/sudo.
Build
Needs dub and one of three first-class D compilers — LDC (ldc2), DMD (dmd), or GDC (gdc):
COMPILER="${COMPILER:-ldc2}" # ldc2 | dmd | gdc
# server → ./lindir-server
dub build --compiler="$COMPILER"
# client → ./lindir-client
cd client && dub build --compiler="$COMPILER"
Requirements
Directory accounts are projected into /var/lib/extrausers/ and must be visible to glibc via NSS extrausers. Install the module before enroll/sync (or password login will not see users).
| Host family | Package | Source |
|---|---|---|
| Debian / Ubuntu | libnss-extrausers |
apt (official) |
| CachyOS / Arch / other pacman-based | libnss-extrausers |
AUR (required) — not in official pacman repos |
| Fedora / RHEL-like / openSUSE | libnss-extrausers or equivalent |
Distro package when available; many cloud images lack it |
On Fedora, Rocky/Alma, and some openSUSE images there is often no packaged libnss-extrausers. Build and install the module from the Debian source tarball (or another trusted build of the same NSS module) before enroll — the Podman auth suite and the isolated e2e lab do exactly that. Real CachyOS hosts should use the AUR package, not a Debian-source stand-in.
Install on CachyOS / Arch
From a checkout of the lindir repo (needs dub + LDC, DMD, or GDC for the build step inside install.sh):
# 1) NSS module (AUR — required; not in official repos)
paru -S libnss-extrausers # or: yay -S libnss-extrausers
# 2) Build + install agent, configure-login.sh, and sync timer
# (run from the repo root; optional COMPILER=ldc2|dmd|gdc, else auto-pick)
sudo ./client/packaging/install.sh
# or: COMPILER=dmd sudo -E ./client/packaging/install.sh
# 3) Join the directory (root enroll also runs configure-login)
sudo lindir-client enroll --url https://id.example.com --token "$TOKEN" --skip-vpn
# If the host is already enrolled, wire login stacks once:
# sudo lindir-client configure-login
# 4) Pull accounts / renew offline login cache
sudo lindir-client sync
getent passwd alice
install.sh installs /usr/bin/lindir-client, /usr/lib/lindir/configure-login.sh, and enables lindir-sync.timer. You do not need a separate install of configure-login.sh.
Optional: package via PKGBUILD instead of install.sh — see Packaging (paru -S libnss-extrausers, then client/packaging/cachyos → makepkg -si).
Install (Ubuntu / Debian / Fedora / openSUSE)
# Debian/Ubuntu first:
sudo apt install libnss-extrausers
# from repo root
sudo ./client/packaging/install.sh
This installs /usr/bin/lindir-client, configure-login.sh, and enables lindir-sync.timer (sync every 1 minute after boot). Packaging: Packaging.
Quick start (any distro)
On the server, create a user with a password (admin API). On the host (after the install steps above):
sudo lindir-client enroll --url https://id.example.com --token BOOTSTRAP_OR_API_TOKEN
# already on Tailscale / mesh — skip creating a new auth key:
sudo lindir-client enroll --url https://id.example.com --token TOKEN --skip-vpn
# one-shot enroll token (len_… from Admin → Hosts or lindir-admin enroll-token create):
sudo lindir-client enroll --url https://id.example.com --token len_… --skip-vpn
# lab / self-signed:
sudo lindir-client enroll --url https://id.example.com --token TOKEN --insecure
# custom CA:
sudo lindir-client enroll --url https://id.example.com --token TOKEN --ca-file /etc/lindir/ca.pem
sudo lindir-client sync
getent passwd alice
Root enroll runs configure-login (NSS extrausers, PAM pam-auth / pam-account / pam_mkhomedir, SSH AuthorizedKeysCommand). Re-run anytime:
sudo lindir-client configure-login
http:// still works for local lab. TLS verification is on by default (tlsVerify in client.json); --insecure or "tlsVerify": false skips it. Use --skip-vpn when the host is already on the mesh (lindir still registers IdM; it will not mint a Tailscale/Headscale auth key).
Config lands in /etc/lindir/client.json (mode 600). Policy cache in /var/lib/lindir/cache.json; password hashes for offline PAM in /var/lib/lindir/cache-auth.json (also mode 600).
Without root, enroll writes under ./.lindir/ for local testing — then run configure-login as root after install.
Login cache (sync renews TTL)
Sync refreshes the login cache. Each successful sync renews offline password login for offlineMaxAgeSeconds (default 72 hours). Within that window the server is not required at login time.
| Control | Behavior |
|---|---|
| Sync | Writes hashes + HBAC snapshot; stamps issuedAtUnix (renews the window) |
| Server down / timeout / 5xx / 429 | Authenticate from cache-auth.json if the window is still open |
| Explicit deny online | HTTP 401/403 → no cache fallback |
| Cache TTL | offlineMaxAgeSeconds (default 259200 = 72h); stale → deny |
| Disable offline | "offlineAuthEnabled": false in client config |
| HBAC | Empty rule set allows (same as server); otherwise group+host must match |
| Secrets | Hashes live only in cache-auth.json, not in the main cache |
Keep lindir-sync.timer enabled so hosts renew the window while online.
Integration tests (Podman)
Multi-OS client authentication (NSS, configure-login, sync, getent, online +
offline pam-auth, homes, Arch fish shell) is gated by:
./scripts/integration/auth/run.sh # debian ubuntu fedora opensuse arch rocky
./scripts/integration/auth/run.sh arch # CachyOS stand-in
See scripts/integration/auth/README.md.
What sync installs
| Path | Content |
|---|---|
/var/lib/extrausers/passwd |
Domain users (NSS); on Arch/CachyOS, shell is /usr/bin/fish when fish is installed |
/var/lib/extrausers/group |
Domain groups |
/var/lib/extrausers/shadow |
Placeholder (! — auth via PAM) |
| Home dirs from bundle | Created on sync from /etc/skel if missing; pam_mkhomedir also wires first-login create |
/etc/sudoers.d/lindir |
Sudo rules for this host |
/etc/systemd/logind.conf.d/99-lindir-energy.conf |
Idle sleep / lid (when energy policy assigned) |
/etc/hosts (# BEGIN lindir-hosts …) |
Blocklists / Tailscale maps (when hosts policy assigned) |
/var/lib/lindir/hosts-feeds/<sha256> |
Cached blocklist feed blobs |
/etc/X11/xorg.conf.d/10-lindir-dpms.conf |
Screen blank/off (when set) |
/etc/dconf/db/local.d/00-lindir-energy |
GNOME idle/power (when /etc/dconf exists) |
Sync order (root): IdM bundle → package jobs → … → energy policy → hosts policy → inventory upload → query/script runs. See Fleet.
Host login wiring (configure-login)
Password login needs three pieces: NSS (see accounts), PAM (check passwords), SSH (optional keys). Prefer the automatic path:
# see Requirements above for libnss-extrausers (apt vs AUR)
sudo lindir-client configure-login
sudo lindir-client sync
getent passwd alice
configure-login is idempotent: updates /etc/nsswitch.conf, inserts marked PAM blocks, and writes /etc/ssh/sshd_config.d/60-lindir.conf.
| Family | PAM files touched |
|---|---|
| Debian / Ubuntu | /etc/pam.d/common-auth, common-account (and session for pam_mkhomedir) |
| openSUSE / SLES | Same common-* layout as Debian |
| Fedora / RHEL / Rocky / Alma | /etc/pam.d/system-auth (authselect-managed stacks keep a backup beside the rewrite) |
| Arch / CachyOS | /etc/pam.d/system-auth |
Manual NSS lines (if you prefer not to run the script):
passwd: files extrausers systemd
group: files extrausers systemd
shadow: files extrausers
Online PAM auth hits POST /v1/client/auth (password + HBAC + account policy). Offline fallback only when the server is unreachable or returns a transient error — see Login cache above.
configure-login also adds pam_mkhomedir (session) so first login can create a missing home; sync creates homes from /etc/skel when absent.
SELinux (Fedora / RHEL / Rocky)
configure-login rewrites PAM and nsswitch via mktemp + mv. On Enforcing hosts that leaves the new files as user_tmp_t, and user@.service then fails with “unable to open config for system-auth” / Permission denied. Current configure-login.sh runs restorecon -F on every file it replaces so labels return to etc_t.
If you run an older agent, or edit those files by hand:
sudo restorecon -F /etc/nsswitch.conf /etc/pam.d/system-auth /etc/pam.d/password-auth 2>/dev/null || true
Debian, Ubuntu, and openSUSE (no SELinux by default) are unaffected. Verify after enroll: getent passwd alice and a local su / SSH login as a directory user.
HTTPS
The client uses libcurl (std.net.curl) for both http:// and https://. Certificate verification is enabled by default.
| Setting | Where | Meaning |
|---|---|---|
tlsVerify |
client.json / enroll | Verify peer + hostname (default true) |
tlsCaFile |
client.json / --ca-file |
Custom CA bundle path |
--insecure |
enroll CLI | Sets tlsVerify: false (lab only) |
SSH public keys
configure-login installs this drop-in (or set it yourself in sshd_config):
AuthorizedKeysCommand /usr/bin/lindir-client authorized-keys
AuthorizedKeysCommandUser nobody
Then sudo systemctl reload sshd (or ssh on some distros).
Host API auth
After enroll, the client uses:
Authorization: Host <hostname> <enrollmentSecret>
| Endpoint | Purpose |
|---|---|
GET /v1/client/bundle |
Users, groups, sudo, HBAC snapshot (+ offline hashes) |
POST /v1/client/auth |
Online PAM password + HBAC + account policy |
GET /v1/client/ssh-keys/{user} |
AuthorizedKeysCommand |
POST /v1/client/fleet/inventory |
Inventory snapshot (packageDetails, osCodename, upgradable / upgradableCount, packages) |
GET/POST /v1/client/fleet/packages/jobs… |
Claim / complete package jobs |
GET /v1/client/fleet/energy |
Effective energy policy (applied:false if none) |
GET /v1/client/fleet/certs |
All assigned ACME cert policies (policies[]; includes DNS credentials) |
POST /v1/client/fleet/certs/status |
Report cert expiry / errors (per policyId) |
GET /v1/client/fleet/certs/discover/jobs |
Claim cert discovery jobs |
POST /v1/client/fleet/certs/discover/jobs/{id}/result |
Upload discovery findings JSON |
GET /v1/client/fleet/hosts |
Effective hosts/blocklist policy metadata |
GET /v1/client/fleet/hosts/feeds/{sha256} |
Feed body (text/plain) when cache miss |
GET/POST /v1/client/fleet/runs… |
Query/script jobs |
systemd timer
lindir-sync.timer fires every minute (OnBootSec=1min, OnCalendar=minutely) and starts lindir-sync.service (lindir-client sync). OnCalendar (not OnUnitActiveSec) keeps the timer alive after long syncs — a missed tick while sync is still running is retried after that sync finishes. While a sync is running, systemd will not start another instance. A manual lindir-client sync exits immediately if the exclusive flock under /run/lindir/ is held — the older sync keeps running.
Each sync renews accounts, then claims pending console sessions first (so Admin → Console is not blocked by later steps), then package/fleet/MDM work (including cert discovery jobs then ACME renew-when-due for every cert policy assigned to the host or its labels). The installed-packages inventory upload is throttled to every 20 minutes by default (inventoryIntervalSeconds in client.json; set 0 to upload every sync). Inventory is forced after package jobs, and after a reboot (boot_id change) so reboot-recommended clears on Ubuntu/Debian without waiting for the throttle.
ACME account keys live under /var/lib/lindir/acme/. Issued PEMs go to the paths in each policy (use distinct certPath/keyPath per domain). DNS-01 providers: Gandi, Hostinger, Hetzner, OVH, IONOS, Infomaniak, Scaleway, Cloudflare; HTTP-01 writes under webroot/.well-known/acme-challenge/. Discovery scans /etc/lindir/certs, Let’s Encrypt live dirs, and common web-server SSL paths unless the job supplies custom roots. Full operator guide: Certificates; handbook: Operator handbook §12.
For OIDC apps that require a compliant device, also run lindir-client mdm-broker (see MDM & Compliance). Pending Hosts → Console sessions are claimed on every sync and run as a detached lindir-client console-session PTY worker (see Fleet).
Optional host tools (crontab, apt/pacman/dnf/zypper, wg-quick, loginctl, …) are invoked through a non-throwing spawn helper. Missing binaries fail that job/step with a clear error (and sync continues) instead of aborting the whole sync. Live WireGuard apply on the server does the same for wg / wg-quick / ip.
sudo systemctl enable --now lindir-sync.timer
systemctl status lindir-sync.timer
After reboot: if the timer is enabled (systemctl is-enabled lindir-sync.timer), systemd starts it again automatically. No crontab. Package install scripts (install.sh, .deb postinst, PKGBUILD) enable the timer; a bare binary copy does not — run enable --now once.
Moving clients to a new server
The agent talks to whatever URL is in /etc/lindir/client.json (serverUrl). Machine auth is hostname + enrollmentSecret (not the URL itself). Which cutover you use depends on whether the new server still has those host rows.
A — New server, database migrated (same host secrets)
Use this when you ran lindir-migrate (or restored a backup) so the hosts table still has the same hostname / enrollment secret for each machine.
- Bring up
lindir-serveron the new host with the migrated DB. SetissuerUrl(and TLS) to the new public URL for OIDC/WebAuthn. - On each enrolled machine, point the agent at the new URL — leave
hostname,hostId, andenrollmentSecretalone:
# edit serverUrl (and tlsVerify / tlsCaFile if the cert changed)
sudoedit /etc/lindir/client.json
# or with jq:
sudo jq '.serverUrl = "https://new-id.example.com"' /etc/lindir/client.json \
| sudo tee /etc/lindir/client.json.tmp >/dev/null \
&& sudo mv /etc/lindir/client.json.tmp /etc/lindir/client.json \
&& sudo chmod 600 /etc/lindir/client.json
sudo lindir-client sync
# or wait for lindir-sync.timer
- Confirm
lastSeenAtupdates in Admin → Hosts. Do not re-enroll unless you want enroll to rewrite the file; non-retired re-enroll would return the same secret anyway.
WireGuard peer rows migrate with the DB; if the IdM host also terminates WireGuard, copy /var/lib/lindir/wireguard (or your configured configDir) onto the new server as well.
B — Fresh server (empty directory)
Use this when the new server has no copy of the old hosts table (or you want new enrollment secrets).
- Create users/policy on the new server (or accept a clean directory).
- Mint an enroll token (Admin → Hosts, or
lindir-admin enroll-token create …). - On each host:
sudo lindir-client enroll \
--url https://new-id.example.com \
--token "$TOKEN" \
--skip-vpn # keep existing Tailscale/Headscale/WG mesh membership
# add --ca-file /path/ca.pem or --insecure only if TLS requires it
sudo lindir-client sync
Enroll overwrites /etc/lindir/client.json with the new serverUrl, hostId, and enrollmentSecret. Login stacks usually stay; re-run sudo lindir-client configure-login only if NSS/PAM/SSH wiring is missing.
TLS checklist
| Situation | Action |
|---|---|
| Same CA / public cert for the new hostname | Often only serverUrl changes |
| New private CA | Set tlsCaFile (or enroll with --ca-file) |
| Lab / self-signed | "tlsVerify": false or enroll --insecure |
Updating the agent (lindir-client)
The agent updates itself through the normal package managers (same path as other fleet package jobs):
| Distro | What runs |
|---|---|
| Ubuntu / Debian | apt-get update then apt-get install lindir-client |
| CachyOS | pacman -Sy --noconfirm lindir-client |
| Fedora / RHEL | dnf -y upgrade lindir-client (falls back to install) |
| openSUSE / SLES | zypper --non-interactive install lindir-client |
Queue from Admin UI Software → Packages → Update agent, or:
lindir-admin fleet packages update-client --host-id HOST_UUID
# host picks it up on next timer tick or:
sudo lindir-client sync
Requirement: lindir-client must be published in an apt/pacman/dnf/zypper repo the host already uses. A one-shot install.sh binary drop is not self-updatable until you package it (see Packaging).
The running sync finishes with the old binary in memory; the next timer start (after it exits) uses the new /usr/bin/lindir-client. .deb / PKGBUILD / RPM install should keep lindir-sync.timer enabled.
