Skip to content

Security Model

Building blockTechnologyLocation
Password hashingargon2id (OWASP parameters)internal/core/services/auth_service.go
User authJWT (HS256), 60 min TTLAuthService + middlewares.Authenticate
Service authAPI keys (SHA-256 hashed)APIKeyService
AuthorizationRBAC: user → roles → permissionsmiddlewares.RequirePermission
Secretsgenerated config.json (0600)internal/config/config.go

Passwords are hashed exclusively with argon2id (64 MiB memory, 4 threads - resistant to GPU cracking). There is nowhere in the entire system where a plaintext password is stored. For an unknown username, the login compares against a dummy hash so that “user does not exist” and “wrong password” cannot be distinguished by timing, and returns the same error in both cases (no user-enumeration leak).

The server is the sole authority: every place that sets a password (creating a user, resetting a password, invitation/activation link, Linux user activation) calls the same function, services.EnforcePasswordPolicy. The strength meter in the UI merely mirrors these rules for instant feedback while typing - it can be bypassed and is deliberately not a security control.

A password is rejected if any rule applies:

RuleRationale
shorter than 12 characters (counted in characters, not bytes)floor against offline cracking
longer than 200 charactersabuse via oversized request bodies
fewer than 3 character classes (upper, lower, digits, symbols)from 20 characters 2 suffice - length replaces complexity (NIST SP 800-63B)
fewer than 6 distinct characters”AAAAbbbb1111” is weak despite its length
contains username, first/last name or an email fragmentthe first candidates in any targeted attack
dominated by an obvious term (admin, password, lcm, …)“admin-admin-1A!” is guessed, not chosen
is a known default passwordincluding leetspeak and an appended year: P4ssw0rd!2026 is caught just like password
contains a predictable sequence (1234, abcd, qwerty, 1qaz2wsx)checked forwards and backwards
contains too many repetitions (aaa, or a short repeated block abcabcabc)looks long, isn’t
starts/ends with whitespace or contains control charactersinput mistakes and copy artefacts

The check returns machine-readable codes (too_short, contains_identity, common_password, …). The UI translates them in both languages and shows concretely what is missing - instead of a blanket rejection.

In regular operation, 2FA is enabled by default for administrators. When seeding a fresh database, LCM sets require_2fa_roles = admin. Reason: the admin account manages SSH access and root privileges for the entire fleet - a password alone is not enough. The setting can be changed under Settings → General; unknown role names are rejected so a typo cannot silently disable the requirement.

In development (--dev) and demo mode (--demo) the requirement is deliberately off - there it would only get in the way, and neither mode is intended for production.

Enforcement is server-side and complete: middlewares.AccountRemediation works as an allowlist (fail-closed) and lets an account that must set up 2FA reach only the endpoints needed for that. All places that verify a TOTP code - login, disabling 2FA, changing your own password - share the same per-account brute-force counter, so switching endpoints does not bypass the lockout.

Failed attempts are counted on two keys simultaneously:

  • per client IP (threshold 5) - stops rapid guessing from a single source;
  • per account (threshold 15) - stops password spraying distributed across many IPs that would deliberately evade the IP lockout. The higher threshold and the capped lockout duration prevent an attacker from cheaply locking out a legitimate account.

Both lockouts grow exponentially (max. 15 minutes) and decay after 15 minutes without a failure. Check and count happen in one operation under the same lock - otherwise hundreds of parallel requests could slip past the check together before the first failure is recorded.

The client IP is determined once per request (middlewares.ResolveClientIP) and shared by the IP allowlist, the login lockout, the access log and the security log. Behind a reverse proxy it comes from X-Forwarded-For - but only if trust_proxy_header is set and the peer is listed in trusted_proxies (netfilter.ProxyTrust). Without that, the address behind a proxy would be identical for all clients - five failed attempts by an attacker would have locked login for the entire installation; and without the list anyone reaching the port directly could choose their own address.

An accepted TOTP code is valid exactly once (RFC 6238): the same code is not accepted a second time within the verification window, and the challenge of the two-step login is revoked after the second step. At login, passwords above 1024 bytes are not hashed at all - argon2id is deliberately expensive, and the endpoint is reachable anonymously.

Links in password-reset and invitation emails are built exclusively from the public_base_url setting (Settings → General), never from the request’s Host header.

