Linux client

lindir-client: enroll, sync, PAM, SSH, NSS, and fleet apply.

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.

  1. Bring up lindir-server on the new host with the migrated DB. Set issuerUrl (and TLS) to the new public URL for OIDC/WebAuthn.
  2. On each enrolled machine, point the agent at the new URL — leave hostname, hostId, and enrollmentSecret alone:
# 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
  1. Confirm lastSeenAt updates 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).

  1. Create users/policy on the new server (or accept a clean directory).
  2. Mint an enroll token (Admin → Hosts, or lindir-admin enroll-token create …).
  3. 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.