A self-hosted web app for managing Linux package updates across multiple servers. Connect over SSH, check for updates, and apply maintenance from one browser dashboard.
- Multi-distribution updates: APT, DNF, YUM, Pacman, APK, Flatpak, Snap, deb-get, and custom package managers.
- SSH credential vault: reusable password, key, and OpenSSH certificate credentials encrypted at rest with AES-256-GCM.
- Automatic discovery: package managers, OS metadata, installed package inventory, system info, reboot state, and distribution lifecycle status.
- Repository sources: view each system's APT, DNF/YUM, Pacman, APK, and Flatpak repository files with credentials masked, and optionally edit them through
sudoeditwith a diff review and activity history. - Granular maintenance: refresh, upgrade all, priority-based Upgrade All batches, selected-package upgrades, full upgrades, autoremove, cancellation, and remote reboot.
- Dashboard organization: create collapsible groups, arrange systems for a clear fleet overview, display per-group health badges, and set explicit Upgrade All priorities.
- Per-system controls: hidden systems, system duplication, default Upgrade All preselection, package-manager toggles/config, APT kept-back auto-hide, script overrides, ProxyJump, and host-key trust approval.
- Script customization: inspect built-in SSH command scripts, copy them into editable custom scripts, define parser settings, import/export custom package managers, and assign overrides per system.
- Scheduling: cron-based refresh, update, autoremove, reboot (optionally only when required), and notification schedules with scoped systems, cache rules, priority-based Upgrade All waves, and schedule run history.
- Notifications: Email/SMTP, Gotify, MQTT, ntfy.sh, Telegram, and Webhook channels with event filters, system scope, immediate or scheduled delivery, test sends, and encrypted secrets.
- Home Assistant MQTT: app and per-system update entities with discovery, retained state/attributes, rich metadata, images, and optional install commands.
- Authentication: password login with optional TOTP, passkeys/WebAuthn, OpenID Connect SSO, and API tokens for external integrations.
- Localized UI: Arabic, English, German, French, Hindi, Japanese, Portuguese, Spanish, Russian, and Chinese with browser-language detection.
- Branding: replace the application name, light and dark mode logos, and colors shown in the sidebar, browser tab, sign-in page, and authenticator apps from Settings > Branding. Accent color and neutral tone can be set separately for light and dark mode, with a live preview and a low-contrast warning.
- Operator-friendly UI: dark/light mode, activity history, real-time command output, dashboard status summaries, version/build info, and Docker-ready production packaging.
Caution
Use on trusted networks only. Do not expose Linux Update Dashboard directly to the internet. For remote access, put it behind TLS, a hardened reverse proxy, authentication, and network-level controls such as VPN or firewall rules.
Important
HTTPS is recommended. Plain HTTP works for basic local dashboard use, but browsers restrict secure-context features such as passkeys/WebAuthn and clipboard actions. Behind a reverse proxy, set LUDASH_BASE_URL to the public https://... URL and enable LUDASH_TRUST_PROXY=true.
Prerequisites:
- Node.js 24.21.0 LTS
- pnpm 12.9.1 through Corepack or global install
- SSH access to at least one Linux server
git clone https://github.com/TheDuffman85/linux-update-dashboard.git
cd linux-update-dashboard
corepack enable
corepack prepare pnpm@12.9.1 --activate
pnpm install
export LUDASH_ENCRYPTION_KEY=$(openssl rand -base64 32)
pnpm run devThe Vite frontend runs on http://localhost:5173 and proxies API calls to the backend on port 3001. The first visit guides you through creating the admin account.
Production from source:
pnpm run build
NODE_ENV=production pnpm run startThe production server serves the API and built frontend on port 3001.
Generate an encryption key once and persist it. Changing LUDASH_ENCRYPTION_KEY later makes existing encrypted credentials unreadable.
export LUDASH_ENCRYPTION_KEY=$(openssl rand -base64 32)
export LUDASH_BASE_URL=http://localhost:3001
docker run -d \
--name linux-update-dashboard \
-p 3001:3001 \
-e LUDASH_ENCRYPTION_KEY=$LUDASH_ENCRYPTION_KEY \
-e LUDASH_BASE_URL=$LUDASH_BASE_URL \
-v ludash_data:/data \
ghcr.io/theduffman85/linux-update-dashboard:latestSet LUDASH_BASE_URL to the URL users and integrations actually use. Behind a reverse proxy, set the public HTTPS URL and add LUDASH_TRUST_PROXY=true.
The API applies a Content Security Policy, limits request bodies to 1 MiB, rejects cross-origin browser mutations, and returns sensitive API responses with private, no-store. Command-line clients that do not send browser Origin or Sec-Fetch-Site headers remain supported. Configure HSTS at the TLS-terminating proxy when appropriate; the application does not set it itself.
services:
dashboard:
image: ghcr.io/theduffman85/linux-update-dashboard:latest
container_name: linux-update-dashboard
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- dashboard_data:/data
environment:
- LUDASH_ENCRYPTION_KEY=${LUDASH_ENCRYPTION_KEY}
- LUDASH_DB_PATH=/data/dashboard.db
- LUDASH_BASE_URL=http://localhost:3001
- TZ=Europe/Berlin
- NODE_ENV=production
# Reverse proxy:
# - LUDASH_BASE_URL=https://dashboard.example.com
# - LUDASH_TRUST_PROXY=true
volumes:
dashboard_data:Docker Secrets are supported by setting LUDASH_ENCRYPTION_KEY_FILE instead of LUDASH_ENCRYPTION_KEY:
services:
dashboard:
image: ghcr.io/theduffman85/linux-update-dashboard:latest
container_name: linux-update-dashboard
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- dashboard_data:/data
environment:
- LUDASH_ENCRYPTION_KEY_FILE=/run/secrets/ludash_encryption_key
- LUDASH_DB_PATH=/data/dashboard.db
- LUDASH_BASE_URL=http://localhost:3001
- NODE_ENV=production
secrets:
- ludash_encryption_key
secrets:
ludash_encryption_key:
file: ./secrets/ludash_encryption_key.txt
volumes:
dashboard_data:Create the secret before starting:
mkdir -p ./secrets
openssl rand -base64 32 > ./secrets/ludash_encryption_key.txt
docker compose up -dFor plain docker run, mount the secret file read-only and set LUDASH_ENCRYPTION_KEY_FILE=/run/secrets/ludash_encryption_key.
The repository also includes docker/docker-compose.yml for local image builds:
cd docker
export LUDASH_ENCRYPTION_KEY=$(openssl rand -base64 32)
export LUDASH_BASE_URL=http://localhost:3001
export TZ=Europe/Berlin
docker compose up -dThe image includes a health check for GET /api/health; loopback requests do not require auth, external requests do. The app returns {"status":"ok"} when healthy.
| Variable | Required | Default | Description |
|---|---|---|---|
LUDASH_ENCRYPTION_KEY |
Yes | - | Secret used for stored credential and notification-secret encryption. Raw 32-byte base64 keys are used directly; other values are derived with PBKDF2-SHA256. |
LUDASH_ENCRYPTION_KEY_FILE |
No | - | Docker Secrets/file alternative for LUDASH_ENCRYPTION_KEY. Do not set both. |
LUDASH_DB_PATH |
No | ./data/dashboard.db |
SQLite database path. Docker images default this to /data/dashboard.db. |
LUDASH_SECRET_KEY |
No | auto-generated | JWT session signing secret, persisted to .secret_key next to the database when omitted. |
LUDASH_SECRET_KEY_FILE |
No | auto-generated | File alternative for LUDASH_SECRET_KEY. Do not set both. |
LUDASH_PORT |
No | 3001 |
HTTP server port. |
LUDASH_HOST |
No | 0.0.0.0 |
HTTP bind address. |
LUDASH_BASE_URL |
Recommended | http://localhost:3001 |
Public URL used for WebAuthn/OIDC and integration URLs such as Home Assistant entity_picture/origin.url. |
LUDASH_TRUST_PROXY |
No | false |
Trust X-Forwarded-* headers behind a reverse proxy. |
TZ |
No | UTC |
IANA timezone for the container/process, UI time display, and cron schedules. |
LUDASH_LOG_LEVEL |
No | info |
debug, info, warn, or error. Debug adds SSH diagnostics and scheduler refresh logs. |
LUDASH_DEFAULT_CACHE_HOURS |
No | 12 |
Startup default for cache reuse hours. User settings can override runtime behavior. |
LUDASH_DEFAULT_SSH_TIMEOUT |
No | 30 |
Startup default SSH connect timeout in seconds. |
LUDASH_DEFAULT_CMD_TIMEOUT |
No | 120 |
Startup default SSH command timeout in seconds. |
LUDASH_MAX_SSH_TIMEOUT |
No | 120 |
Maximum SSH timeout accepted by settings/API. |
LUDASH_MAX_CMD_TIMEOUT |
No | 600 |
Maximum command timeout accepted by settings/API. |
LUDASH_MAX_CONCURRENT_CONNECTIONS |
No | 5 |
Startup default for simultaneous SSH connections. User settings can override runtime behavior. |
LUDASH_MIN_SCHEDULE_INTERVAL_MINUTES |
No | 5 |
Minimum interval accepted for cron schedules. |
VITE_MIN_SCHEDULE_INTERVAL_MINUTES |
No | 5 |
Build-time client hint for schedule interval warnings; set it to match LUDASH_MIN_SCHEDULE_INTERVAL_MINUTES when customizing. |
LUDASH_EOL_CATALOG_FILE |
No | - | Custom distribution lifecycle catalog used by pnpm run generate:eol and pnpm run build. |
NODE_EXTRA_CA_CERTS |
No | - | Additional PEM CA bundle for outbound TLS targets such as OIDC, SMTP, Gotify, ntfy, or webhooks. |
NODE_ENV |
No | - | Set to production to serve the built SPA from the backend. |
For secret-backed settings, do not set both VAR and VAR_FILE; startup fails with a configuration error when both are present for the same setting.
OIDC and password login are normally configured under Settings. Each of the following variables pins one setting from the deployment instead. A pinned setting overrides the saved value, is locked in Settings with a note naming the variable, and is never written to the database. Removing the variable and restarting restores the last value saved in Settings. Unset or empty variables leave the setting managed in Settings, and invalid values stop startup with a configuration error.
| Variable | Setting | Notes |
|---|---|---|
LUDASH_OIDC_ISSUER |
Issuer URL | http:// or https:// URL. Must be set together with LUDASH_OIDC_CLIENT_ID. |
LUDASH_OIDC_CLIENT_ID |
Client ID | Must be set together with LUDASH_OIDC_ISSUER. |
LUDASH_OIDC_CLIENT_SECRET |
Client secret | LUDASH_OIDC_CLIENT_SECRET_FILE is supported for Docker Secrets. Never returned by the API. |
LUDASH_OIDC_SCOPE |
Scopes | Space-separated; must include openid. |
LUDASH_OIDC_GROUPS_CLAIM |
Groups claim | Default groups. |
LUDASH_OIDC_ADMIN_GROUPS |
Admin groups | Comma-separated. |
LUDASH_OIDC_READ_ONLY_GROUPS |
Read-only groups | Comma-separated. |
LUDASH_OIDC_UNMATCHED_ROLE |
Access for unmatched users | deny, admin, or read-only. |
LUDASH_OIDC_END_SESSION |
Sign out of the identity provider on logout | true or false. |
LUDASH_PASSWORD_LOGIN_ENABLED |
Password login | true or false. |
services:
linux-update-dashboard:
environment:
- LUDASH_OIDC_ISSUER=https://auth.example.com/application/o/ludash/
- LUDASH_OIDC_CLIENT_ID=ludash
- LUDASH_OIDC_CLIENT_SECRET=${LUDASH_OIDC_CLIENT_SECRET}
- LUDASH_OIDC_ADMIN_GROUPS=ludash-admins
- LUDASH_PASSWORD_LOGIN_ENABLED=falsePassword login only stays disabled, whether from the environment or from Settings, while OIDC is configured (issuer and client ID) or at least one passkey exists. Otherwise it stays enabled, a warning is logged, and Settings shows a notice. If the identity provider becomes unreachable while password login is disabled, set LUDASH_PASSWORD_LOGIN_ENABLED=true and restart to sign in with a password again; password hashes are kept while password login is disabled.
Linux Update Dashboard supports password login, TOTP authenticator codes, passkeys, OpenID Connect SSO, and API tokens. Passwords use bcrypt, TOTP secrets are encrypted at rest, sessions are HTTP-only JWT cookies with rolling refresh, and password login cannot be disabled unless passkeys or SSO are configured to prevent lockout. OIDC and password login can also be pinned from the deployment; see Authentication settings from the environment.
TOTP can be enabled from Settings > Password after signing in with the account password. Once enabled, password sign-ins require a current authenticator app code.
For OIDC, configure the provider callback URL as {LUDASH_BASE_URL}/api/auth/oidc/callback. Set LUDASH_BASE_URL before configuring OIDC so callback and origin validation match the public URL.
OIDC settings also control the requested scopes (must include openid), the claim that carries group membership (default groups, read from the ID token and the UserInfo endpoint), and which groups get admin or read-only access. Admin groups take precedence over read-only groups. Users that match no configured group are denied by default; you can instead grant them admin or read-only access. Installations that already used OIDC before group mapping existed keep granting admin access to unmatched users, so existing SSO accounts are not locked out after upgrading. Role changes apply on the next SSO sign-in, and OIDC sessions expire after one day.
When an SSO user logs out, the dashboard also ends their session at the identity provider (OpenID Connect RP-initiated logout), so the next SSO sign-in asks for credentials again. This requires the provider to advertise an end_session_endpoint; register {LUDASH_BASE_URL}/login as a post-logout redirect URI with your provider. You can turn this off under Settings → OIDC (SSO) to only clear the dashboard session.
Read-only users can view the dashboard, systems, available updates, and activity history, but cannot run checks, upgrades, or reboots, change systems, or open credentials, schedules, notifications, scripts, or settings. API tokens owned by a read-only user are always read-only. Local password accounts always have admin access.
API tokens are for external integrations such as gethomepage widgets, scripts, monitoring, and Telegram command automation. Tokens can be read-only or read/write, expire after 30/60/90/365 days or never, are stored as SHA-256 hashes, are limited to 25 per user, and cannot access management endpoints or configure SSH connections.
curl -H "Authorization: Bearer ludash_..." http://localhost:3001/api/dashboard/statsThe endpoint catalog lives in API.md.
| Package Manager | Distributions |
|---|---|
| APT | Debian, Ubuntu, Linux Mint |
| DNF | Fedora, RHEL 8+, AlmaLinux, Rocky |
| YUM | CentOS, older RHEL |
| Pacman | Arch Linux, Manjaro |
| APK | Alpine Linux |
| Flatpak | Any cross-distribution host |
| Snap | Any cross-distribution host |
| deb-get | Debian and Ubuntu hosts with deb-get installed |
| Custom | User-defined scripts and parser rules |
Managers are detected over SSH when testing a connection or running the first check. Detected managers are enabled by default and can be toggled per system. Security updates are identified where the package manager exposes enough information.
Per-system manager settings include APT upgrade vs full-upgrade and kept-back auto-hide, DNF upgrade vs distro-sync, optional metadata refresh skips for DNF/Pacman/APK/Flatpak, and opt-in DNF/YUM automation for GPG-key and EULA prompts. Snap does not currently expose manager-specific settings.
deb-get installs apps that publish .deb files on GitHub, GitLab, or a website but have no APT repository, such as Obsidian or draw.io. The dashboard manages the apps that deb-get installed itself (methods direct, github, gitlab, and website in /etc/deb-get/installed). Apps that deb-get set up from an APT repository or PPA stay with APT.
- Check: runs the read-only
deb-get show <app>for each app and compares versions withdpkg --compare-versions. It does not rundeb-get update, because that also runsapt-get updateand re-checks every app deb-get knows about in the background, which uses up GitHub API limits. - Upgrade all:
deb-get upgrade --dg-only. deb-get also upgrades the apt and ppa apps it installed in this step. - Upgrade selected:
deb-get install <app...>. - No duplicates: deb-get installs its apps through dpkg. While deb-get is enabled, its apps are removed from the APT inventory and from APT updates.
- GitHub token: assign a Secret credential to the built-in
githubTokenentry. deb-get receives it asDEBGET_TOKEN, which raises the GitHub API rate limit. Without it, GitHub-hosted apps can fail to report a published version, and the check output names them. See Secret config entries.
deb-get needs root for every action, including show. The generated sudoers file therefore has an exact deb-get upgrade --dg-only rule, an active deb-get show * rule (see the read-only lookups below), a commented deb-get install * rule for selected upgrades, and env_keep for DEBGET_TOKEN. Any sudo access to deb-get is effectively root: deb-get runs its app definitions as shell code and installs whatever packages they point to.
Use Edit mode on the dashboard to create groups, drag groups into a preferred display order, and drag systems between or within groups. The display order is independent of upgrade execution order.
Each group, including Ungrouped, has an Upgrade priority from 0 to 99 (default 1):
- Lower priority numbers run first.
- Groups with the same priority run in parallel.
- The next priority starts only after all systems at the current priority finish.
- Each system also has an Upgrade priority from
0to99within its current group (including Ungrouped), with a default of1. - Lower-priority-number systems run first within that group, while systems with the same priority run in parallel.
- System priority is scoped to its group, so groups sharing a priority progress through their own system priorities independently.
Upgrade All remains available whenever at least one system with updates is idle. Systems that are refreshing, queued, upgrading, rebooting, repairing package-manager issues, or running another maintenance operation are left out until that operation finishes.
Each system has an Upgrade All switch in dashboard Edit mode. Turning it off leaves the system available in the Upgrade All Systems modal but unchecked by default. Checkbox changes made inside the modal apply only to that run and do not change the saved preselection.
The Group badges toggle adds per-group summaries for up-to-date systems, available updates, reboot requirements, lifecycle warnings, check issues, and unreachable systems. The preference is stored in the browser and is disabled by default. Groups can be collapsed outside Edit mode without changing their display or upgrade priority.
Schedules are managed from the Schedules page. Existing installs migrate to an enabled Default refresh schedule using the previous refresh interval and cache settings.
- Refresh schedules re-check scoped systems when cached results are stale.
- Update schedules refresh scoped systems first, then run the normal per-system Upgrade action where visible updates remain.
- Reboot schedules reboot every scoped system. With Only reboot if required enabled, they refresh scoped systems first and only reboot those that report a pending reboot; systems with a dismissed reboot warning are skipped.
- Notification schedules deliver pending event batches for assigned notification channels.
Schedules use five-field cron expressions in the process timezone. Set Docker TZ, such as TZ=Europe/Berlin, for local-time scheduling. The default minimum interval is 5 minutes and can be changed with LUDASH_MIN_SCHEDULE_INTERVAL_MINUTES.
Update schedules use the same explicit dashboard-group and per-system priorities as manual Upgrade All Systems runs. Hidden systems and systems excluded from Upgrade All are not queued unless explicitly included.
Set a refresh schedule's cache duration to 0 to disable cache reuse. Manual refreshes, server restarts, and newly added systems can still trigger checks outside configured schedules. Notification channels can be assigned to multiple notification schedules.
Notification channels are configured from the Notifications page. Each channel can choose event types (updates, unreachable, appUpdates, and rebootRequired), system scope, immediate or scheduled delivery, and provider-specific settings. rebootRequired fires when a refresh first detects a pending reboot and includes the triggering packages when the host provides /run/reboot-required.pkgs; it can also be included in scheduled digests. New channels default to updates and appUpdates. Secrets such as SMTP passwords, app tokens, bot tokens, command tokens, webhook credentials, and sensitive headers are encrypted at rest.
| Type | Best for | Notes |
|---|---|---|
Email |
inbox alerts | SMTP with optional auth, a customizable subject prefix, Plain SMTP, STARTTLS, or implicit TLS. Prefer NODE_EXTRA_CA_CERTS for private CAs. |
Gotify |
self-hosted/mobile push | App token stored encrypted. |
MQTT |
brokers, automations, Home Assistant | Generic event publishing plus optional Home Assistant MQTT Update entities. |
ntfy |
lightweight push topics | Topic delivery with optional bearer token. |
Telegram |
chat alerts and optional commands | Private-chat binding only. Commands are off by default and require confirmations for mutating actions. |
Webhook |
custom integrations, chat ops, n8n, Node-RED, Discord | POST/PUT/PATCH, templates, query params, headers, auth, retries, timeout, and Discord preset. |
For email channels, set Email subject prefix to a label such as [LINUX-UPDATE] to prepend it to every subject, including test emails and scheduled digests. Leave it blank to keep the original subjects.
LUDASH_BASE_URL should be explicitly set for Home Assistant. The MQTT integration can publish one app update entity and one per-system package update entity. Discovery config uses retained payloads; update state and JSON attributes are published on separate retained topics; install commands map to the normal per-system upgrade action when enabled. Notification schedules only affect the generic MQTT event topic, not Home Assistant state.
Per-system attributes include update counts, security counts, reboot state, reachability, active operation, detected host metadata, and pending package details. Package entity versions are synthetic fingerprints for the pending update set, not real package-version pairs.
Telegram notification setup:
- Create a bot with @BotFather.
- Create a
Telegramchannel in the dashboard and paste the bot token. - Save, reopen the channel, click Create Link, and open the generated private-chat link.
- Start the bot in Telegram and use Send Test to verify delivery.
Bindings use single-use deep links that expire after 10 minutes and show unbound, pending, or bound status. Changing the bot token clears the binding.
Optional commands are private-chat-only, disabled by default, scoped to the channel's systems, and backed by an auto-generated write-capable API token. The token is revoked when commands are disabled, the chat is unlinked, the channel is deleted, or the bot token changes. Supported commands are /help, /version, /menu, /status, /refresh <system-id|all>, /packages <system-id>, /upgrade <system-id|all>, /fullupgrade <system-id|all>, and /upgradepkg <system-id> <package>. Mutating commands require confirmation buttons that expire after 5 minutes.
Webhook channels support POST, PUT, and PATCH; custom or Discord presets; no auth, bearer auth, or basic auth; text, JSON template, or form bodies; query params; custom headers; timeout; retries; retry delay; and optional insecure TLS for trusted internal targets.
Templates use simple Mustache variable tags. Only dotted event.* paths are allowed; sections, loops, and other Mustache control tags are rejected. Common variables include {{event.title}}, {{event.body}}, {{event.priority}}, {{event.sentAt}}, {{event.appName}} (the configured branding name), totals under {{event.totals.*}}, text summaries, JSON payload helpers, and JSON-safe variants such as {{event.titleJson}} and {{event.decoratedTitleJson}}.
Webhook delivery defaults to a 10-second timeout, 2 retries, and a 30-second retry delay. URLs must be http or https, embedded URL credentials are rejected, cloud metadata endpoints are blocked, and reserved headers such as Authorization, Host, Content-Length, Connection, and Cookie cannot be set manually.
The Scripts page exposes the SSH command templates for package-manager detection, update checks, installed-package inventory, issue repair, autoremove, upgrades, selected-package upgrades, system info, and reboots.
Importable custom package-manager bundles live in examples/, including a Docker Compose example. The Docker Compose example treats services as packages: checks pull images into the local Docker cache and compare them with running containers, while upgrade operations recreate all or selected services. Configure its project path after importing it; the SSH user must be allowed to run Docker. Import the bundle under additional package-manager keys to manage multiple Compose projects on the same system.
- Built-in scripts for APT, DNF, YUM, Pacman, APK, Flatpak, Snap, deb-get, system-info, and reboot are read-only and can be copied.
- Custom scripts can define shell steps, operation type, parser settings, installed-package inventory parsing, and system-info section mapping.
- Per-system overrides can replace individual operations such as
apt/check_updates,apt/repair_issue,apt/autoremove,apt/upgrade_all, orsystem/reboot. - Custom package managers can define display labels, parser regexes, optional config entries, and import/export bundles.
- Config entries can be marked secret for tokens and passwords. See Secret config entries.
- Used scripts and package managers are protected from accidental deletion.
Command placeholders include {{package}}, {{packages}}, {{quotedPackage}}, {{quotedPackages}}, {{manager}}, {{config.someKey}}, and {{sudo:COMMAND}}. Custom parser regexes should use named capture groups such as packageName, newVersion, currentVersion, architecture, and repository.
For restricted automation accounts, use a dedicated SSH user and leave the dashboard sudo password unset. The Systems page has a Sudoers setup action that generates a least-privilege /etc/sudoers.d allowlist for the selected system. Review generated files before installing them, keep commands exact, prefer absolute executable paths, leave selected-package wildcard rules commented unless needed, and avoid broad rules such as NOPASSWD: ALL or sudo sh <writable-script>.
Read-only lookups are the one exception to exact, active rules. Some lookups must run as root and take a different argument list every time, such as deb-get show <app>. The generated file lists these in their own active section as <command> *. Only commands on a fixed allowlist in the dashboard qualify, and only in check or inventory steps. A custom script that passes a variable to any other sudo command still gets a warning instead of a rule.
Example validation after installing a generated file:
sudo chmod 440 /etc/sudoers.d/updater-updater
sudo visudo -cf /etc/sudoers.d/updater-updaterPackage maintenance remains privileged because package scripts run as root. The goal is to limit the dashboard account to the required maintenance commands.
Mark a config entry as secret when a script needs a token or password, for example a GitHub token that raises API rate limits. A secret entry has no default value. Instead, it names the environment variable the script reads, such as DEBGET_TOKEN. Each system then assigns a Secret credential from the Credentials page.
- The value is stored encrypted, like every other credential. It is never returned by the API, included in bundle exports, or copied anywhere except the systems that reference it.
- At run time the dashboard sends the value over the SSH channel's stdin, ahead of the sudo password. A short preamble reads it into the environment variable. The command text, the process list on the host, the detached script in
/tmp, the activity history, and the command shown in the browser contain only the variable name. - Command output is redacted of the value before it is streamed, stored, or logged. Values shorter than 6 characters are not redacted.
- Scripts reference the variable, for example
"$DEBGET_TOKEN".{{config.<key>}}placeholders are rejected for secret entries. - Enable Keep through sudo on the entry when a root command needs the variable. sudo's default
env_resetdrops it otherwise, so the generated sudoers file addsDefaults:<user> env_keep += "DEBGET_TOKEN". Command rules stay exact, and the value never appears in sudo's command line or auth log. Accounts with full sudo access need this line too. Names that change shell, loader, interpreter, or sudo behavior, such asPATH,LD_*,BASH_ENV,PYTHONPATH, orSUDO_*, are rejected.
The tool that receives the secret decides what happens next. A tool that passes it to curl on the command line makes it visible in the host's process list while that command runs.
The Repository sources action on the Systems page opens a modal with the repository files of the system's active package managers. The files are read over SSH as the SSH user, without sudo, each time the modal is opened or refreshed.
| Manager | Files |
|---|---|
| APT | /etc/apt/sources.list, /etc/apt/sources.list.d/*.list, /etc/apt/sources.list.d/*.sources |
| DNF | /etc/yum.repos.d/*.repo, /etc/distro.repos.d/*.repo, /usr/share/dnf5/repos.d/*.repo (view only) |
| YUM | /etc/yum.repos.d/*.repo |
| Pacman | /etc/pacman.conf, /etc/pacman.d/*mirrorlist |
| APK | /etc/apk/repositories, /etc/apk/repositories.d/*.list |
| Flatpak | /var/lib/flatpak/repo/config, /etc/flatpak/remotes.d/*.flatpakrepo (view only; use flatpak remote-*) |
Credentials in URLs (https://user:pass@...) and password, token, or secret fields are masked before content leaves the server.
Editing is off by default. Turn on Allow repository source editing in the system settings to edit existing files of that system; while it is off, the modal links to the system settings. Read-only users and API tokens cannot edit or change the setting.
- One file at a time: while a file is being edited, the modal can only be closed with Cancel, so an unsaved edit is not lost to Escape or a click outside.
- Review before writing: saving first validates the syntax of one-line APT entries, deb822 stanzas, and INI section headers, then shows a diff of the change against the file currently on the host. The file is written only after you confirm.
- Concurrent changes: if the file changed on the host since it was loaded, the save is rejected and the file must be reloaded.
- How it is written: the new content travels base64-encoded over the SSH channel's stdin. Root writes the file directly. Other users go through
sudoedit, which copies the file to a temporary file owned by the SSH user, lets a throwaway editor script in/tmp/ludash_*replace that copy, and installs it as root.sudoeditrefuses symlinks and keeps the file's owner and mode. The dashboard reads the file back afterwards to confirm that it changed. Nothing is installed on the host. - History: each save is recorded in the activity history with a unified diff.
- Not editable from the dashboard: files with masked credentials (they would be lost), files over 64 KiB, files that are not valid UTF-8, vendor files, and Flatpak remotes. New files cannot be created yet.
For restricted SSH users, each file needs an exact sudoedit rule. When editing is enabled, the Sudoers setup output lists one commented rule per file found on the host:
# updater ALL=(root) NOPASSWD: sudoedit /etc/apt/sources.list.d/debian.sources
Uncomment only the files you want to edit. Write access to repository sources is root-equivalent: a repository decides which packages the next upgrade installs, and package scripts run as root. Only add repositories you trust, and run Check for updates after an edit to confirm the package manager accepts the new configuration.
Upgrade operations and autoremove run through detached remote scripts with nohup, so they continue if SSH disconnects or the dashboard restarts. The dashboard streams output with tail --pid, captures the exit code from a companion file, and marks setup failures as failed instead of falling back to unsafe direct execution. If monitoring disconnects mid-operation, the remote process may still be running and the UI reports that warning.
| Operation | SSH-safe |
|---|---|
| Upgrade all packages | Yes |
| Full/dist upgrade | Yes |
| Upgrade selected packages | Yes |
| Autoremove unused packages/runtimes | Yes |
| Check for updates | No, read-only and retryable |
| Reboot | No, fire-and-forget |
If a sudo password is configured, it is sent only over the live SSH stdin stream to the privileged command. It is never written to files or environment variables. SSH-safe operations are marked with an SSH-safe badge in activity history.
The dashboard records /etc/os-release fields during refreshes and compares them with a bundled lifecycle catalog. Warnings appear on the dashboard, systems list, and system detail page when a release is near EOL, fully EOL, or in reduced support such as Debian LTS after regular Debian Security Support.
The warning window is configurable from Settings > Lifecycle Warnings and defaults to 180 days. Supported catalog keys are ubuntu, debian, devuan, fedora, rhel, rocky, almalinux, centos, centos-stream, alpine, and proxmox. Derivatives are matched only by their own ID; a distribution that is merely ID_LIKE=debian shows an unknown lifecycle status instead of being compared against unrelated Debian releases.
Lifecycle data is generated at build time by scripts/generate-distro-lifecycle-data.mjs from https://endoflife.date/api. The generated data lives in server/generated/distro-lifecycle-data.json, with server/default-distro-lifecycle-catalog.json as an offline fallback. pnpm run build runs the generator before compiling and keeps or falls back to local data if the remote API is unavailable.
Use a custom source catalog for repeatable builds:
LUDASH_EOL_CATALOG_FILE=/path/to/my-eol-catalog.json pnpm run generate:eol
# or
LUDASH_EOL_CATALOG_FILE=/path/to/my-eol-catalog.json pnpm run buildCustom catalogs use the same shape as server/default-distro-lifecycle-catalog.json; each entry needs cycle and eol, with optional supportEnd, supportLabel, and finalSupportLabel.
If you edit server/generated/distro-lifecycle-data.json directly, remember that pnpm run build regenerates it. Use LUDASH_EOL_CATALOG_FILE for repeatable custom data.
For container installs, set LUDASH_LOG_LEVEL=debug and inspect logs:
docker logs -f linux-update-dashboardAt info, logs include startup, configuration, warnings, and errors. debug adds attempt-scoped SSH diagnostics and routine scheduler refresh logs. Failed test-connection requests include a debug reference ID you can match against logs.
Logged SSH diagnostics are limited to safe metadata such as host, port, username, auth type, elapsed time, and filtered auth/debug events. Passwords, sudo passwords, private keys, passphrases, tokens, and raw SSH payloads are never logged. Avoid leaving debug logging enabled longer than needed.
If a login banner is sent by the SSH server, Test Connection shows it below the error. The banner is not written to the logs because it can contain one-time links. "Authentication did not complete" means the handshake succeeded but the server held the login open until the timeout. A common cause is Tailscale SSH with an ACL rule set to "action": "check": it ignores the configured credentials and waits for a browser re-authentication that the dashboard cannot perform. Use "action": "accept" for the dashboard host, or disable Tailscale SSH on the target so its regular SSH server answers.
- Credentials and notification secrets are encrypted at rest with AES-256-GCM and per-entry random IVs/auth tags.
- Encryption keys can be raw base64 or passphrases derived via PBKDF2-SHA256 with 480k iterations and a per-instance salt.
- Sessions use HTTP-only, SameSite=Lax JWT cookies signed with HS256.
- State-changing API requests require a per-session CSRF token header.
- API inputs use strict type, format, and range validation.
- Auth endpoints are rate-limited: setup 3/min, login and WebAuthn verify 5/min, and failed bearer token attempts 20/min per IP.
- API tokens are hash-only, can be read-only, skip CSRF as stateless bearer requests, and are blocked from management endpoints and SSH connection configuration.
- Password login uses timing-safe dummy-hash comparisons to reduce username enumeration risk.
- OIDC client secrets are encrypted at rest and can use private/self-signed CAs through
NODE_EXTRA_CA_CERTS. - Per-system mutexes and a semaphore-based SSH connection limit prevent conflicting or runaway SSH operations.
- Client: React, React Router, TanStack Query, Vite, Tailwind CSS in
client/; production output goes todist/client. - Server: Node.js and Hono in
server/; compiled output goes todist/server. - Database: SQLite with Drizzle schema/migrations; startup creates and upgrades schema automatically.
- SSH:
ssh2connection manager, package-manager parsers, command scripts, output streaming, ProxyJump support, and host-key validation. - Runtime services: scheduling, caching, notification delivery, MQTT/Home Assistant state, app update checks, and distribution lifecycle resolution.
├── .github/ # CI/CD workflows and Dependabot
├── assets/ # Logo assets served by the app
├── client/ # React SPA, routes, hooks, components, styles, locales
├── docker/ # Dockerfile, compose, entrypoint, test systems
├── examples/ # Custom package-manager examples
├── screenshots/ # README screenshots and capture scripts
├── scripts/ # Build-time helper scripts
├── server/ # Hono backend, auth, db, routes, services, ssh
├── shared/ # Code used by both client and server (branding theme)
├── tests/server/ # Vitest server and integration tests
├── run.sh # Local dev/production/test runner
├── reset-dev-branch.sh # Reset dev branch to main
├── vite.config.ts # Vite + Tailwind config
└── package.json
Use the helper:
./run.sh dev # hot reload, backend :3001, client :5173
./run.sh # build and start production server on :3001
./run.sh test # production server plus Docker test systemsOr use pnpm directly:
pnpm run dev
pnpm run dev:server
pnpm run dev:client
pnpm test
pnpm run check./run.sh test stops running services, builds and starts 17 Docker SSH fixtures, builds the frontend, and starts the production server on :3001. Add test systems in the dashboard with host.docker.internal or 172.17.0.1 on Linux and the matching SSH port.
Shared credentials:
- User
testuser, passwordtestpass Sudo password:testpassforludash-test-ubuntu-sudoandludash-test-debian-fish-sudo, optional for most othersludash-test-ubuntu-rootalso acceptsroot/testpassfor root-login banner testing
| Container | SSH Port | Package Manager / Fixture | Base |
|---|---|---|---|
ludash-test-ubuntu |
2001 | APT | Ubuntu 24.04 |
ludash-test-fedora |
2002 | DNF | Fedora 41 |
ludash-test-centos7 |
2003 | YUM | CentOS 7 |
ludash-test-archlinux |
2004 | Pacman | Arch Linux |
ludash-test-flatpak |
2005 | Flatpak | Ubuntu 24.04 |
ludash-test-snap |
2006 | Snap | Ubuntu 24.04 |
ludash-test-ubuntu-sudo |
2007 | APT with sudo password | Ubuntu 24.04 |
ludash-test-debian-fish |
2008 | APT with fish shell | Debian 12 |
ludash-test-debian-fish-sudo |
2009 | APT, fish, sudo password | Debian 12 |
ludash-test-alpine |
2010 | APK | Alpine 3.16 |
ludash-test-apt-keptback |
2011 | APT kept-back fixture | Debian 12 |
ludash-test-apt-snap-partial |
2012 | APT + failing Snap fixture | Ubuntu 24.04 |
ludash-test-dnf-gpg-prompt |
2013 | DNF GPG key prompt fixture | Fedora 41 |
ludash-test-dnf-eula-prompt |
2014 | DNF EULA prompt fixture | Fedora 41 |
ludash-test-apt-dpkg-interrupted |
2015 | APT interrupted dpkg fixture | Debian 12 |
ludash-test-ubuntu-root |
2016 | APT root-login/full sudo fixture | Ubuntu 24.04 |
ludash-test-custom-package-managers |
2017 | All custom package-manager examples | Ubuntu 24.04 |
ludash-test-deb-get |
2018 | deb-get stand-in with local .deb apps, least-privilege sudoers |
Debian 12 |
Fixtures pin older package versions from archived or local repositories while current repos stay active, so package-manager checks report deterministic pending updates. Special fixtures cover kept-back packages, partial multi-manager failures, DNF GPG prompts, DNF EULA prompts, interrupted dpkg repair, root-login behavior, and custom package manager examples in examples/.
The custom package-manager container uses local npm/Python repositories and deterministic Docker Compose/Hermes command fixtures. Build it and run every example end to end with:
docker compose -f docker/test-systems/docker-compose.yml up -d --build custom-package-managers
LUDASH_RUN_DOCKER_INTEGRATION=1 pnpm vitest run tests/server/custom-package-manager-examples.integration.test.tsImport the JSON bundles from examples/ on the dashboard's Scripts page. Then add the container as localhost:2017 with testuser / testpass, disable its built-in APT manager, and run a check. Every custom example reports one update from 1.0.0 to its fixture target; Upgrade all installs them through the same persistent SSH workflow used for normal systems. Reset all fixtures to 1.0.0 whenever you want to repeat the test:
docker exec -u testuser ludash-test-custom-package-managers \
/opt/ludash-custom-package-managers/reset-fixtures.shMost test users are restricted through least-privilege sudoers allowlists in docker/test-systems/sudoers/. Selected-package operations use reviewed argument wildcards only where a fixture needs them.
Docker integration tests are opt-in, for example:
LUDASH_RUN_DOCKER_INTEGRATION=1 pnpm vitest run tests/server/apt-sudo-password.integration.test.ts
LUDASH_RUN_DOCKER_INTEGRATION=1 pnpm vitest run tests/server/secret-env.integration.test.ts
LUDASH_RUN_DOCKER_INTEGRATION=1 pnpm vitest run tests/server/deb-get.integration.test.tsReset dev to main and force push:
./reset-dev-branch.shTranslations live in client/locales/. Browser-language detection applies to the UI. Server-generated text such as notifications and scheduled messages uses the saved language setting; with Browser default, background server text falls back to English because no browser locale is available.
Contributions for corrections, more natural wording, and new languages are welcome. Keep the same keys as client/locales/en.json.
|
|
CrowdSec Web UI A self-hosted web dashboard for CrowdSec to review alerts, manage decisions, configure notifications, and optionally view runtime metrics. |