If public_base_url is not set, LCM falls back to its own configuration (scheme, host and port of the listener). Depending on the network setup the link may then not be reachable from outside - but it is never misleading. For production, the address should be set.

  1. Login (POST /api/v1/auth/login): after argon2id verification, AuthService issues an HS256-signed JWT. Claims: user ID (sub), username, iat/exp.
  2. Request: the frontend sends Authorization: Bearer <jwt>. The Authenticate middleware validates signature and expiry - the signature method is explicitly pinned to HS256 (jwt.WithValidMethods), which prevents algorithm-confusion attacks.
  3. Role resolution: permissions are not stored in the token. On every request the user is loaded fresh from the DB together with their roles/permissions - role changes and deactivations take effect immediately, not only at the next login.
  4. Expiry: after access_token_ttl_minutes (default 60) the token is invalid → the server responds 401 → the frontend logs out automatically (see API reference).

The JWT secret is generated cryptographically at random on first start (48 bytes from crypto/rand) and lives only in the config.json (file permissions 0600). Secrets shorter than 32 characters are rejected on load.

Session invalidation on every restart: The HS256 signing material is drawn fresh from crypto/rand on every process start and lives only in memory (newSigningKey in auth_service.go). There is no stored secret behind it any more - earlier versions mixed in a jwt_secret from config.json, which had no purpose once the key was bound to the process start; a leftover entry is ignored and removed the next time the file is written. So every (re)start produces a different signing key and all previously issued tokens fail signature verification - every session ends, a new login is required. This specifically covers the case where an old, still-valid session would otherwise trust a freshly seeded admin with the same ID (rebuild, process restart, newly created database).

User "alice" ──> Role "admin" ──> Permissions: users:read, users:write, servers:write, ...
User "bob" ──> Role "manager" ──> Permissions: servers:read, servers:write, jobs:read, ...

Permission codes are constants in internal/core/domain/rbac.go. Routes are protected declaratively:

// Logged in only:
auth.Post("/2fa/setup", middlewares.RequireAuth(), authCtrl.SetupTOTP)
// Logged in AND permission:
servers.Get("/:id/storage-history", middlewares.RequirePermission(domain.PermServersRead), serverCtrl.StorageHistory)

RequirePermission responds 401 (not logged in) or 403 (logged in, but the right is missing). Role resolution happens in the background: Authenticate loads the user with Preload("Roles.Permissions"), and HasPermission then only checks in-memory.

Introducing a new permission:

  1. Define the constant in domain/rbac.go (convention: resource:action).
  2. Describe it in storage/seed.go and assign it to the appropriate roles.
  3. Protect the route with it.
  4. Optionally use auth.can('...') in the frontend for the UI - this is purely cosmetic; the real check is always done by the server.

For processes without a browser (CI, cron, other services) there are API keys (X-API-Key header):

  • Created via POST /api/v1/apikeys (permission apikeys:manage); the plaintext key (lcm_…) is included only in this one response.
  • Only the SHA-256 hash is stored. A DB leak does not reveal any valid keys.
  • Each key runs in the permission context of the user who created it - RBAC applies unchanged.
  • Keys can expire (expires_in_days) and be revoked.

Scopes: when creating a key you choose "scope": "read", "readwrite" (default) or "mcp". A read key may only use GET/HEAD/OPTIONS - any writing method is rejected with 403 in the Authenticate middleware, before controller code runs and in addition to the RBAC check. This lets you issue, for example, monitoring keys that cannot change anything even in an admin context. An mcp key is strictly isolated: it works only on the separate MCP listener (see below) and is rejected on the regular REST API/UI. Migration 1 automatically sets keys from older versions to readwrite (previous behavior).

Rate limiting: api_key_rate_limit_per_minute in config.json (default 120, 0 = off) limits requests per API key per minute (fixed window, in-memory). The limit applies before key validation - even brute-force attempts with invalid keys are throttled. On exceeding it: 429 with a Retry-After header. JWT browser sessions are not affected. For multi-instance deployments the limit belongs in the reverse proxy or a shared store.

The seeding user system exists for background processes without a user context; it cannot log in with a password (IsSystem).

The log service (internal/logging/logging.go) is based on log/slog:

  • Level via log_level in config.json (debug, info, warn, error).
  • Debug mode at startup: ./lcm -debug raises the level to debug without changing the config - for development and troubleshooting.
  • Access log (access_log: true): every API request is logged with method, path, status, duration, IP, and username; 4xx as WARN, 5xx as ERROR. At debug level, additionally the query string and user agent.
  • Audit mirror: every audit entry (permission changes, server actions, settings) is also written as a journal line audit action=… actor=… - the database lives on the node, the journal can be forwarded (journalctl -u lcm | grep -E " (audit|security) ", forwarding via rsyslog/systemd-journal-upload).
  • Passwords, tokens, and request bodies are never logged.

