Skip to content

Repository files navigation

vpsguard

CI Latest release Go version License: MIT

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.).

⚠️ vpsguard modifies system configuration (sshd_config, firewall, fail2ban, file permissions). Always run --dry-run first and review what it would do before applying changes to a production server.

vpsguard audit, harden, and monitor catching a simulated SSH key intrusion

Installation

Install script

curl -fsSL https://raw.githubusercontent.com/salamancacm/vpsguard/main/install.sh | sh

This 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.

From a binary

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/

Building from source

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

go install github.com/salamancacm/vpsguard@latest

This 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.

Homebrew (Linux only)

brew tap salamancacm/tap
brew install vpsguard

Only works under Homebrew-on-Linux ("Linuxbrew") — the formula refuses to install on macOS, since the resulting binary couldn't run there anyway.

APT / RPM

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.

Usage

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.

Quick start

sudo vpsguard init

Walks 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.

Audit (read-only)

sudo vpsguard audit

JSON output (handy for scripting/CI):

sudo vpsguard audit --json

Run only some checks:

sudo vpsguard audit --check=ssh,firewall

Available checks: ssh, firewall, fail2ban, users, sshkeys, cron, updates, network, docker, kernel, cloud.

Hardening

See what it would do, without touching anything:

sudo vpsguard harden --dry-run

Apply, confirming each step:

sudo vpsguard harden

Apply everything without asking (for automation, use with care):

sudo vpsguard harden --yes

Every 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).

Continuous monitoring

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 monitor

Install the cron entry so it runs on its own (every 15 minutes by default):

sudo vpsguard install-cron

Snapshots are stored at /var/lib/vpsguard/snapshot.json and the cron-driven monitor log goes to /var/log/vpsguard-monitor.log.

Pinning a binary baseline

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 baseline

pins 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.

Audit log

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 verify

recomputes 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.

Updating

vpsguard update --check   # just report whether a newer release exists
sudo vpsguard update      # download, verify, and install it

update 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.

Fleet mode

Audit several hosts at once over SSH, from one place:

sudo vpsguard fleet

List 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 22

fleet 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.

Docker image

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 audit

It'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 fleet

Pin a specific version instead of :latest with the release tag, e.g. ghcr.io/salamancacm/vpsguard:v0.5.0.

Configuration

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: root

Notifications

By 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_url posts a flat JSON payload with both a text and content field, which covers Slack, Discord, Mattermost, and most other webhook-compatible chat tools with one setting.
  • slack_webhook_url / discord_webhook_url post 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 of webhook_url if you want that, not in addition to it.
  • telegram_bot_token + telegram_chat_id send via a Telegram bot (see @BotFather to create one and get a chat ID). Both must be set together.
  • email_to requires sendmail or mailutils' mail to already be available.

A broken webhook, bad Telegram credentials, or missing mail transport prints a warning but never makes monitor itself fail.

Audit checks

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.

Requirements

  • Linux (Debian/Ubuntu or RHEL/Fedora/Rocky/AlmaLinux)
  • Most commands need root

Contributing

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.

Security

Found a security issue? Please see SECURITY.md for how to report it responsibly.

License

MIT

About

Audit, harden, and monitor the security of a Linux VPS

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages