Native fleet

Packages, energy, inventory, labels, queries, and vulnerabilities.

Native fleet

lindir includes a built-in fleet layer: host inventory, labels, package management, energy policies, remote queries, and scripts. Device management lives in the same SQL control plane as IdM.

Package management

First-class install / remove / upgrade / refresh for supported platforms:

Distro Manager Client backends
Ubuntu (and Debian) apt-get noninteractive, confold, update before install/upgrade
CachyOS (and Arch) pacman --noconfirm, -Sy / -Syu / -R as appropriate
Fedora / RHEL / Rocky / Alma dnf -y install/remove/upgrade; check-update for refresh (yum shim OK)
openSUSE / SLES zypper --non-interactive install/remove/update; refresh for indexes

Actions

Action Packages Behavior
install required Install named packages
remove required Remove named packages (apt-get remove --purge / pacman -R / dnf remove / zypper remove)
upgrade optional Specific packages, or empty list = full system upgrade (apt-get upgrade / pacman -Syu / dnf upgrade / zypper update) if allowFullSystemUpgrade
refresh none Update indexes only (apt-get update / pacman -Sy / dnf check-update / zypper refresh)
reboot none Schedule host reboot a few seconds after the agent reports the job (next sync)
update-client none Upgrade/install the lindir-client package via apt/pacman/dnf/zypper (must be in host repos)
add-ppa required Ubuntu/apt only: Launchpad ppa:owner/name via add-apt-repository -y then apt-get update (needs software-properties-common)
enable-ppa required apt only: re-enable inventoriable source id (.list.disabled → .list, or deb822 Enabled: yes) then apt-get update — no package changes
disable-ppa required apt only: disable inventoriable source id (.list → .list.disabled, or deb822 Enabled: no) then apt-get update — packages stay installed
remove-ppa required apt only: disable inventoriable /etc/apt/sources.list.d/{id} and revert packages from that origin to remaining archive versions. packages[] = source basenames (.list / .sources)

Package names are strictly validated on the server (no shell metacharacters). PPA specs and apt source ids use dedicated validators. The agent never builds shell strings from operator input — it passes argv arrays to apt-get / pacman / dnf / zypper / add-apt-repository.

Queue a reboot

Admin UI Software → Packages → Queue reboot, or:

lindir-admin fleet packages reboot --host-id HOST_UUID
# on the host:
sudo lindir-client sync   # reports job, then schedules reboot (~3s)

Update the agent

Admin UI Software → Packages → Update agent, or:

lindir-admin fleet packages update-client --host-id HOST_UUID

Hosts need lindir-client available from their package repos (not only a manual install.sh binary). After reboot, lindir-sync.timer (if enabled) resumes syncs automatically — see Client.

Flow

  1. Admin queues: POST /v1/fleet/packages/jobs { "action":"install", "hostIds":["…"], "labelIds":["…"], "packages":["nginx"] } (labelIds expand to hosts server-side)
  2. Host on lindir-client sync (as root): claims pending jobs (status → running) → runs apt/pacman/dnf/zypper argv → posts result → refreshes inventory
  3. Inspect: GET /v1/fleet/packages/jobs / {id} or Admin UI Software → Packages (click a row for stdout)
  4. Cancel before claim: DELETE /v1/fleet/packages/jobs/{id} (only pending; running cannot be stopped remotely). List pending: GET /v1/fleet/packages/jobs?status=pending

Claiming prevents a concurrent sync from double-executing the same job. Cancel uses a conditional update so a race with claim cannot resurrect a cancelled job.

lindir-admin fleet packages install --host-id HOST_UUID --package nginx --package curl
lindir-admin fleet packages install --label-id LABEL_UUID --package curl   # all hosts with label
lindir-admin fleet packages upgrade --host-id HOST_UUID          # full upgrade
lindir-admin fleet packages add-ppa --host-id HOST_UUID --package ppa:deadsnakes/ppa
lindir-admin fleet packages disable-ppa --host-id HOST_UUID --package deadsnakes-ubuntu-ppa-jammy.list
lindir-admin fleet packages enable-ppa --host-id HOST_UUID --package deadsnakes-ubuntu-ppa-jammy.list
lindir-admin fleet packages remove-ppa --host-id HOST_UUID --package deadsnakes-ubuntu-ppa-jammy.list
lindir-admin fleet packages list
lindir-admin fleet packages pending
lindir-admin fleet packages cancel --id JOB_UUID
# on the host:
sudo lindir-client sync