The package inventory of all servers, centrally recorded in the DB, is checked daily (cron configurable, default 30 2 * * *) against the Trivy vulnerability database - agentless: for each server a CycloneDX SBOM is generated from the package records and scanned locally with trivy sbom; the managed servers are not contacted for this.

  • Status effect: critical CVE → red light, high CVE → yellow (with an insight explanation).
  • Graceful degrade: if Trivy is not installed on the LCM host, the feature disables itself cleanly (notice in the UI and job log); everything else runs normally. Trivy is set up separately (see Installation).
  • Alerts: the “Security/CVE” alert rule notifies from a configurable minimum severity (see Settings → Alerts).
  • Code: internal/core/services/cvescan.go (SBOM/PURL building), internal/infrastructure/trivy/ (CLI binding).

During onboarding (or later via restrict privileges) the LCM user can be moved from NOPASSWD:ALL to a sudoers whitelist: package management (apt, dnf/yum, zypper, pacman, apk), Docker, ufw and the strictly validating lcm-helper. Free-form scripts, custom actions and reboot are then blocked.

Proof instead of assumption. After switching, LCM checks - as the restricted user - whether the helper and the system’s package manager really are reachable via sudo. If not, full mode is restored within the same run and the failure is reported, rather than leaving behind a server whose core functions are dead and whose way back leads only through the server console. Part of this is that LCM sets the sudo search path (secure_path) itself: RHEL 10 and its clones do not include /usr/local/sbin, where the helper lives; on openSUSE LCM additionally disables targetpw for that user.

What the mode achieves - and what it does not. It reduces the attack surface against operating mistakes and accidental changes, and makes explicit what LCM is allowed to do on the system. It is not a protection against an attacker who has obtained the service key:

  • apt-get/dpkg run arbitrary code as root via hooks (-o APT::Update::Pre-Invoke::=…),
  • docker run -v /:/host … mounts the entire host filesystem.

Both are the very purpose of these programs, and sudo cannot reliably filter their arguments. Without them the mode would be pointless, since package updates and Docker are exactly the actions that should keep working.

Anyone needing real protection against a compromised service account would have to put these commands behind narrowly validating lcm-helper subcommands (only certain apt transactions without -o overrides, Docker without host mounts and without --privileged) - that cuts the feature set considerably and is deliberately not the current state.

Privilege profiles: the arguments are part of the rule

Section titled “Privilege profiles: the arguments are part of the rule”

A Linux user distributed by LCM gets its root rights through a privilege profile (see Linux users). The security boundary there is the sudoers allowlist, and it stands or falls with the arguments: sudo compares the complete command line.

Input validation therefore rejects whatever cannot be bounded:

RejectedWhy
relative paththe user’s search path would decide which program runs as root
wildcards (*, ?, [])apt-get install * permits any package - package scripts run as root
shell metacharacters, commain sudoers the comma separates commands; one comma would smuggle a second command into the same rule
bare systemctl, apt-get, docker …without a sub-action every one of them is allowed, including systemctl edit - an editor as root

Two additions LCM makes itself:

  • --no-pager for systemctl and journalctl. Without it the pager runs as root, and in less a plain !sh is enough for a root shell - an apparently read-only status command would be a full privilege escalation.
  • sudoedit instead of an editor command. sudo nano /etc/… is effectively a root shell. sudoedit starts the editor as the user and writes the file back as root afterwards.

Programs from which an arbitrary command can be started as root regardless of the arguments - shells, interpreters, editors, pagers, dd, tee, chmod …

  • are not forbidden, but they require an explicit confirmation per rule plus an audit entry. The list is deliberately not exhaustive: it catches the common cases, the responsibility for composing a profile stays with the operator.

In restricted mode it is not LCM that writes the file but the lcm-helper - and it does not take the specification on trust: every line must be issued to its own profile group and must not carry ALL as the command, after which visudo runs. Without that check a compromised LCM could hand the restricted service user exactly the full rights back through a profile file that the mode is meant to prevent.

Hardening writes its own drop-in (60-lcm-hardening.conf) and then reads back the effective configuration (sshd -T evaluates includes and match blocks). A server counts as hardened only if sshd demonstrably reports password authentication as no:

  • if it still reports yes - for instance because a lexically earlier drop-in file wins - the drop-in is rolled back and the failure reported;
  • if the check yields no result at all, that does not count as success either: ssh_hardened stays off and the response names the missing proof. For a security function an unproven success message is more harmful than an honest failure - whoever reads “hardened” stops looking.

The configuration file is not assumed to live under /etc/ssh: openSUSE Leap 16 has a stateless /etc and ships it as /usr/etc/ssh/sshd_config.

Installing the package sets up the machine itself as a managed server named lcm-host. Without it, the host-specific features (Trivy, apt-cacher-ng, CrowdSec LAPI) would stay out of reach on a fresh install until someone onboarded the host by hand.

The private key is not left on the filesystem:

  1. postinstall.sh generates the key pair locally, adds the public key to lcm-svc’s authorized_keys and writes the private key to /var/lib/lcm/self-onboard.json (mode 0600, owned by lcm).
  2. On the next start LCM reads that file, encrypts the key into the database with the master key and deletes the file - including when the registration fails or is deliberately skipped. A clear-text key must not be left behind.

The host key of 127.0.0.1 is probed and stored as a trust anchor just like for any other server: self-management does not waive strict host-key checking.

CaseBehaviour
LCM_NO_SELF_MANAGE=1 during installationAccount, sudoers rule and handover file are never created
Container (Docker/Podman/LXC)No entry - “localhost” there is the container, not the host
No SSH service reachableNo entry
localhost already managedNo duplicate (detected via loopback + port 22, not by name)
The entry was deletedIt does not come back - deleting sets self_server_disabled

The last case is the important escape hatch: anyone who does not want self-management deletes the server in the web interface. That removes the entry and records that it must not be re-created - otherwise it would return at the next apt upgrade, because postinstall.sh runs again.

The data directory (/var/lib/lcm: database, master key, TLS key, backups) belongs to the service user alone: 0700, secrets 0600. The configuration (/etc/lcm) belongs to root, group lcm may read and write (restore), nobody else. The package script sets this - and LCM checks it itself at start and every 15 minutes (internal/perms): files owned by the service are tightened again; what it cannot fix (foreign owner, root-owned configuration with world bits) is reported as security event=permissions.insecure. A chmod 777, a restore with the wrong umask or a planted file does not go unnoticed.

Every path parameter that must be a UUID (jobs, SSH sessions, deep-scan reports) is parsed as a UUID before any query runs. Names are single-line and at most 64 characters, descriptions 1000, scripts and custom actions 32 KiB; control characters are never allowed. Hosts consist only of the characters of a hostname or IP address, ports lie in 1-65535, email addresses are exactly one address. The check lives in the controllers (validate.go) and answers 422 with the field name - the domain check in the service (name free, role known, cron valid) is unaffected.

All secrets in the database are encrypted field by field with AES-256-GCM. The master key lives separately from the DB: on first start as lcm.key (file permissions 0600) in the data directory, after the migration as a systemd credential (see below) only encrypted in the credential store (internal/infrastructure/crypto). Without it the encrypted fields are unreadable - which is why it belongs in every backup; the archive carries it even when no file exists any more.

lcm credentials init (as root) moves the key from the file into a systemd credential:

Terminal window
sudo lcm credentials init # binding: TPM2 + host key if a TPM exists, otherwise host key
sudo lcm credentials init --with-key=tpm2
sudo systemctl restart lcm

The command encrypts the key with systemd-creds into /etc/credstore.encrypted/lcm.key, verifies that exactly the current key comes back, writes the unit drop-in LoadCredentialEncrypted=lcm.key:… and only then destroys lcm.key. At start systemd decrypts the credential into an in-memory directory only the service can see ($CREDENTIALS_DIRECTORY); the journal reports master key source=credential.

What it buys: no plaintext key in the data directory any more. A copy of the directory, an archive without passphrase, a stray rsync - all worthless. With TPM binding that also holds for an image of the whole machine; with host binding only (container without TPM) a container backup includes systemd’s host key - there only an encrypted container backup closes the gap. What it does not buy: whoever has root on the running machine still reaches the key; that is the price of unattended restarts.

Order at start: LCM_ENCRYPTION_KEY (explicit override), then lcm.key, then the credential. The file deliberately wins over the credential: after a restore or rotate-db-key it is back and belongs to the database. LCM then reports security event=masterkey.file-overrides-credential; running lcm credentials init again moves the new key into the credential and removes the file.

The same mechanism carries two more secrets, each as SetCredentialEncrypted= in the unit drop-in: trivy_token (instead of config.json/environment) and backup_passphrase (instead of LCM_BACKUP_PASSPHRASE). A credential takes precedence over file and environment.

For the backups themselves it goes one step further: with recipient keys (age, X25519) the archives are encrypted to public keys - the scheduled backup then needs no secret on the server at all, the private key stays offline with the operator. Whoever takes over the host gets the archives, but not the key to them. Setup in the backup guide.

