Security model
On this page
LWS logs in to Proxmox VE hosts as root and runs commands there. This page describes what that gives access to, where the trust boundaries are, how LWS protects values and secrets on the way, and what it leaves to you.
What LWS can do
For each availability zone under regions in config.yaml, LWS opens an SSH
connection as the zone’s user, normally root, with the zone’s
ssh_password passed to sshpass. On the host it runs pct for containers,
pvesh for firewall security groups, vzdump for backups and apt-get for
host updates, and px exec runs any command you give it. Inside containers,
commands run through pct exec as the container’s root. With
use_local_only: true, most of these commands run on the local machine
instead, which then has to be the Proxmox host itself.
The consequence is simple: whoever can run LWS with your config.yaml, or
call the REST API with its key, has root on every host in that file. The
security policy
treats this as by design.
Trust boundaries
The machine that runs LWS
Root on this machine, and the account that runs LWS, can read config.yaml
and act as LWS. Use a machine that only administrators can log in to, keep it
patched, and run LWS under its own account.
config.yaml
The file holds the SSH password of every host and the API key in plain text.
LWS does not encrypt it and does not read secrets from environment variables.
Keep it mode 0600, owned by the account that runs LWS, and out of version
control (the repository’s .gitignore excludes it):
chmod 600 config.yaml
lws conf backup /backup/lws-config.yaml --timestamp
lws conf backup writes its copy with mode 0600, also when it replaces an
existing file. The copy is still in clear text, and --compress only
compresses it. lws conf show prints the file with values of keys containing
password, secret or key masked.
The network path to the hosts
SSH encrypts the connection, including the password. LWS calls ssh with
StrictHostKeyChecking=accept-new: the first time it connects to a host, it
accepts the host’s key and stores it in ~/.ssh/known_hosts of the account
that runs LWS; afterwards, a different key for that host is refused with
Host key verification failed, and the command does not run.
That first connection is trust on first use. Make it over a network you
trust, then compare the stored fingerprint (ssh-keygen -lF <host> as the
LWS account) with the one shown on the host’s console
(ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub). known_hosts belongs to
an account: the CLI run as you and the API run as a service account trust
separately, and a container keeps it only on a volume.
A changed key usually means the host was reinstalled. If you cannot explain it, do not remove the old key: something else may be answering in place of your host. Troubleshooting shows how to replace a key you know has changed.
The REST API
- The API key is equivalent to root on every configured host. It also lets
a client write a copy of
config.yamlto any path the API’s account can write (POST /api/v1/conf/backup), and send any file that account can read to a host (POST /api/v1/px/upload). - The API refuses to start without a key or with an example value from the
repository, and warns when the key is shorter than 32 characters. It
compares keys in constant time (
hmac.compare_digest). - It listens on
127.0.0.1by default. It has no TLS of its own: across a network, the key travels in clear text unless a TLS proxy is in front. - Browsers on other origins are refused unless
api.allowed_originslists them (CORS). This does not affectcurlor scripts. - Error responses do not include exception details; those go to
api.log.
Running the API in production covers the proxy, the service account and the checklist.
How LWS keeps values out of shells
OpenSSH joins every argument after user@host into one string, which the
host’s shell parses again. An argument list that is safe for a local
subprocess call is therefore not safe over SSH:
pct exec 100 -- ls && reboot would run reboot on the host. LWS uses two
layers against this.
- Allow-list validation. Values that end up in host commands are checked
first: container IDs are digits only; names of security
groups and storages use letters, digits,
-and_; template names, host names, service names, paths, ports and protocols have their own patterns; IP addresses and CIDRs must parse. A rejected value stops the command with aninvalid ...message before anything runs. - Quoting.
run_argvsends the remote copy of each command throughshlex.join, so the host’s shell sees exactly the intended arguments.lxc runquotes its free-text values (host name, password, DNS,net0) withshlex.quotein the same way.
Two commands run arbitrary commands on purpose:
px exechands the command to the host’s shell as written, so pipes,&&and redirections work. It is root command execution on the host.lxc execsplits the command into words and runs it in the container without a shell;&&or|arrive as plain arguments. Call a shell explicitly for a pipeline:
lws lxc exec 100 "sh -c 'apt-get update && apt-get -y upgrade'"
The REST API starts lws.py without a shell, one argument per value, and
refuses requests whose command words (IDs, names, the command of the exec
endpoints) contain any of ; & | ` $ ( ) { }. It also rejects
non-numeric instance IDs in URLs. This keeps shell syntax out of commands; it
is not a sandbox, since /api/v1/px/exec still runs any command on the host.
Secrets in process lists and logs
- The SSH password reaches
sshpassthrough theSSHPASSenvironment variable (sshpass -e), not-p, so it does not appear in the command lines thatpsshows to other users. The environment of a process is readable by its own account and root only. - LWS does not write the SSH password to its logs.
lxc run --passworddoes not pass the container’s root password topct create. Once the container runs, LWS sets it withchpasswdinside the container, which reads it from standard input, so it appears neither in the Proxmox host’s process list nor in LWS’s logs. It is still an argument oflwsitself, so it stays in the shell history and the process list of the machine you typed it on. Leave it out when you do not need it.- The API logs each command before running it, with the values of options
whose name contains
password,secret,tokenorkeyreplaced by***. The text ofexeccommands is logged as sent, and atapi.log_level: DEBUGthe API also logs the output of every command. - The CLI writes errors to
lws.logandlws.json.login the current directory. They can include the error output of failed host commands.
Containers: privileged and unprivileged
lxc run creates a privileged container unless you pass --unprivileged.
In a privileged container, the container’s root is user ID 0 on the host
kernel, so an escape from the container is root on the host. An unprivileged
container maps its root to an unprivileged user ID range on the host.
Prefer unprivileged containers:
lws lxc run --image-id local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst --unprivileged
Docker inside a container needs the LXC feature nesting=1, plus
keyctl=1 in an unprivileged container. lws app setup 100 --enable-nesting
sets them and restarts the container. Nesting exposes more of the host’s
/proc and /sys to the container and weakens its isolation, much more so
in a privileged one. For workloads you do not trust, a virtual machine is the
stronger boundary; LWS does not manage virtual machines. See
Docker in LXC.
What LWS does not do
- SSH keys. LWS logs in with passwords only, so each host must accept password logins for the configured user.
- Roles or per-user access. One
config.yaml, and one API key, give full access to every host listed. There is no read-only key. - Proxmox permissions. LWS does not use Proxmox users, roles or API tokens. It acts as the SSH user, so Proxmox’s permission system does not limit what it does.
- Audit log. Besides the log files above, nothing records who did what.
api.loghas the commands and the client address (the proxy’s, behind a proxy), not a user name. - Encryption at rest.
config.yamland its backups are plain text. - TLS and rate limiting in the API. A reverse proxy provides both.
Recommendations
- Run LWS on a dedicated management machine or VM, under its own account.
- Firewall the hosts’ SSH port so that it accepts connections only from that
machine and your administration network. Do not expose it to the internet.
If root password logins are only needed for LWS, limit them to its
address with a
Match Addressblock in the hosts’sshd_config. - Give every host a long, unique root password, and change it when someone who knew it leaves.
- Keep the API on loopback, behind a proxy with TLS and its own authentication.
- Create containers with
--unprivileged, and enable nesting only where Docker runs. - If you need limited access, such as read-only monitoring or a team restricted to some resources, use Proxmox API tokens with roles directly, not LWS.
- To separate teams, give each its own
config.yamllisting only its hosts, run by its own account and, for the API, its own instance and key. Root on one node of a Proxmox cluster controls the whole cluster, so separate by cluster, not by node. - Keep LWS up to date: only the latest release receives security fixes.
Reporting a vulnerability
Do not open a public issue. Follow the
security policy:
on GitHub, open the repository’s Security tab and choose
Report a vulnerability, which creates a private advisory. The policy
lists what to include and what is in scope; issues that need the API key or
config.yaml are out of scope, since both grant root by design.