Config

"fleet": {
  "enabled": true,
  "defaultPackageTimeoutSec": 1800,
  "allowFullSystemUpgrade": true,
  "maxPackagesPerJob": 50,
  "maxOutputBytes": 262144
}

Set maxBodyBytes ≥ fleet.maxInventoryBytes (example uses 1MiB) so agents can post full package inventories.

Package name rules

Allowed: alphanumerics, -_+.@:/ (Debian multiarch pkg:amd64, Arch repo/pkg). Rejected: spaces, shell metacharacters, leading -//, ...

Energy policies

Named power profiles (idle sleep, screen blank/off, lid close) assigned to hosts and/or labels. Host assignment wins over label.

On sync the agent writes:

Mechanism Path Controls
systemd-logind /etc/systemd/logind.conf.d/99-lindir-energy.conf IdleAction / IdleActionSec, HandleLidSwitch*
X11 DPMS /etc/X11/xorg.conf.d/10-lindir-dpms.conf BlankTime / OffTime (minutes)
GNOME dconf /etc/dconf/db/local.d/00-lindir-energy session idle-delay + power plugin timeouts
lindir-admin fleet energy create --name laptop \
  --idle-action suspend --idle-delay-sec 1800 \
  --screen-blank-sec 600 --screen-off-sec 900 --lid-close suspend
lindir-admin fleet energy assign --policy-id UUID --label-id LABEL_UUID
lindir-admin fleet energy unassign --policy-id UUID --label-id LABEL_UUID
lindir-admin fleet labels assign --host-id UUID --label-id LABEL_UUID
lindir-admin fleet labels hosts --label-id LABEL_UUID
# on hosts:
sudo lindir-client sync

Admin UI: Compliance → Energy. API: /v1/fleet/energy/policies, PUT/DELETE …/policies/{id}, /v1/fleet/energy/assign, /unassign, …/targets, client GET /v1/client/fleet/energy.

Fields: idleAction (ignore|suspend|hibernate|lock|poweroff), idleDelaySec, screenBlankSec, screenOffSec, lidClose, lidCloseDocked. Seconds 0 = never / unmanaged for that setting. Host assignment wins over label (newest label policy if several).

CachyOS does not leave a reboot marker file — the desktop UI comes from the pacman hook cachyos-reboot-required (D-Bus notification only).

lindir detects reboot-needed as follows:

Platform Signals
Ubuntu/Debian /run/reboot-required (+ optional .pkgs)
CachyOS/Arch Hook message in job output (“Reboot is recommended…”); same package targets as cachyos-reboot-required.hook with the hook’s running-kernel heuristics; missing /usr/lib/modules/$(uname -r) after a kernel upgrade
Fedora/RHEL needs-restarting -r when dnf-utils / yum-utils is installed
openSUSE/SLES zypper needs-rebooting (exit 102) and /run/reboot-needed

Results are stored on the package job (rebootRequired / rebootReason) and on each inventory snapshot so the Admin UI can show host-level status. Reboot itself is not performed automatically.

The agent also writes a boot-id sticky mark (reboot-required next to cachePath, default /var/lib/lindir/reboot-required) when live OS signals or a package job recommend reboot. After any reboot (manual or fleet reboot job), the next sync sees a new /proc/sys/kernel/random/boot_id, clears the mark, and inventory reports rebootRequired: false (unless the OS still signals reboot-needed). Inventory includes bootId for debugging.

Other fleet features

