Hardening guide
Ordered roughly by effort-to-benefit. Everything here is optional; none of it is done for you beyond the file permissions the installer sets.
Verify config permissions
Do this first, and after every install or upgrade.
ls -l /usr/local/bin/vm_autoscale/config.yaml
# want: -rw------- 1 root rootIf it shows anything broader:
sudo chown root:root /usr/local/bin/vm_autoscale/config.yaml
sudo chmod 600 /usr/local/bin/vm_autoscale/config.yaml
sudo chown -R root:root /etc/vm_autoscale
sudo chmod 700 /etc/vm_autoscale
sudo chmod 600 /etc/vm_autoscale/config.yaml.backupIf you installed before the permissions fix
The installer's recursive chmod 755 left the config world-readable, meaning every local user could read your Proxmox root password. Fix the permissions and rotate that password and any SMTP credentials — assume they were exposed for as long as the file was readable.
Use a dedicated SSH key
Do not reuse an existing admin key.
sudo ssh-keygen -t ed25519 -f /root/.ssh/vm_autoscale_ed25519 -N "" \
-C "vm-autoscale@$(hostname -f)"
sudo chmod 600 /root/.ssh/vm_autoscale_ed25519sudo ssh-copy-id -i /root/.ssh/vm_autoscale_ed25519.pub root@10.0.0.11proxmox_hosts:
- name: pve1
host: 10.0.0.11
ssh_user: root
ssh_key: /root/.ssh/vm_autoscale_ed25519
ssh_port: 22
# ssh_password intentionally absent — it would override the keyThe key must be unencrypted
Ed25519, ECDSA, RSA and DSS key types all load. There is no passphrase option, though, so an encrypted key cannot be used — protect it with file permissions instead.
Remove ssh_password entirely — leaving it set means the key is ignored.
Restrict what the key can do
You cannot get to true least privilege — the service genuinely needs qm set — but you can bound the key.
# /root/.ssh/authorized_keys on each Proxmox node
from="10.0.0.5",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ssh-rsa AAAA...from= is the useful part: the key becomes worthless from anywhere but the autoscaler's address. no-pty prevents interactive use while still allowing command execution.
A forced command is tempting but impractical here, since the service issues several different commands with variable arguments. A wrapper script that whitelists qm status|config|set and pvesh get for specific VMIDs is possible if you want to invest in it.
Verify SSH host keys
The default is accept-new: the service records each node's host key the first time it connects and refuses to connect if that key later changes.
ssh_host_key_policy: accept-new
ssh_known_hosts: /etc/vm_autoscale/known_hostsThat leaves one window open — the first connection is trust-on-first-use. To close it, pin the keys yourself and switch to strict:
sudo install -d -m 700 /etc/vm_autoscale
for h in 10.0.0.11 10.0.0.12; do
ssh-keyscan -H "$h" | sudo tee -a /etc/vm_autoscale/known_hosts
done
sudo chmod 600 /etc/vm_autoscale/known_hostsVerify the fingerprints out of band
ssh-keyscan trusts whatever answers. Compare what it collected against the fingerprints printed on each node's console:
# On the Proxmox node
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubssh_host_key_policy: strictUnder strict a node missing from ssh_known_hosts is refused, so remember to add new nodes before adding them to proxmox_hosts.
A mismatch is fatal and is not retried:
[ERROR] Host key mismatch for 10.0.0.11: the server presented a key that does
not match the one recorded in /etc/vm_autoscale/known_hosts.If a node was legitimately rebuilt, remove its line from the file. Otherwise investigate before doing anything else — that message is what an interception attempt looks like.
Still worth doing alongside:
- Segment the network. Put management SSH on a dedicated VLAN reachable only from the autoscaler.
- Use addresses, not names.
host: 10.0.0.11removes DNS from the attack path.
ssh_host_key_policy: auto disables all of this
It reproduces the old behaviour — accept any key, every time, remember nothing. It exists as an escape hatch and logs a warning on every connection.
Confine the systemd unit
The shipped unit runs as root with no sandboxing. A drop-in adds meaningful confinement without touching the packaged file:
sudo systemctl edit vm_autoscale.service[Service]
# Filesystem
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log /var/log/vm_autoscale
ReadOnlyPaths=/usr/local/bin/vm_autoscale
# Privileges
NoNewPrivileges=true
RestrictSUIDSGID=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
# Network — outbound only, no unusual address families
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Restart behaviour
RestartSec=10sudo systemctl daemon-reload
sudo systemctl restart vm_autoscale.service
sudo systemctl status vm_autoscale.service
systemd-analyze security vm_autoscale.serviceTest this
ProtectSystem=strict makes the whole filesystem read-only except ReadWritePaths. If you changed log_file or csv_output_dir, add those paths. Watch the first cycle after applying it.
Running the service on a Proxmox node itself limits how far you can go — qm needs access that heavy confinement would break. The directives above are safe because the service only SSHes out; it does not run qm locally.
Protect the log
The log names your nodes and VMs and reveals capacity patterns.
sudo chmod 640 /var/log/vm_autoscale.log
sudo chown root:adm /var/log/vm_autoscale.logRotate it — nothing does by default:
# /etc/logrotate.d/vm_autoscale
/var/log/vm_autoscale.log {
weekly
rotate 8
compress
delaycompress
missingok
notifempty
copytruncate
create 0640 root adm
}copytruncate is required: the service holds the file open for its lifetime.
For an audit trail, ship the journal somewhere append-only:
# /etc/rsyslog.d/50-vm-autoscale.conf
if $programname == 'python3' and $msg contains 'vm_autoscale' then @@logs.internal.example:514Firewall the outbound path
The service needs exactly three kinds of outbound connection: SSH to your nodes, HTTPS to Gotify, SMTP to your relay. Nothing else.
# nftables sketch — adapt to your ruleset
nft add rule inet filter output ip daddr 10.0.0.0/24 tcp dport 22 accept
nft add rule inet filter output ip daddr 10.0.1.20 tcp dport 443 accept
nft add rule inet filter output ip daddr 10.0.1.25 tcp dport 587 acceptEgress filtering is what turns "the autoscaler was compromised" into "the autoscaler was compromised and could only talk to three known hosts".
Keep dependencies current
pip3 install pip-audit
pip-audit -r /usr/local/bin/vm_autoscale/requirements.txtDependabot watches the repository weekly. On your host you have to run the upgrade yourself:
pip3 install --upgrade paramiko PyYAML requests
sudo systemctl restart vm_autoscale.serviceDo not install by piping curl into bash
# Fetch, read, then run
curl -fsSL https://raw.githubusercontent.com/fabriziosalmi/proxmox-vm-autoscale/main/install.sh \
-o /tmp/install.sh
less /tmp/install.sh
sudo bash /tmp/install.shBetter still, pin to a release rather than tracking main:
sudo git clone --branch v1.7.2 --depth 1 \
https://github.com/fabriziosalmi/proxmox-vm-autoscale /usr/local/bin/vm_autoscaleRotate credentials
Nothing rotates automatically. On a schedule that suits you:
# New key
sudo ssh-keygen -t ed25519 -f /root/.ssh/vm_autoscale_ed25519.new -N ""
sudo ssh-copy-id -i /root/.ssh/vm_autoscale_ed25519.new.pub root@10.0.0.11
# Update config.yaml, restart, verify a full cycle, then remove the old key
# from authorized_keys on every nodeRotate immediately if config.yaml was ever readable by a non-root user, if the host was compromised, or if someone with access has left.
Checklist
- [ ]
config.yamlis600, root-owned - [ ]
/etc/vm_autoscaleis700, backup inside is600 - [ ] Key authentication, dedicated key,
ssh_passwordremoved - [ ]
ssh_host_key_policy: strictwith a verifiedssh_known_hosts - [ ]
from=restriction on the key inauthorized_keys - [ ] Management SSH on a segmented network
- [ ] systemd hardening drop-in applied and verified
- [ ] Log permissions set and rotation configured
- [ ] Outbound firewall rules in place
- [ ]
pip-auditclean - [ ] Installed from a pinned release, not
curl | bash - [ ] Credential rotation scheduled