Stored encrypted are, among others (full list in internal/storage/rotate.go):

  • SSH credentials: the server private key (servers.private_key_enc), the default SSH password, and the onboarding SSH key in the settings;
  • the RouterOS login password (servers.login_password_enc) for devices using password authentication;
  • users’ 2FA secrets (users.totp_secret_enc) and Linux-user passwords;
  • the system mailer SMTP password and notification-channel secrets (SMTP password or webhook URL);
  • CrowdSec credentials on the LCM host: the LAPI machine password (crowd_sec_lapi_password_enc) and console key (crowd_sec_console_key_enc);
  • the stored TLS key PEM.

Large console output (job/SSH output) as well as the server host/name go through a GORM serializer (aesgcm); the server name additionally carries a blind index derived from the master key for searching without storing the plaintext.

Rotation: the subcommand lcm rotate-db-key generates a new master key and re-encrypts all registered fields in one transaction - the DB never ends up in a mixed state. The server-name blind index is recomputed with the new key. Newly introduced encrypted columns must be registered in encryptedColumns (or serializerColumns) so that rotation picks them up.

Servers behind NAT connect outbound via lcm-agent (MQTT over WebSocket) on a separate, dedicated port (default 9320, internal/remote) - separate from UI/REST. The enrollment token is shown in plaintext only on first display; at rest only its hash is stored. The agent listener uses the same TLS certificate as the UI, whose fingerprint the agent pins during enrollment (MitM protection). Commands over the agent transport are subject to the same silence limit as the job watchdog: they are aborted when no output arrives for longer than the permitted time. Details: LCM Remote.

The optional MCP listener (internal/mcp, off by default, bind 127.0.0.1:9330) exposes read-only server properties to AI agents. Security-relevant points:

  • Dedicated scope: access only with an API key of scope mcp (bearer token); such keys work nowhere else.
  • Whitelist DTO: only the curated ServerView struct is serialized - never domain.Server. By construction it carries no passwords, login users, private/public keys, host-key fingerprints, or agent tokens.
  • No write tools: only list_servers, get_server, and fleet_summary - no configuring actions.
  • Toggleable at runtime: via Settings → MCP. The endpoint speaks HTTP; for remote access put a TLS-terminating reverse proxy in front. Details: MCP interface.
  • Error handling: internal errors only reach the client as a generic “internal server error” - no stack traces or SQL details.
  • Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy (see middlewares/security.go).
  • XSS: the frontend renders exclusively via Svelte templating (automatic escaping); {@html} is not used.
  • Build gates: make build aborts on npm audit or govulncheck findings.
  • Default bind: 127.0.0.1 - anyone exposing it externally deliberately sets "host": "0.0.0.0" and should terminate TLS via a reverse proxy (Caddy, nginx).
  • IP allowlist: allowed_ips in config.json restricts network access to allowed client addresses (keywords localhost/private or IP/CIDR); non-matching clients get an early 403 (IPAllowlist middleware, before auth/logging). Filtering uses the direct TCP connection; behind a reverse proxy set trust_proxy_header: true plus trusted_proxies (X-Forwarded-For counts from those peers only). The matcher lives in the internal/netfilter package. See Security & CVE Scans and Reverse proxy.
  • Body budget: 1 MiB per request, checked on the headers before the body is read (middlewares.BodyBudget, request streaming). The large 64 MiB limit applies to the backup upload alone. Bodies without a length (chunked) are rejected outside the upload.
  • Email address only with password: your own address receives the password reset. Changing it requires the current password - a stolen session alone cannot redirect the account to a foreign address.
  • Console ticket within scope: the ticket for the web console is issued only for servers the user may see - the console permission alone is not enough.
  • Security log: logins (login.ok, login.failed, login.locked, login.2fa.*), password and email changes, second factor, API keys and console tickets are written as one journal line with the fixed text security and the client address (middlewares.SecurityEvent). Successes are INFO, everything else WARN: journalctl -u lcm -p warning | grep security. Unlike the audit log in the database, the journal can be forwarded and is usable by fail2ban (event=login.failed ip=<HOST>).

Deliberate simplifications of the template

Section titled “Deliberate simplifications of the template”
  • No refresh-token flow: after token expiry a new login is required. For longer sessions, raise the TTL or add a refresh endpoint.
  • Token in localStorage: simple and appropriate for desktop/intranet apps. If you need stricter XSS protection, switch to httpOnly cookies (then add CSRF protection, e.g. the Fiber CSRF middleware).
  • No rate limiting: for exposed deployments, apply Fiber’s limiter middleware to /auth/login.