Piece Role
Labels Tags on hosts (manual assign/unassign or HBAC sync); used for package + energy targeting; Admin Hosts → Labels, GET …/labels/{id}/hosts
Inventory JSON snapshot each sync (OS, packageDetails, upgradable / upgradableCount, aptSources, crontabs, systemdTimers, systemdOneshots, systemdServices, systemdUnits, scheduler, osCodename, memory, rebootRequired, bootId). Package enumeration follows detectPackageManager() (distro-native), so an empty /usr/bin/rpm on Arch/CachyOS does not zero out pacman -Q. upgradableCount is the true total even if the detail array is capped
Apt sources Flattened GET /v1/fleet/apt-sources (Admin Software → Apt sources, CLI fleet apt-sources); enable / disable / remove (remove-ppa = disable + package revert)
Cron User crontab -u + /etc/cron.d/lindir-* — see Cron
Timers System + user lindir-*.timer / .service — see Timers
Oneshots Login/boot lindir-*.service oneshots — see Oneshots
Services Always-on Type=simple lindir-*.service — see Services
Systemd System unit catalog + operate jobs — see Systemd catalog
Upgrades Packages with newer versions in local distro indexes; queue selected packages or full system upgrade from Admin
Vulnerabilities Distro-aware findings (Ubuntu Security API, Arch AVG, OSV for Debian/RHEL/SUSE); see below
Queries / scripts Generic shell jobs (admin-trusted; separate from package mgr)
Ops attention GET /v1/ops/attention — stale sync, reboot needed, failed jobs, denials, upgradable counts, open vulns (Admin Dashboard)
lindir-admin fleet inventory
lindir-admin fleet upgrades                 # all hosts
lindir-admin fleet upgrades --host-id UUID
lindir-admin fleet apt-sources              # sources.list.d / PPAs from inventory
lindir-admin fleet apt-sources --host-id UUID

Admin UI: Software → Upgrades (/fleet/upgrades, optional ?hostId=). Upgradable counts on Inventory and the Dashboard link to that host’s upgrades. Queue upgrade all enqueues a full system upgrade (packages: [] → apt-get upgrade / pacman -Syu / dnf upgrade / zypper update); checkboxes + Queue selected upgrade named packages (batched at maxPackagesPerJob, default 50). Data comes from the last inventory sync (apt list --upgradable / dnf check-update / pacman -Qu / zypper list-updates); it does not refresh mirrors — queue a refresh package job first if indexes are stale.

Cron

Imperative jobs (same claim/result shape as packages), not desired-state overwrite of entire crontabs.

Surface What lindir touches
User Enrolled usernames that exist on the host via crontab -u USER
System Only /etc/cron.d/lindir-<name> (mode 0644, root:root)

