Linux Directory Operator Handbook
A practical guide to deploying, operating, and integrating Linux Directory — central identity, host policy, and fleet care for Linux machines.
For operators, SREs, and integrators. Topic guides (OIDC, VPN, LDAP, Fleet, Certificates, and the rest) sit beside this handbook when you need more detail on one surface.
Table of contents
- What Linux Directory is
- Mental model: how everything interacts
- Components and naming
- Install and first boot
- Day-one identity: users, groups, hosts, policy
- Linux hosts: enroll, sync, PAM, SSH, NSS
- Admin surfaces: API, CLI, Web UI
- Connecting apps via OIDC (SSO)
- Passkeys / WebAuthn
- Read-only LDAP gateway
- Optional VPN mesh (Tailscale, Headscale, WireGuard, Pangolin)
- TLS certificates for hosts (ACME)
- Fleet (native)
- Production hardening checklist
- Operations cookbook
- Glossary and further reading
1. What Linux Directory is
1.1 Purpose
Linux Directory is a central identity and host-policy server for Linux fleets. It answers questions operators live with every day:
- Who may log into which machines? (HBAC)
- Which groups get which sudo rules?
- Which SSH public keys are authorized for which users?
- How do web apps get SSO against the same directory? (OIDC)
- How do legacy LDAP clients read accounts without becoming the source of truth? (LDAP gateway)
- How do we install packages, reboot, and set energy policy on fleets? (native fleet)
- How do hosts obtain and renew TLS certificates for public names? (ACME)
- Optionally: how do enrolled hosts join a VPN mesh with group-aware ACLs?
SQL (PostgreSQL or MySQL/MariaDB) is the system of record. The Linux client materializes accounts and policy on each host (extrausers, sudoers, PAM, SSH) and applies fleet jobs (packages, energy, TLS certificates, and more) on sync.
1.2 What the system holds
Linux Directory keeps, in one place:
-
People and hosts — users, groups, enrollment, one-shot join tokens
-
Host access — HBAC, central sudo, SSH public keys
-
Account policy — length, max age, must-change; disable user for account denial; Admin login IP throttle
-
App sign-in — OIDC IdP, passkeys, optional compliant-device gate
-
Fleet care — packages (apt/pacman/dnf/zypper), energy, inventory, labels, queries/scripts, vulnerability findings
-
Device posture — compliance scripts and policies; lock and retire
-
Day-to-day ops — attention dashboard, audit search, Admin API / CLI / Web UI
-
TLS on hosts — ACME policies, DNS-01 / HTTP-01, discovery of existing PEMs
-
Optional VPN — Tailscale, Headscale, WireGuard, or Pangolin plugins
SQL is the system of record; LDAP, when enabled, is a read-only projection. Nextcloud (and similar apps) join as OIDC companions for files and calendar (Chapter 8). Ansible, Landscape, or Foreman may orbit the same estate when you already use them. Chapter 12 covers host TLS certificates; Chapter 13 details the wider fleet surface.
1.3 Platforms
First-class server + client: Ubuntu LTS and CachyOS (Arch-based). First-class client also: Debian (apt), Fedora / RHEL-like (dnf), openSUSE / SLES (zypper).
Further reading: Scope, landscape survey, Distributions.
2. Mental model: how everything interacts
2.1 Big picture
2.2 Trust domains (keep them distinct)
| Actor | How it authenticates | What it can do |
|---|---|---|
| Admin session | HttpOnly cookie after POST /v1/admin/login |
Console API; role from Admin ACL (admin_ro GET-only, admin_rw full) |
| Bootstrap / API token | Authorization: Bearer … |
Break-glass full admin API (treat as admin_rw) |
Enroll token (len_…) |
Authorization: Bearer len_… |
Only POST /v1/hosts/enroll (TTL / maxUses / optional hostname hint) |
| Client API auth | Authorization: Host <hostname> <secret> |
Client API only: bundle, auth, SSH keys, fleet inventory/jobs/energy, console for that host |
| End user (login) | Password or passkey (via OIDC / PAM / LDAP bind) | Prove identity; subject to HBAC on hosts + account policy |
Host local admin is ordinary policy: membership in group lindir-host-admins plus the seeded sudo rule (ALL / host *). HBAC still decides which hosts receive the user. The users.is_admin column is deprecated and ignored for authorization (API isAdmin is derived from that group). Console access requires an admin_acl_bindings row (admin_ro / admin_rw) — not host sudo.
| OIDC relying party | Client id (+ secret or PKCE) | Exchange codes for tokens; never sees user passwords |
A compromised host secret must not grant directory admin. A stolen browser session for /admin is Bearer-scoped — revoke the API token. Treat fleet package/script/energy APIs as root on every targeted host.
2.3 Data flow on a typical day
- Operator creates user
alice, groupadmins, HBAC + sudo rules (Admin UI / CLI / API); optionally sets account policy. - Host
box1runslindir-client enrollwith an admin token or a one-shot enroll token → server stores host + enrollment secret; optional VPN enroll. - Timer runs
lindir-client sync→ pulls bundle → writes extrausers + sudoers + login cache → renews the offline login window (default 72h) → runs pending package jobs → applies energy policy → uploads inventory → runs queries/scripts. - Alice SSHs to
box1→ PAM/pam-auth→ online password + policy + HBAC when the server answers quickly; otherwise PBKDF2 + HBAC from the sync cache within the TTL. - Alice opens Grafana → OIDC redirect → Linux Directory authorize → ID token → Grafana trusts JWKS.
- Optional:
vpn sync-aclspushes group membership into Tailscale/Headscale/WireGuard sidecar map (Pangolin: no-op — use resource roles). - Operator checks Dashboard /
ops attentionfor stale hosts, reboot recommendations, failed jobs, denials.
2.4 What is not pushed to hosts
Hosts do not get a full admin API surface. They get a host-scoped bundle: enabled users allowed by HBAC for that host (with hashes for offline PAM), groups (members filtered to those users), sudo rules for that host (including seeded lindir-host-admins when members are present), and HBAC rules trimmed to the host. Empty HBAC set stays fail-open (all enabled users). Password hashes travel only on the host-authenticated channel.
Further reading: Architecture.
3. Components and naming
| Name | Role |
|---|---|
| Linux Directory | Product name (Admin UI brand, documentation) |
lindir |
Technical short name / package / config namespace (/etc/lindir/) |
lindir-server |
Central daemon: HTTP API, OIDC, optional LDAP, admin SPA |
lindir-admin |
Bearer CLI for ops |
lindir-client |
Host agent: enroll, sync, PAM, authorized-keys |
| Admin Web UI | Vue 3 SPA at /admin/ (static files from adminDistPath) |
openapi.json |
Machine-readable HTTP API (GET /openapi.json) |
Linux Directory is the product name. Binaries, packages, paths, and env vars keep the short name lindir (lindir-server, lindir-admin, lindir-client, /etc/lindir/, …).
| Config path | Purpose |
|---|---|
/etc/lindir/config.json |
Server |
/etc/lindir/client.json |
Client (mode 600) |
/var/lib/lindir/ |
Server state (OIDC key, WireGuard, caches…) |
/var/lib/lindir/cache.json |
Client policy cache |
/var/lib/lindir/cache-auth.json |
Client offline password hashes (600) |
/var/lib/lindir/acme/ |
Client ACME account keys (host TLS) |
/var/lib/extrausers/ |
NSS accounts written by sync |
4. Install and first boot
4.1 Lab (from source)
# Server + CLI + admin UI (first-class: ldc2 | dmd | gdc)
COMPILER="${COMPILER:-ldc2}"
dub build --compiler="$COMPILER"
(cd cli && dub build --compiler="$COMPILER")
(cd admin && rm -rf node_modules && npm ci && npm run build)
# Copy and edit config — start with config.example.json
cp config.example.json config.local.json
# Set a strong bootstrapToken; for lab only keep allowInsecureBootstrap
./lindir-server --config config.local.json --migrations sql/migrations
# → http://127.0.0.1:1289/admin/
# → http://127.0.0.1:1289/health
memory:// as dbDsn is fine for short-lived labs. Move to Postgres or MySQL when persistence matters.
4.2 Production-shaped install
- Provision PostgreSQL or MySQL (Storage). For MySQL/MariaDB, set
max_allowed_packet≥ 64 MiB before fleet vuln refresh / large inventory (Storage). - Build release binaries and admin UI (Packaging).
- Install via
packaging/install-server.sh,.deb, or CachyOSPKGBUILD. - Put TLS in front (Caddy / nginx / Traefik). Set:
issuerUrlto the public HTTPS URL browsers will open for authorizerequireForwardedHttps: truewebauthnRpId/webauthnOriginsto match that hostname
- Ensure PostgreSQL or MySQL is up before (and with)
lindir-server. Packaged units useAfter=for the database, notRequires=— if the DB is down, Admin and OIDC can return empty responses. Prefer a unit drop-in withRequires=/Wants=on your database service, or a process supervisor that starts DB first. - Set a long random
bootstrapToken,allowInsecureBootstrap: false. - Create a long-lived API token via Admin UI / CLI, then stop using the bootstrap token daily.
- After upgrading the Admin SPA, rebuild and redeploy
adminDistPath(staleadmin/distcan look like “token-only” login or missing pages).
4.3 Server config reference
Sample files in the Lindir tree (same keys; differ only in storage and bootstrap hardening):
| File | Use |
|---|---|
config.example.json |
Lab — dbDsn: "memory://" (in-process store; no external DB) |
config.postgres.example.json |
PostgreSQL |
config.mysql.example.json |
MySQL / MariaDB |
dbDsn of memory:// (or memory / empty) always opens the in-memory store; dbEngine is ignored for that DSN. Defaults below match source/lindir/config.d when a key is omitted.
Top-level
| Key | Default | Meaning |
|---|---|---|
listenHost |
127.0.0.1 |
HTTP bind address |
listenPort |
8080 (samples use 1289) |
HTTP bind port |
dbEngine |
postgres |
postgres or mysql / mariadb (when not using memory://) |
dbDsn |
Postgres URL | Connection URL, or memory:// for lab without a DB |
bootstrapToken |
change-me |
Initial admin Bearer secret |
allowInsecureBootstrap |
false |
Allow known-weak bootstrap tokens (labs only) |
rateLimitPerMinute |
120 |
Global API requests per source IP per minute |
adminLoginPerMinute |
10 |
Web Admin password / WebAuthn finish attempts per client IP per minute |
maxBodyBytes |
1048576 |
Max HTTP body size (keep ≥ fleet.maxInventoryBytes) |
exposeErrorDetails |
false |
Include exception text in 500 responses |
requireForwardedHttps |
false |
Require X-Forwarded-Proto: https behind a TLS reverse proxy |
trustedProxies |
[] |
When non-empty, only trust forwarded headers from these client IPs |
issuerUrl |
"" → http://listenHost:listenPort |
Public base URL for OIDC / WebAuthn |
oidcSigningKeyPath |
oidc-rsa.pem |
PEM path for OIDC RS256 signing key (created on first start if missing) |
webauthnRpId |
hostname from issuerUrl / listen |
WebAuthn RP ID (must match browser hostname) |
webauthnRpName |
lindir |
WebAuthn RP display name |
webauthnOrigins |
[issuerUrl] |
Allowed Origins for WebAuthn clientData |
adminDistPath |
admin/dist |
Built Vue SPA directory; empty disables /admin |
ldap
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Start read-only LDAP listener |
listenHost |
127.0.0.1 |
LDAP bind address |
listenPort |
1389 |
LDAP port (non-root default) |
baseDn |
dc=lindir,dc=local |
Directory base DN |
allowAnonymous |
true |
Allow anonymous bind (set false in production) |
fleet
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Native fleet APIs + agent endpoints |
maxInventoryBytes |
1048576 |
Cap for agent inventory POST bodies |
maxOutputBytes |
262144 |
Cap for job stdout/stderr stored / returned |
defaultQueryTimeoutSec |
30 |
Default agent query timeout |
defaultScriptTimeoutSec |
120 |
Default agent script timeout |
defaultPackageTimeoutSec |
1800 |
Default package install/remove/upgrade timeout |
allowFullSystemUpgrade |
true |
Allow upgrade jobs with an empty package list |
maxPackagesPerJob |
50 |
Max packages named in one package job |
archSecurityUrl |
Arch security JSON URL | Upstream for Arch vuln refresh |
ubuntuSecurityBase |
https://ubuntu.com/security |
Ubuntu security API base (no trailing slash) |
debianDsaListUrl |
Debian DSA list URL | Debian advisory rollups |
osvApiBase |
https://api.osv.dev |
OSV API base (RHEL-like / openSUSE) |
vpn
| Key | Default | Meaning |
|---|---|---|
provider |
null |
null | tailscale | headscale | wireguard | pangolin |
apiBase |
"" |
Cloud / Headscale / Pangolin API base URL |
apiKey |
"" |
Provider API token |
tailnet |
"" |
Tailscale tailnet name, or Headscale user name/id |
wireguard.endpoint |
"" |
Client Endpoint (host:port) |
wireguard.iface |
wg0 |
Interface name |
wireguard.serverAddress |
10.66.0.1/24 |
Server address CIDR |
wireguard.peerNetwork |
10.66.0.0/24 |
Peer IP pool |
wireguard.dns |
"" |
DNS pushed to clients |
wireguard.configDir |
wireguard |
Server key, wg0.conf, client confs |
wireguard.serverPrivateKeyPath |
"" → <configDir>/server.key |
Server private key path |
wireguard.applyLive |
false |
Run wg set after enroll/remove |
pangolin.orgId |
"" |
Pangolin organization id |
pangolin.endpoint |
"" |
Newt / Pangolin dashboard base URL |
Migrations under sql/migrations/ apply automatically on start (001 … 012_fleet_energy, …).
Further reading: Storage, Packaging, OWASP Controls, Fleet, VPN, LDAP, OIDC, WebAuthn.
5. Day-one identity: users, groups, hosts, policy
5.1 Recommended order
- Create groups that mirror access roles (
admins,developers,vpn-users). - Create users with passwords (needed for PAM and OIDC password login).
- Add members to groups.
- Enroll hosts (via client or admin enroll).
- HBAC — who may log into which hosts.
- Sudo — which groups get which commands on which hosts.
- SSH keys — attach public keys to users.
- Sync hosts (timer or manual).
- OIDC / VPN / LDAP as needed.
5.2 HBAC semantics (host login)
When a user authenticates on a host (POST /v1/client/auth or PAM), Linux Directory:
- Verifies the password (online).
- Loads the user’s group memberships.
- Evaluates HBAC rules: enabled rules whose group and host sets match.
Empty HBAC rule set → allow (same idea as “no rules yet”). Once you add rules, unmatched logins are denied (hbac_denied).
Design tip: start with a broad “all users → all hosts” rule in lab; tighten before production.
5.3 Sudo and SSH keys
- Sudo rules are synced into
/etc/sudoers.d/lindiron each host for rules that include that host (or*). - SSH keys are not dumped into
authorized_keysfiles by default; useAuthorizedKeysCommand→lindir-client authorized-keysso keys stay central.
5.4 Audit
Admin mutations, host package results, MDM outcomes, and auth events land in audit_events. Browse via Admin UI Audit (filters + last-24h / denials shortcuts; clickable column sort, default When newest first; expand a row for detail and linked package-job stdout/stderr with hostname), CLI lindir-admin audit [--actor …] [--action …] [--since …], or GET /v1/audit?... (results sorted by at descending, then limited). Package-result actors are recorded as host:<hostname> (older rows may still show a host UUID — the UI resolves those when possible). Two independent locks: host anti-theft (locked_until after failed interactive PAM — sshd / greeter / unlock; not sudo) blocks host login only; Web Admin uses per-IP throttle (adminLoginPerMinute, default 10) and ignores host lock. Clear host lock: Users → Unlock / POST /v1/users/unlock / lindir-admin user unlock. Clear Admin throttle: Account policy / POST /v1/admin/login-throttle/clear / lindir-admin admin-login-throttle clear. Full deny: Disable the user.
5.5 Day-to-day operations
| Concern | Where |
|---|---|
| Stale sync / reboot / failed jobs / denials / upgradables / vulns | Admin Dashboard, GET /v1/ops/attention, lindir-admin ops attention |
| Account policy (length, expiry, must-change, disable) | Admin Directory → Account policy / Users, GET/PUT /v1/account-policy, POST /v1/users/enable |
| One-shot join | Admin Hosts enroll tokens (len_…), /v1/enroll-tokens |
| Label-targeted packages/reboot | Fleet UI / labelIds on package jobs |
| Distro upgrades / select or full upgrade | Fleet Upgrades / GET /v1/fleet/upgrades |
| Energy (sleep / screen / lid) | Fleet UI / /v1/fleet/energy/* |
| TLS certificates (ACME, host/label; many per host) | Admin Compliance → Certificates, Chapter 12, Certificates |
| Incident search | Admin Audit (filters, sort, host logins ~24h, CSV export, package job logs) |
Further reading: Admin CLI, Fleet, Certificates, OWASP Controls.
6. Linux hosts: enroll, sync, PAM, SSH, NSS
6.0 Install the client (CachyOS / Arch)
paru -S libnss-extrausers # AUR — required
sudo ./client/packaging/install.sh # from repo root
That installs the agent, configure-login.sh, and lindir-sync.timer. Details: Client. On Debian/Ubuntu: apt install libnss-extrausers then the same install.sh.
6.1 Enroll
On the host (as root), after install:
# bootstrap or API token (ldt_…), or a one-shot enroll token (len_…):
sudo lindir-client enroll --url https://id.example.com --token "$TOKEN"
# lab / self-signed:
sudo lindir-client enroll --url https://id.example.com --token "$TOKEN" --insecure
Create one-shot tokens in Admin Hosts or:
lindir-admin enroll-token create --name lab --ttl-hours 24 --max-uses 1 --hostname-hint box1
This writes /etc/lindir/client.json (URL, hostname, enrollment secret, TLS settings) and registers the host on the server. If a VPN provider is configured, enroll may also create a mesh auth key / WireGuard peer (see Chapter 11).
Moving agents to a new IdM URL: if the database was migrated (same host secrets), edit serverUrl in /etc/lindir/client.json and sync — no re-enroll. On a fresh empty server, re-enroll with a token (--skip-vpn if already on the mesh). Full procedure: Client — Moving clients to a new server.
6.2 Sync
sudo lindir-client sync
# or: systemctl enable --now lindir-sync.timer
Sync (as root) does, in order:
- Pull
/v1/client/bundle→ extrausers + sudoers + offline caches - Claim/run pending package jobs
- Apply energy policy (logind / DPMS / dconf)
- Upload inventory
- Run pending query/script jobs
| Artifact | Path |
|---|---|
| Users / groups | /var/lib/extrausers/passwd, group, shadow |
| Homes | Created on sync from /etc/skel if missing; pam_mkhomedir |
| Shell (Arch) | /usr/bin/fish when fish is installed |
| Sudo | /etc/sudoers.d/lindir |
| Policy cache | /var/lib/lindir/cache.json |
| Offline hashes | /var/lib/lindir/cache-auth.json (TTL renewed each sync) |
| Energy (logind) | /etc/systemd/logind.conf.d/99-lindir-energy.conf |
| Energy (X11) | /etc/X11/xorg.conf.d/10-lindir-dpms.conf |
| Energy (GNOME) | /etc/dconf/db/local.d/00-lindir-energy |
6.3 Host login wiring
As root, enroll runs lindir-client configure-login (also safe to re-run). That wires NSS extrausers, PAM pam-auth / pam-account, and SSH AuthorizedKeysCommand.
Requirement: libnss-extrausers must already be installed — apt on Debian/Ubuntu; AUR on every pacman-based host (paru -S libnss-extrausers). Official Arch repos do not ship it. Fedora / RHEL / openSUSE often need a distro package or a build from Debian source when none is packaged. Verify with getent passwd alice.
On SELinux Enforcing hosts (Fedora, Rocky, …), current configure-login runs restorecon after rewriting PAM/nsswitch. Older agents or hand edits may leave user_tmp_t and break user@.service — see Client.
Extrausers shadow uses a ! placeholder on purpose — password hashes are never written there. Sync refreshes the login cache and renews offline validity for offlineMaxAgeSeconds (default 72 hours); within that window login works with the server down. When the server is reachable, PAM tries online auth first (must-change / host lock / disable); 401/403 never fall back to cache. Interactive login failures can host-lock the user (anti-theft); sudo failures do not; neither locks Web Admin. Details: Client, Architecture.
7. Admin surfaces: API, CLI, Web UI
All three talk to the same Bearer JSON API. The UI suits exploration; the CLI suits scripts; OpenAPI suits integrations.
7.1 Authentication
export LINDIR_URL=https://id.example.com
export LINDIR_TOKEN=… # bootstrap or ldt_… API token
Create durable tokens: Admin UI Platform → API tokens, or lindir-admin / POST /v1/auth/tokens. Revoke compromised ones promptly.
7.2 Web UI
Build once: cd admin && rm -rf node_modules && npm ci && npm run build.
Open https://id.example.com/admin/ — branded Linux Directory. Prefer directory user login (session cookie); emergency Bearer tokens still work via sessionStorage. Redeploy into adminDistPath after every UI upgrade — a stale SPA can look like token-only login while the API is fine (Admin Web UI).
Pages: Dashboard; Directory (Users, Groups, Passkeys, Account policy); Hosts (list, Console, Inventory, Labels, enroll tokens); Host access (HBAC, Sudo, SSH keys); Software (Packages, Upgrades, Apt sources, Vulnerabilities); Compliance (Devices, Policies, Scripts, Energy, Certificates); Automation (Cron, Timers, Oneshots, Services, Systemd, Queries, Scripts, Runs); Platform (API tokens, Admin ACL, OIDC, VPN); Audit.
Dev: Vite on :5173 proxies /v1 and /health to the server (Admin Web UI).
7.3 CLI examples
lindir-admin health
lindir-admin ops attention
lindir-admin user create --username alice --password '…' --email alice@example.com
lindir-admin user disable --user-id UUID
lindir-admin group create --name admins
lindir-admin group add-member --group-id UUID --user-id UUID
lindir-admin group remove-member --group-id UUID --user-id UUID
lindir-admin enroll-token create --name lab --ttl-hours 24 --max-uses 1
lindir-admin account-policy set --min-password-length 12
lindir-admin host enroll --hostname box1 --tag tag:lindir
lindir-admin oidc client create --name grafana --redirect-uri 'https://grafana/…'
lindir-admin vpn sync-acls
lindir-admin fleet packages install --label-id UUID --package curl
lindir-admin fleet upgrades --host-id UUID
lindir-admin fleet vulns refresh --force
lindir-admin fleet vulns scan
lindir-admin fleet energy create --name laptop --idle-action suspend --idle-delay-sec 1800
lindir-admin fleet energy assign --policy-id UUID --label-id UUID
lindir-admin fleet energy unassign --policy-id UUID --label-id UUID
lindir-admin fleet labels assign --host-id UUID --label-id UUID
# Always-on user daemon (linger on by default for add/enable)
lindir-admin fleet service add --host-id UUID --scope user --username nicolay \
--unit myapp --command '/home/nicolay/bin/mydaemon' \
--timer-description 'always-on myapp'
lindir-admin fleet service restart --host-id UUID --scope user --username nicolay --unit myapp
lindir-admin policy hbac check --host-id UUID --group-id UUID
lindir-admin passkey list --user-id UUID
lindir-admin audit --action auth_ok --since '2026-01-01 00:00:00'
7.4 API map (high level)
| Area | Prefix / paths |
|---|---|
| Health / meta | GET /health, GET /v1/info, GET /openapi.json |
| Ops attention | GET /v1/ops/attention |
| Directory | /v1/users, /v1/users/password, /v1/users/enable, /v1/groups, /v1/groups/members, /v1/groups/members/remove, /v1/groups/{id}/members, /v1/hosts |
| Account policy | GET/PUT /v1/account-policy |
| Enroll tokens | /v1/enroll-tokens, /v1/enroll-tokens/revoke |
| Policy | /v1/policy/hbac, /v1/policy/sudo, /v1/policy/ssh-keys |
| Authz tokens | /v1/auth/tokens |
| Fleet | /v1/fleet/labels, /inventory, /upgrades, /packages/jobs (+ ?status=pending, DELETE …/jobs/{id} cancel), /vulns/*, /energy/*, /certs/*, /queries, /scripts, /runs |
| Audit | GET /v1/audit?actor&action&since&… (newest first) |
| OIDC IdP | /oidc/*, /.well-known/openid-configuration |
| OIDC admin | /v1/oidc/clients |
| WebAuthn | /v1/webauthn/* |
| Admin SPA | /admin/ |
Full schema: openapi.json. CLI: Admin CLI. UI: Admin Web UI.
8. Connecting apps via OIDC (SSO)
Linux Directory is the OpenID Connect identity provider. Apps (Grafana, Nextcloud, internal portals, SPAs) redirect users here; they never store Linux Directory passwords.
8.1 Server prerequisites
{
"issuerUrl": "https://id.example.com",
"oidcSigningKeyPath": "/var/lib/lindir/oidc-rsa.pem"
}
- Discovery:
https://id.example.com/.well-known/openid-configuration - JWKS (RS256):
https://id.example.com/oidc/jwks - Authorize / token / userinfo under
/oidc/… - Users need passwords (or passkeys — Chapter 9)
issuerUrl must be what the browser can open for /oidc/authorize. Apps may discover the IdP over a private network, but the advertised authorize URL still has to match a path users can reach (split-horizon DNS, reverse proxy, or host port-forward). Details: OIDC.
8.2 Register a confidential client (server-side apps)
lindir-admin oidc client create \
--name grafana \
--redirect-uri 'https://grafana.example.com/login/generic_oauth'
Save clientId and clientSecret (shown once). In the app:
| Setting | Value |
|---|---|
| Issuer / Auth URL | Discovery or …/oidc/authorize |
| Token URL | …/oidc/token |
| UserInfo | …/oidc/userinfo |
| JWKS | …/oidc/jwks |
| Scopes | openid profile email |
| Auth method | client_secret_basic or client_secret_post |
8.3 Register a public client (SPA / native) with PKCE
lindir-admin oidc client create \
--name spa \
--redirect-uri 'https://app.example.com/cb' \
--public
No client secret. PKCE (S256) is required. Token auth method: none.
8.4 Example: Grafana Generic OAuth
- Create confidential client with Grafana’s callback URL.
- Grafana → Authentication → Generic OAuth:
- Client ID / Secret from Linux Directory
- Auth URL / Token URL / API URL (UserInfo) / JWKS as above
- Scopes:
openid email profile
- Map email/login fields from claims (
email,preferred_username/subas your app expects). - Test with a Linux Directory user that has a password (or passkey on the authorize page).
8.5 Refresh tokens
Token responses include a single-use refresh token (rotated; ~30 day lifetime by default). Apps should treat refresh like a session secret.
8.6 Example: Nextcloud (user_oidc)
Nextcloud is a natural companion for files, calendar, and contacts (optional Mail app). Linux Directory stays the IdP; Nextcloud never stores Linux Directory passwords.
- Create a confidential OIDC client with Nextcloud’s callback. The redirect URI must match exactly what
user_oidcsends — on most installs that includes/index.php:
lindir-admin oidc client create \
--name nextcloud \
--redirect-uri 'https://cloud.example.com/index.php/apps/user_oidc/code'
Without /index.php you typically get invalid_redirect_uri. If an older client used the wrong URI, revoke it and create again, then update the provider’s client id/secret in Nextcloud.
Optional MDM gate: set requireCompliantDevice on the client (Admin UI Platform → OIDC, or API) so login needs a compliant enrolled host + lindir-client mdm-broker — see MDM & Compliance.
-
On Nextcloud, install and enable the user_oidc app.
-
If the IdP discovery URL is on a private or loopback address (lab, RFC1918), allow Nextcloud’s PHP to reach it:
sudo nextcloud.occ config:system:set allow_local_remote_servers --value=true --type=boolean
Without that, discovery fails with “Could not reach provider” for local IdPs.
- Add an OpenID Connect provider pointing at Linux Directory:
| Setting | Value |
|---|---|
| Discovery / Issuer | https://id.example.com/.well-known/openid-configuration |
| Client ID / Secret | From oidc client create |
| Scopes | openid profile email |
| Auth method | client_secret_basic or client_secret_post |
-
Map claims for login / display / email (
preferred_username,email, name /subas your Nextcloud config expects). Soft auto-provision on first login is the usual setup. -
Test browser SSO with a Linux Directory user (password or passkey on the authorize page). Confirm the authorize URL in the browser address bar matches your public
issuerUrl(not a guest-only gateway address).
Clients: CalDAV / CardDAV / desktop sync use Nextcloud app passwords (or session tokens), not the browser OIDC redirect.
Mail: Nextcloud’s Mail app can talk IMAP/SMTP to a separate mailbox stack (Mailcow, Stalwart, …). That mail plane sits outside Linux Directory — register those apps for SSO separately if they support OIDC.
Further reading: OIDC.
9. Passkeys / WebAuthn
Passkeys are registered against Linux Directory as WebAuthn RP (ES256). They can replace passwords on the OIDC authorize page.
9.1 Config
{
"issuerUrl": "https://id.example.com",
"webauthnRpId": "id.example.com",
"webauthnRpName": "Linux Directory",
"webauthnOrigins": ["https://id.example.com"]
}
Requires a secure context (HTTPS or localhost). Behind a reverse proxy, enable requireForwardedHttps.
Open Admin and the OIDC page with a hostname that matches webauthnRpId. An RP ID of localhost does not accept http://127.0.0.1/… — use http://localhost/…, or set RP ID / origins to the host you actually type. See WebAuthn.
9.2 Operator flow
- Admin begins registration for a
userId→ browsercredentials.create. - Finish registration → credential stored.
- User hits OIDC login → Sign in with passkey → short-lived
loginToken→ authorization code.
List/revoke credentials via Admin API (/v1/webauthn/credentials…).
Further reading: WebAuthn.
10. Read-only LDAP gateway
Some apps (NAS panels, older Grafana LDAP auth, lab tools) only speak LDAP. Linux Directory can project SQL users/groups as LDAP without making LDAP writable or authoritative.
10.1 Enable
"ldap": {
"enabled": true,
"listenHost": "127.0.0.1",
"listenPort": 1389,
"baseDn": "dc=lindir,dc=local",
"allowAnonymous": false
}
Tree: ou=people (posixAccount) and ou=groups (posixGroup). Bind with user DN + password, or anonymous if allowAnonymous is true (keep false in production — LDAP / OWASP Controls).
10.2 Connect an app
Point the LDAP client at ldap://127.0.0.1:1389 (or expose via stunnel / a private network when you need an encrypted path).
ldapsearch -H ldap://127.0.0.1:1389 -x -b 'dc=lindir,dc=local' '(uid=alice)'
OIDC suits new apps; use LDAP when the client cannot speak OAuth.
Further reading: LDAP.
11. Optional VPN mesh (Tailscale, Headscale, WireGuard, Pangolin)
VPN is pluggable and optional. Core IdM works with vpn.provider: "null" (LAN-only).
11.1 How VPN hooks into IdM
| Event | VPN action |
|---|---|
| Host enroll | enrollHost → auth/preauth key, WireGuard peer, or Pangolin Newt site |
POST /v1/vpn/sync-acls |
Push IdM groups → mesh ACL / policy / groups.json (Pangolin: no-op) |
| Host remove | removeHost → delete device/peer/site |
Identity code does not import vendor SDKs outside lindir.vpn.*.
11.2 Choosing a provider
| Provider | When to use |
|---|---|
null |
No mesh; LAN or external VPN |
tailscale |
Managed Tailscale tailnet + API ACL merge |
headscale |
Self-hosted coordination; policy.mode: database |
wireguard |
Self-managed peers + config files under configDir |
pangolin |
Pangolin Newt sites via Integration API |
11.3 Connect Tailscale
- Create an API key with auth-key, device, and ACL scopes.
- Config:
"vpn": {
"provider": "tailscale",
"apiBase": "https://api.tailscale.com",
"apiKey": "tskey-api-…",
"tailnet": "example.ts.net"
}
- Enroll hosts with tags allowed by your policy (default
tag:lindir), or use--skip-vpn/skipVpnwhen the device is already on the mesh. - After group changes:
lindir-admin vpn sync-acls.
Without apiKey/tailnet, the provider dry-runs (logs only).
11.4 Connect Headscale
"vpn": {
"provider": "headscale",
"apiBase": "https://headscale.example.com",
"apiKey": "…",
"tailnet": "lindir"
}
tailnet is the Headscale user name. Ensure policy is API-writable.
11.5 Connect WireGuard
"vpn": {
"provider": "wireguard",
"wireguard": {
"endpoint": "vpn.example.com:51820",
"iface": "wg0",
"serverAddress": "10.66.0.1/24",
"peerNetwork": "10.66.0.0/24",
"configDir": "/var/lib/lindir/wireguard",
"applyLive": false
}
}
Enroll writes client + server configs. Bring up with wg-quick yourself unless you enable applyLive (needs root + tools). ACL “sync” writes groups.json for whatever external enforcer you use.
11.6 Connect Pangolin
- Create an organization API key with site create/list/delete permissions (Integration API).
- Install Newt on hosts that will enroll.
- Config:
"vpn": {
"provider": "pangolin",
"apiBase": "https://api.pangolin.net/v1",
"apiKey": "…",
"pangolin": {
"orgId": "org_…",
"endpoint": "https://app.pangolin.net"
}
}
Enroll creates a Newt site named after the hostname and returns vpnDetail.kind=newt. The client writes /etc/newt/newt.env and starts newt (systemd unit when present). Resource access (roles/users) is managed in Pangolin, not via vpn sync-acls.
Further reading: VPN.
12. TLS certificates for hosts (ACME)
Certificates are how a machine proves, to the rest of the network, that it is entitled to speak for a name. They expire on purpose: short lifetimes shrink the damage of a stolen key and make automation necessary. Linux Directory issues and renews host certificates through a native ACME client in lindir-client, driven by policies you define once and assign to hosts or labels.
This chapter is the operator’s path from first policy to a verified PEM on disk. Depth and edge cases: Certificates. Fleet endpoint tables: Fleet.
12.1 How it fits the rest of the system
| Step | What happens |
|---|---|
| Admin creates a cert policy | Domains, challenge (dns-01 or http-01), DNS provider + credential (or HTTP webroot), install paths, optional reload hook |
| Admin assigns host and/or label | Never the whole fleet in one click — targets stay explicit |
| Host sync | Pulls every assigned policy; renews when the on-disk cert is missing or near expiry; reports status |
| Audit | Real ACME issue → fleet.cert_issued; failure → fleet.cert_issue_failed (actor host:…) |
Credentials for DNS APIs are encrypted at rest on the server and revealed only to the host that must perform the challenge.
12.2 DNS-01 and HTTP-01
DNS-01 places a TXT record at _acme-challenge.<name>. Prefer it whenever you can use a supported DNS API: the authority does not need to reach port 80 on the host, and wildcards become possible at the CA when your account and policy allow them. Supported providers: gandi, hostinger, hetzner, ovh, ionos, infomaniak, scaleway, cloudflare.
HTTP-01 writes a token file under webroot/.well-known/acme-challenge/. Use it when the host already publishes that path on port 80 and DNS automation is unavailable.
After DNS updates, public resolvers and the certificate authority’s secondary validators must all see the same TXT. Long-lived leftovers from earlier attempts can confuse that picture; short TTLs on challenge records and patience after a failed run are part of the craft.
12.3 Many certificates on one machine
A host may carry several policies — one domain at Hostinger, another at Cloudflare, separate PEM paths for each. Sync applies them independently. Keep certPath and keyPath distinct so renewals do not overwrite each other. An optional reloadHook (for example systemctl reload nginx) reloads listeners after install.
12.4 Discovery
Compliance → Certificates → Discover on host (or lindir-admin fleet certs discover) queues a scan. The client examines common locations (including Let’s Encrypt live trees) and returns domains, paths, and expiry. Findings already covered by a Lindir policy are marked managed; others can prefill a new policy form.
12.5 Day-to-day commands
lindir-admin fleet certs create --name edge --domain example.com \
--dns-provider hostinger --dns-credential "$TOKEN" \
--cert-path /etc/letsencrypt/live/example.com/fullchain.pem \
--key-path /etc/letsencrypt/live/example.com/privkey.pem \
--reload-hook 'systemctl reload nginx'
lindir-admin fleet certs assign --policy-id UUID --host-id HOST_UUID
lindir-admin fleet certs status
lindir-admin fleet certs discover --host-id HOST_UUID
On the host, after sync:
openssl x509 -in /path/to/fullchain.pem -noout -subject -dates
Staging directory for safe experiments: https://acme-staging-v02.api.letsencrypt.org/directory (acmeDirectoryUrl on the policy).
12.6 What this is not
These policies manage TLS for services on enrolled hosts. Terminating HTTPS for the Lindir server itself remains the job of your reverse proxy and platform TLS — see §14.
Further reading: Certificates, Fleet, Client.
13. Fleet (native)
Built-in fleet — inventory, labels, apt/pacman/dnf/zypper package management, energy policies, shell queries/scripts, TLS certificates (Chapter 12). Details: Fleet.
13.1 Division of responsibility
| Concern | Owner |
|---|---|
| Users, groups, HBAC, sudo, SSH, SSO, account policy | Linux Directory IdM |
| Host inventory, labels, packages, reboot, update-client, energy, TLS certs, cron, systemd timers/oneshots/services/catalog, query/script, vuln findings | Linux Directory fleet (same server) |
| Files / calendar / contacts | Companion (Nextcloud via OIDC — §8.6) |
| Heavy CVE estates / Landscape | Optional companions |
13.2 What works today
- Package management — install / remove / upgrade / refresh / reboot / update-client via apt (Ubuntu/Debian), pacman (CachyOS), dnf (Fedora/RHEL-like), and zypper (openSUSE); add-ppa / enable-ppa / disable-ppa / remove-ppa (purge-revert) on apt hosts; target by
hostIdsand/orlabelIds. - Energy policies — idle sleep, screen blank/off, lid close (logind + X11 DPMS + GNOME dconf); assign to hosts/labels.
- TLS certificates — see Chapter 12 and Certificates.
- Labels (create / assign / HBAC sync).
- Inventory upload on client sync (includes
packageDetails,osCodename/osVersion,upgradable,aptSources,crontabs,systemdTimers,systemdOneshots,systemdServices,systemdUnits, reboot-recommended signals). Admin Software → Upgrades / Apt sources; Automation → Systemd / Cron / Timers / Oneshots / Services; Hosts → Inventory. - Cron — user crontab +
/etc/cron.d/lindir-*add / enable / disable / remove (preserves comments). - Systemd timers — system + user
lindir-*.timer/.service(optional linger for user scope). - Systemd oneshots — login (
default.target) / boot (multi-user.target)lindir-*.service(omit linger for login-only). - Systemd services — always-on
Type=simplelindir-*.service(Restart=on-failure); user scope under~USER/.config/systemd/user/with linger on by default, or systemmulti-user.target. See example below. - Systemd catalog — observe system units + operate jobs (start/stop/restart/…); no unit-file writes (use Timers / Oneshots / Services for
lindir-*). - Vulnerability findings — Ubuntu Security API, Arch AVG, and OSV for Debian/RHEL/SUSE (CachyOS kernel name aliases); Admin Software → Vulnerabilities.
- Queries & scripts queued per host.
- Admin API, CLI, Web UI (Software / Compliance / Automation / Hosts pages).
Example: always-on user service
Install a persistent daemon as Linux user nicolay on a host (Admin Automation → Services, or CLI). The unit becomes lindir-myapp.service; linger keeps the user manager (and the service) up without an interactive login.
# Queue from the admin machine (Bearer token in LINDIR_TOKEN / config)
lindir-admin fleet service add --host-id HOST_UUID \
--scope user --username nicolay \
--unit myapp \
--command '/home/nicolay/bin/mydaemon --config /home/nicolay/.config/myapp.toml' \
--working-directory /home/nicolay/apps/myapp \
--timer-description 'myapp always on'
# On the host (or wait for lindir-sync.timer)
sudo lindir-client sync
# → writes ~nicolay/.config/systemd/user/lindir-myapp.service
# → loginctl enable-linger nicolay
# → systemctl --user -M nicolay@ enable --now lindir-myapp.service
# Later: restart after a binary update
lindir-admin fleet service restart --host-id HOST_UUID \
--scope user --username nicolay --unit myapp
sudo lindir-client sync # on the host
Inventory then lists the unit under systemdServices[] (Admin Automation → Services / Hosts → Inventory). Full reference: Fleet.
13.3 Ops tip
Queue work (package, cron, timer, oneshot, service, systemd operate, energy, or cert policy), then run sudo lindir-client sync on the target — or wait for lindir-sync.timer. Inspect results with:
| What | Endpoint |
|---|---|
| Package jobs | GET /v1/fleet/packages/jobs (?status=pending; cancel DELETE …/jobs/{id}) |
| Cron jobs / inventory | GET /v1/fleet/cron/jobs · GET /v1/fleet/crontabs |
| Timers | GET /v1/fleet/timer/jobs · GET /v1/fleet/timers |
| Oneshots | GET /v1/fleet/oneshot/jobs · GET /v1/fleet/oneshots |
| Services | GET /v1/fleet/service/jobs · GET /v1/fleet/services |
| Systemd catalog / operate | GET /v1/fleet/systemd/jobs · GET /v1/fleet/systemd-units |
| Effective energy on host | GET /v1/client/fleet/energy |
| Cert status | GET /v1/fleet/certs/status |
| Query / script runs | GET /v1/fleet/runs |
| Upgradable packages | GET /v1/fleet/upgrades |
| Vulnerability scan | POST /v1/fleet/vulns/scan |
Admin UI pages under Software, Automation, and Compliance cover the same surfaces without hand-written curl.
14. Production hardening checklist
- Strong
bootstrapToken;allowInsecureBootstrap: false - Postgres or MySQL (not
memory://); server unit waits on / requires the DB - TLS terminator;
issuerUrlHTTPS and browser-reachable;requireForwardedHttps: true - WebAuthn RP ID / origins match the hostname in the URL bar
- Admin SPA rebuilt into
adminDistPathafter UI upgrades - Daily ops use API tokens, not bootstrap; revoke old tokens
- Admin UI / API not exposed without authz at the edge if possible
- LDAP bound to localhost or private net until StartTLS exists
- VPN API keys stored only in server config (
0600) - DNS API credentials for cert policies treated as secrets (encrypted at rest; least privilege on tokens)
- Client:
tlsVerify: true; offline TTL understood; sync timer enabled - HBAC rules reviewed (empty-set allow semantics — see OWASP Controls)
-
exposeErrorDetails: false; rate limits sane;maxBodyBytesadequate for fleet - Fleet: review who holds Bearer tokens (packages/scripts/energy/certs = root on hosts)
- Backups of SQL +
oidcSigningKeyPath(+ WireGuardconfigDirif used) - Plan for audit alerting (logs alone are not enough for A09:2025)
Details: OWASP Controls.
15. Operations cookbook
15.1 Onboarding a developer laptop
- Create user + add to
developers. - Ensure HBAC allows
developerson target hosts. - User sets password (or admin sets it); optional passkey registration.
- Laptop: install client → enroll → sync → NSS/PAM/SSH hooks.
- Optional: OIDC apps already trust Linux Directory — no per-app password.
15.2 Adding a server to the fleet
- Provision OS (Ubuntu/Debian/CachyOS/Fedora/openSUSE — see Distributions).
- Install
lindir-clientpackage / install script. enrollwith API token or one-shot enroll token (len_…).- Confirm VPN key/peer if provider enabled.
- Assign fleet labels / energy / baseline packages / cert policies as needed.
- Verify
getent, SSH key login, sudo rule; check Dashboard for sync age.
15.3 Offboarding a user
- Disable or delete user in Linux Directory (disable preserves audit history).
- Revoke API tokens and WebAuthn credentials.
- Remove from groups; run
vpn sync-aclsif mesh ACLs depend on groups. - Hosts pick up removal on next sync (and HBAC deny online immediately for auth).
15.4 Adding SSO for an application
OIDC is the usual path (Chapter 8). Grafana: §8.4. Nextcloud (files/calendar/contacts): §8.6. Use the LDAP gateway only when the app cannot speak OAuth.
15.5 Login when the server is unreachable
After a successful sync, offline PAM works for offlineMaxAgeSeconds (default 72 hours) — the sync is the cache refresh that renews that window. Explicit denials online (401/403) stay denials. Re-sync while online to renew the window again.
15.6 Health probes
curl -fsS https://id.example.com/health
curl -fsS -H "Authorization: Bearer $TOKEN" https://id.example.com/v1/info
# expect adminUi / oidc / ldap / webauthn flags as configured
15.7 Issuing a TLS certificate for a host name
- Create a cert policy (domains, DNS-01 provider + credential, or HTTP-01 webroot, PEM paths, optional reload hook) — Chapter 12.
- Assign the policy to a host or label (never the whole fleet).
- Sync the host (
sudo lindir-client syncor wait for the timer). - Confirm with
openssl x509 -in … -noout -subject -datesand Admin Certificates → Status. - Prefer Let’s Encrypt staging until the first successful issue, then switch to production.
Depth: Certificates.
16. Glossary and further reading
| Term | Meaning |
|---|---|
| HBAC | Host-based access control — which groups may log into which hosts |
| Bundle | Host-scoped JSON policy+accounts returned by /v1/client/bundle |
| Bearer token | Admin API credential (Authorization: Bearer) |
| Host auth | Authorization: Host <hostname> <secret> |
| Extrausers | NSS module reading /var/lib/extrausers/* |
| IdP | Identity provider (here: Linux Directory’s OIDC stack) |
| RP | OIDC/WebAuthn relying party (the app / browser origin) |
| Fleet | Native device management in Linux Directory (packages, energy, inventory, labels, queries, TLS certs) |
| ACME | Automatic Certificate Management Environment (RFC 8555) — how hosts prove names and receive certificates |
| DNS-01 / HTTP-01 | ACME challenge types: TXT at _acme-challenge vs HTTP file under a webroot |
| Cert policy | Server-side ACME config assigned to hosts/labels; client issues/renews PEMs on sync |
| Linux Directory | Product name; technical short name is lindir |
| Enroll token | Short-lived len_… Bearer limited to host enroll |
| Account policy | Min password length, expiry, must-change, disable-without-delete; Admin login IP throttle |
Document index
| Doc | Content |
|---|---|
| Scope | Current capabilities and boundaries |
| Architecture | Module layout |
| Storage | Postgres / MySQL |
| Client | Host agent |
| Fleet | Packages, energy, inventory, certs |
| Certificates | Host TLS / ACME policies, challenges, discovery |
| MDM & Compliance | Compliance, lock/retire, OIDC device gate |
| Admin CLI / Admin Web UI | CLI / Web UI |
| OIDC / WebAuthn | SSO / passkeys |
| LDAP | Read-only directory projection |
| VPN | Tailscale / Headscale / WireGuard / Pangolin |
| Packaging / Distributions | Install & platforms |
| OWASP Controls | OWASP Top 10:2025 controls + residuals |
| landscape survey | Landscape of Linux IdM systems |
| Lab | E2e VM lab pointer + FreeIPA / Kanidm orientation |
| openapi.json | HTTP API contract |
Linux Directory operator handbook — identity, host policy, fleet care, TLS for host names, and the day-to-day path from first boot to production.
