A CLI to audit, harden, and monitor the security of a Linux VPS.
vpsguard checks SSH configuration, firewall, fail2ban, user accounts,
authorized SSH keys, cron, automatic updates, and listening ports; it can
automatically fix anything with a safe, well-known remediation; and it can
watch the server over time, alerting when something appears that wasn't
there before (a new user, a new SSH key, a new port, etc.).
⚠️ vpsguardmodifies system configuration (sshd_config, firewall, fail2ban, file permissions). Always run--dry-runfirst and review what it would do before applying changes to a production server.
curl -fsSL https://raw.githubusercontent.com/salamancacm/vpsguard/main/install.sh | shThis detects your architecture, downloads the matching binary from the
latest release, verifies it against the published SHA-256 checksum
before installing (refuses to install on a mismatch), and puts it on your
PATH (/usr/local/bin if run as root, ~/.local/bin otherwise).
Piping curl into sh is a trust-on-first-use tradeoff — you're running
code from the network. If you'd rather not, read
install.sh first, or install from a binary by hand below.
Download the binary for your architecture from
Releases and put it on your PATH:
curl -Lo vpsguard https://github.com/salamancacm/vpsguard/releases/latest/download/vpsguard-linux-amd64
chmod +x vpsguard
sudo mv vpsguard /usr/local/bin/Requires Go 1.22+.
git clone https://github.com/salamancacm/vpsguard.git
cd vpsguard
go build -o vpsguard .
sudo mv vpsguard /usr/local/bin/Cross-compiling from another platform onto a Linux VPS:
GOOS=linux GOARCH=amd64 go build -o vpsguard-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o vpsguard-linux-arm64 .go install github.com/salamancacm/vpsguard@latestThis builds from source for whatever platform you run it on. Since
vpsguard only runs on Linux (it refuses to start otherwise — see
requireLinux()), running this on a non-Linux machine gets you a binary
you'd still need to cross-compile or transfer, not one you can use
locally; run it directly on the target Linux host instead, or use
GOOS=linux GOARCH=amd64 go install ... from elsewhere.
brew tap salamancacm/tap
brew install vpsguardOnly works under Homebrew-on-Linux ("Linuxbrew") — the formula refuses to install on macOS, since the resulting binary couldn't run there anyway.
Every release also publishes .deb and .rpm packages for amd64 and
arm64, built with nfpm (config:
nfpm.yaml). Download the one matching your distro from
Releases and install it directly:
# Debian / Ubuntu
curl -Lo vpsguard.deb https://github.com/salamancacm/vpsguard/releases/latest/download/vpsguard_<version>_amd64.deb
sudo apt install ./vpsguard.deb
# RHEL / Fedora / Rocky / AlmaLinux
curl -Lo vpsguard.rpm https://github.com/salamancacm/vpsguard/releases/latest/download/vpsguard-<version>-1.x86_64.rpm
sudo dnf install ./vpsguard.rpm(Replace <version> with the actual release version shown on the
Releases page — GitHub doesn't support wildcards in
/latest/download/ URLs.) Both packages just drop the binary at
/usr/bin/vpsguard; there's no service, config, or post-install step to
worry about.
When run in an interactive terminal, vpsguard shows a small banner and
prints each check's result as it runs instead of going quiet until the
end. Piped, redirected, or --json output is always the same plain,
stable format regardless — safe for scripts, cron, and CI.
sudo vpsguard initWalks through the usual first-run steps in one guided flow: runs audit,
offers to walk through harden (same per-check confirmation as running
it directly), offers to pin a baseline, and offers to install the
monitor cron entry. Nothing happens without an explicit yes at each
step — init doesn't skip any of the confirmations described below, it
just walks you to them instead of you having to know to run four
separate commands. Skip any step and run it by hand later; the sections
below cover each one on its own.
sudo vpsguard auditJSON output (handy for scripting/CI):
sudo vpsguard audit --jsonRun only some checks:
sudo vpsguard audit --check=ssh,firewallAvailable checks: ssh, firewall, fail2ban, users, sshkeys,
cron, updates, network, docker, kernel, cloud.
See what it would do, without touching anything:
sudo vpsguard harden --dry-runApply, confirming each step:
sudo vpsguard hardenApply everything without asking (for automation, use with care):
sudo vpsguard harden --yesEvery config file change is backed up before it's written
(file.bak.<timestamp>). Checks with automatic remediation: ssh,
firewall, fail2ban, sshkeys, updates. users, cron, network,
docker, kernel, and cloud are audit-only — they require human
judgement (cloud specifically requires changing an EC2 API setting from
outside the instance, which vpsguard has no way to do from inside it).
monitor saves a snapshot of server state on every run and compares it
against the previous one, reporting suspicious changes (new user, new SSH
key, new port, sudoers changes, new cron entry, new process running as
root, and a change to the SHA-256 of sshd/sudo/su/ssh or vpsguard itself).
sudo vpsguard monitorInstall the cron entry so it runs on its own (every 15 minutes by default):
sudo vpsguard install-cronSnapshots are stored at /var/lib/vpsguard/snapshot.json and the cron-driven
monitor log goes to /var/log/vpsguard-monitor.log.
Comparing against only the previous run has a blind spot: a binary
swapped back and forth between two monitor runs, or re-compromised
right after every check, can look unchanged forever once the tampered
hash becomes the new "previous run".
sudo vpsguard baselinepins the current hashes of the watched critical binaries (sshd, sudo,
su, ssh) as a fixed, trusted reference. Once set, every monitor run
also compares against it — and keeps flagging a mismatch on every
subsequent run until it's fixed and vpsguard baseline is run again, not
just once. Re-run it after vpsguard harden, vpsguard update, or any
legitimate package upgrade that touches a watched binary.
Every monitor run (after the first, which only seeds the initial
snapshot) appends one entry to /var/lib/vpsguard/audit.log — a
tamper-evident, append-only history of every run's findings. Each entry
embeds the hash of the entry before it, so editing or deleting any past
entry, including the most recent one, breaks the chain from that point
on.
vpsguard auditlog verifyrecomputes the chain from scratch and reports whether it's intact, or
exactly where it breaks. This can't, by itself, catch an attacker with
root replacing the entire log with a new, internally-consistent fake
chain — for that, compare a run's reported hash against one you saved
off-host from an earlier auditlog verify.
vpsguard update --check # just report whether a newer release exists
sudo vpsguard update # download, verify, and install itupdate never runs on its own — it's always an explicit command, same as
harden requiring --yes/confirmation. It checks the checksum published
alongside the release before replacing the running binary and refuses to
install on a mismatch.
Audit several hosts at once over SSH, from one place:
sudo vpsguard fleetList the targets under hosts: in the config file:
hosts:
- name: web-1
addr: 203.0.113.10
user: root
- name: db-1
addr: 203.0.113.11
user: root
port: 2222 # optional, defaults to 22fleet connects using your own SSH setup (keys, agent, ~/.ssh/config) —
vpsguard never handles credentials itself — and runs vpsguard audit --json on each host, in parallel (--concurrency, default 5). vpsguard
must already be installed on every target host. An unreachable host is
reported as an error for that host without failing the rest of the run.
--json gives an array of {host, addr, findings, error} per host.
An official image is published to GHCR on every release, for running
audit/fleet from CI without installing the binary on the runner
(the GitHub Action
wraps this same image):
docker run --rm ghcr.io/salamancacm/vpsguard:latest --help
docker run --rm --privileged ghcr.io/salamancacm/vpsguard:latest auditIt's a static binary on Alpine with openssh-client and
ca-certificates added, so fleet works out of the box — mount your
SSH key and config the same way you would for any other containerized
SSH client:
docker run --rm \
-v ~/.ssh/id_ed25519:/root/.ssh/id_ed25519:ro \
-v ./vpsguard-config.yaml:/etc/vpsguard/config.yaml:ro \
ghcr.io/salamancacm/vpsguard:latest fleetPin a specific version instead of :latest with the release tag, e.g.
ghcr.io/salamancacm/vpsguard:v0.5.0.
An optional /etc/vpsguard/config.yaml (or --config <path> on audit,
harden, and monitor) tunes vpsguard's behavior. Every field is
optional — an absent file, or an absent field within it, means "use the
default," same as before this existed.
# Skip these checks entirely in audit and harden — same as never passing
# them to --check.
disabled_checks:
- network
# Acknowledge a specific finding going forward. It still prints (with an
# [ACK] tag) and still appears in --json with its real severity — nothing
# is silently hidden — but it's excluded from the OK/WARN/CRIT summary
# tally, so the summary reflects only what still needs a decision.
accepted_findings:
- check: network
message_contains: "6379 (redis)" # substring match, not exact
# Override a check's built-in thresholds. Only `kernel` has tunable
# thresholds today.
thresholds:
kernel:
security_update_warn: 5
security_update_crit: 20
# Where `monitor` pushes findings when it detects a change — see below.
# Set any combination of these; every one that's configured gets used.
notify:
webhook_url: "https://hooks.slack.com/services/..." # generic Slack/Discord/Mattermost-compatible payload
slack_webhook_url: "https://hooks.slack.com/services/..." # richer, color-coded Slack message
discord_webhook_url: "https://discord.com/api/webhooks/..." # richer, color-coded Discord embed
telegram_bot_token: "123456:AAExampleTokenTextGoesHere"
telegram_chat_id: "-1001234567890"
email_to: "you@example.com"
min_severity: "WARN"
# Targets for `vpsguard fleet` — see above.
hosts:
- name: web-1
addr: 203.0.113.10
user: rootBy default monitor only prints to stdout/the log file — nobody reads
that proactively, so configure one or more of the notify.* settings
(above) to actually get pinged when something changes. All of them are
optional and independent — set any combination, and every one that's
configured gets used:
webhook_urlposts a flat JSON payload with both atextandcontentfield, which covers Slack, Discord, Mattermost, and most other webhook-compatible chat tools with one setting.slack_webhook_url/discord_webhook_urlpost to that platform's native format instead — a colored Slack attachment or Discord embed (red for CRIT, yellow/orange for WARN) — so a bad finding actually stands out in the channel. Use these instead ofwebhook_urlif you want that, not in addition to it.telegram_bot_token+telegram_chat_idsend via a Telegram bot (see @BotFather to create one and get a chat ID). Both must be set together.email_torequiressendmailor mailutils'mailto already be available.
A broken webhook, bad Telegram credentials, or missing mail transport
prints a warning but never makes monitor itself fail.
| Check | What it checks |
|---|---|
ssh |
PermitRootLogin, PasswordAuthentication, port, MaxAuthTries |
firewall |
ufw/nftables/iptables active with a default-deny policy |
fail2ban |
installed, active, sshd jail enabled |
users |
UID 0 accounts besides root, empty passwords, /etc/sudoers.d |
sshkeys |
permissions on ~/.ssh and authorized_keys, number of trusted keys |
cron |
user crontabs and /etc/cron.* (informational) |
updates |
automatic security updates active |
network |
listening TCP/UDP ports; flags non-standard ones, and CRITs on database ports (postgres/mysql/redis/mongo/elasticsearch) bound to all interfaces |
docker |
Docker socket permissions, an unauthenticated TCP daemon listener, per-container issues (--privileged, running as root, ports published to all interfaces — Docker's iptables rules bypass ufw/firewalld, so these aren't caught by the firewall/network checks), and users in the docker group (root-equivalent access) |
kernel |
pending reboot for a newer kernel, count of pending security package updates |
cloud (beta) |
on AWS EC2, whether the instance metadata service (IMDS) still accepts unauthenticated IMDSv1-style requests (the Capital One breach vector) — a no-op on anything that isn't AWS EC2 |
Findings tagged [BETA] in the output come from a check that's real
and tested, but hasn't been validated against the actual real-world
system it targets (e.g. cloud has never run against a real AWS
account — see its tests for what has
been verified). Not a comment on code quality, just an honest nudge to
double-check a beta finding yourself before acting on it. --json and
--check/disabled_checks work on beta checks exactly like any other —
nothing is hidden or excluded by default.
- Linux (Debian/Ubuntu or RHEL/Fedora/Rocky/AlmaLinux)
- Most commands need root
Issues and pull requests are welcome. See CONTRIBUTING.md for how to get started. This project especially benefits from outside review since it touches system security configuration — if you find an incorrect check or an unsafe remediation, please open an issue.
Found a security issue? Please see SECURITY.md for how to report it responsibly.