Does not edit /etc/crontab, foreign /etc/cron.d/*, or anacron scripts. Systemd timers are managed separately under Timers.

Ops: add / remove / enable / disable. Round-trip parse preserves blank lines, # comments, env lines, and jobs that were not targeted. Disable rewrites a job to # lindir:disabled <original line>; enable reverses only that marker — plain # comment lines are never treated as disabled jobs. Missing entryId → soft success note (like a missing apt source).

Requires root lindir-client sync (timer or manual). Inventory uploads crontabs[] with stable entry fingerprints (id), plus scheduler.cron / scheduler.systemdTimers availability (userAvailable / systemAvailable / available + reasons) so the Admin UI can show hosts missing crontab or systemctl without failing the whole sync.

lindir-admin fleet crontabs [--host-id UUID]
lindir-admin fleet cron list|pending|cancel --id JOB
lindir-admin fleet cron add --host-id UUID --scope user --username alice \
  --schedule '0 * * * *' --command '/usr/local/bin/job.sh' --comment 'hourly'
lindir-admin fleet cron add --host-id UUID --scope system --cron-d backup \
  --schedule '@daily' --command '/usr/local/bin/backup.sh' --user-field root
lindir-admin fleet cron disable --host-id UUID --scope user --username alice --entry-id HEX

Admin UI: Automation → Cron (/fleet/cron, optional ?hostId=). API: GET /v1/fleet/crontabs, POST|GET|DELETE /v1/fleet/cron/jobs. Inventory list exposes flattened cronUserAvailable / systemdTimersAvailable (etc.).

Systemd timers

Imperative jobs (same claim/result shape as cron/packages). Only lindir-<name>.timer + matching .service oneshot units.

Scope Units
system /etc/systemd/system/lindir-*.timer + .service (root)
user ~USER/.config/systemd/user/lindir-*.{timer,service} via systemctl --user -M USER@; optional linger (loginctl enable-linger) so timers run without an interactive session

Does not touch foreign units, path timers, or non-lindir-* names. add writes both unit files, daemon-reload, and enable --now. disable / enable / remove operate on the timer unit; remove deletes both files.

lindir-admin fleet timers [--host-id UUID]
lindir-admin fleet timer list|pending|cancel --id JOB
lindir-admin fleet timer add --host-id UUID --scope system --unit backup \
  --on-calendar daily --command '/usr/local/bin/backup.sh' --timer-description nightly
lindir-admin fleet timer add --host-id UUID --scope user --username alice --unit mail \
  --on-calendar hourly --command '/usr/bin/true' --linger
lindir-admin fleet timer disable --host-id UUID --scope system --unit backup

Admin UI: Automation → Timers (/fleet/timers). API: GET /v1/fleet/timers, POST|GET|DELETE /v1/fleet/timer/jobs. Inventory includes systemdTimers[] plus capability flags.

Systemd oneshots

Imperative jobs for Type=oneshot lindir-*.service units (no .timer). Distinct from calendar timers — use unique unit names (refuses names that already have a .timer).

Scope WantedBy When it runs
user default.target User session start / login (omit linger for login-only)
system multi-user.target Boot

Optional linger on user scope starts the user manager at boot (not login-only). add writes the unit, daemon-reload, and enable --now (runs once immediately as well as for future logins/boots). Timer companion .service files are ignored in inventory and refused for mutate.

lindir-admin fleet oneshots [--host-id UUID]
lindir-admin fleet oneshot list|pending|cancel --id JOB
lindir-admin fleet oneshot add --host-id UUID --scope user --username alice \
  --unit login-setup --command '/usr/local/bin/on-login.sh' \
  --timer-description 'setup at login'
# omit --linger for true login-only
lindir-admin fleet oneshot add --host-id UUID --scope system --unit boot-setup \
  --command '/usr/local/bin/on-boot.sh'

Admin UI: Automation → Oneshots (/fleet/oneshots). API: GET /v1/fleet/oneshots, POST|GET|DELETE /v1/fleet/oneshot/jobs. Inventory: systemdOneshots[].

Systemd services

Imperative jobs for persistent Type=simple lindir-*.service units (Restart=on-failure). Distinct from oneshots and calendar timers — use unique unit names (refuses sibling .timer or oneshot-marked units).

Scope WantedBy Notes
user default.target Under ~USER/.config/systemd/user/ via systemctl --user -M USER@; linger defaults on so the daemon stays up without an interactive login
system multi-user.target /etc/systemd/system/

add writes the unit, optional loginctl enable-linger, daemon-reload, and enable --now. Extra action restart reloads a running managed service.

lindir-admin fleet services [--host-id UUID]
lindir-admin fleet service list|pending|cancel --id JOB
lindir-admin fleet service add --host-id UUID --scope user --username nicolay \
  --unit myapp --command '/home/nicolay/bin/mydaemon' \
  --timer-description 'always on'
lindir-admin fleet service restart --host-id UUID --scope user --username nicolay --unit myapp
lindir-admin fleet service add --host-id UUID --scope system --unit mydaemon \
  --command '/usr/local/bin/mydaemon'

Admin UI: Automation → Services (/fleet/services). API: GET /v1/fleet/services, POST|GET|DELETE /v1/fleet/service/jobs. Inventory: systemdServices[].

Systemd catalog

Observe + operate for the system manager (PID 1). Not desired-state: inventory lists units; jobs run systemctl lifecycle ops. Does not write unit files (use Automation → Timers / Oneshots / Services for lindir-*).

Inventory: each sync merges list-unit-files + list-units --all into systemdUnits[] (cap ~1500): unit, type, loadState, activeState, subState, unitFileState, description.

Ops: start | stop | restart | reload | enable | disable | mask | unmask. Denylist blocks dbus, systemd-logind/journald/udevd/networkd/resolved, and lindir-sync.* / lindir-client.service.

lindir-admin fleet systemd-units [--host-id UUID]
lindir-admin fleet systemd list|pending|cancel --id JOB
lindir-admin fleet systemd restart --host-id UUID --unit nginx.service
lindir-admin fleet systemd enable --label-id UUID --unit cups.service

Admin UI: Automation → Systemd (/fleet/systemd). API: GET /v1/fleet/systemd-units, POST|GET|DELETE /v1/fleet/systemd/jobs.

Vulnerabilities

Findings use distro security data, rolled up to advisories / security updates (one UI row per advisory when a DSA/AVG/USN covers many CVEs):

Distro Feed Match rule
Ubuntu Ubuntu Security API per source package Host osId=ubuntu (or Ubuntu osCodename) + package version vs release status; rolled up by package + fixed version (USN id when present). Agents report osFamily=debian with osId=ubuntu.
Debian Debian DSA list One finding per DSA when installed source package < suite fixed version (e.g. linux on trixie < 6.12.111-1 → DSA-6528-1). DSA is used ahead of OSV /v1/query, which under-reports fresh kernel advisories.
Arch / CachyOS Arch AVG Pacman-style vercmp; one finding per AVG (CVE count in detail). CachyOS kernels aliased to linux / linux-lts / …
Fedora / RHEL / Rocky / Alma OSV (Red Hat / Rocky Linux / AlmaLinux) Per package+version; rolled up to one finding per package (RHSA id when present)
openSUSE / SLES OSV (openSUSE) Per package+version; rolled up (SUSE-SU id when present)

Inventory must include packageDetails and (for Ubuntu) osCodename / (for Debian) osCodename or osVersion — collected on each agent sync.

lindir-admin fleet vulns refresh           # background: Arch/Ubuntu/Debian-DSA/OSV feeds if cache older than 1 day
lindir-admin fleet vulns refresh --force   # bypass daily TTL
lindir-admin fleet vulns scan              # background: match inventory against cache only (no HTTP)
lindir-admin fleet vulns job               # poll status (idle|running|done|error)
lindir-admin fleet vulns list [--host-id ID]

Admin UI: Software → Vulnerabilities (severity filter + sort; polls job status; Force refresh bypasses the daily TTL). Dashboard ops attention includes openVulns. From findings, operators queue work under Packages or Upgrades.

Arch / CachyOS: matching uses Arch AVG package names. CachyOS kernels (linux-cachyos*) are aliased to linux / linux-lts / … Zero findings are expected when installed versions already meet AVG fixed (or are newer than affected on open advisories). Scan fails loudly if Arch hosts exist but the AVG cache is empty — run Refresh first.

Caching: Scan reads only fleet_vuln_cache (one bulk load per job; payloads stay compressed until a feed key is used). Outbound Arch AVG / Ubuntu Security / Debian DSA / OSV API calls happen on Refresh, and at most once per day per feed (arch_feed_at / ubuntu_feed_at / debian_dsa_feed_at / osv_feed_at). Pass "force": true on refresh (CLI --force / UI Force refresh) to bypass the TTL. Ubuntu Security API pages at limit=20 (API max); a failed Ubuntu refresh does not stamp ubuntu_feed_at so retries are not blocked for a day. Refresh runs in a background thread with its own DB connection.

MySQL/MariaDB: large cache inserts need max_allowed_packet ≥ 64 MiB on the DB server — see Storage. Lindir does not raise this at connect time.

Config overrides (optional):

"fleet": {
  "archSecurityUrl": "https://security.archlinux.org/issues/all.json",
  "ubuntuSecurityBase": "https://ubuntu.com/security",
  "debianDsaListUrl": "https://salsa.debian.org/security-tracker-team/security-tracker/-/raw/master/data/DSA/list",
  "osvApiBase": "https://api.osv.dev"
}

Agent sync order

  1. IdM bundle (users/sudo)
  2. Package jobs
  3. Energy policy (logind / DPMS / dconf; clears drop-ins if no policy)
  4. Inventory upload (reflects new packages + reboot signals)
  5. Generic query/script runs
  6. MDM compliance report + lock/retire actions (see MDM & Compliance)
  7. Console — claim pending interactive sessions and spawn a detached console-session PTY worker

Host console (interactive shell)

Admin Hosts → Console (or a per-host Console link) opens an xterm.js session over a WebSocket on lindir-server. The enrolled agent claims the session on the next lindir-client sync and runs a detached lindir-client console-session process that relays a root PTY.

Step What happens
1 Admin POST /v1/hosts/{id}/console → session pending
2 Browser connects GET /v1/console/{id}/ws?ticket=… (short-lived single-use ticket from create; not the admin Bearer). Non-browser clients may use Authorization: Bearer on the upgrade.
3 Host sync: GET /v1/client/console/pending →