diff --git a/_blocks/deploy-hetzner-cloud.md b/_blocks/deploy-hetzner-cloud.md new file mode 100644 index 0000000..00c012f --- /dev/null +++ b/_blocks/deploy-hetzner-cloud.md @@ -0,0 +1,50 @@ +# DEPLOY — Hetzner Cloud (CX22 / CAX11 + TF + Cloud Firewall) + +**Why Hetzner:** cheapest EU VPS with reputable network. CX22 (x86, 2 vCPU / 4 GB / 40 GB) = **€3.79/mo + VAT**; CAX11 (Ampere ARM64, 2 vCPU / 4 GB / 40 GB) = **€3.79/mo + VAT**. Prices verified on [VERIFIED 2026-04-21]. Hourly billing caps at the monthly rate — safe to spin down for tests. + +**Terraform provider:** `hetznercloud/hcloud` (official). Pin version: +```hcl +terraform { + required_providers { + hcloud = { source = "hetznercloud/hcloud", version = "~> 1.49" } + } +} +provider "hcloud" { token = var.hcloud_token } +``` +Token via env: `export HCLOUD_TOKEN=$(grep ^HCLOUD_TOKEN ~/.claude/secrets/.env | cut -d= -f2)`. **NEVER commit the token** (RULE 0.8 — see `domain-has-secrets.md`). + +**Minimal `hcloud_server` resource:** +```hcl +resource "hcloud_server" "node" { + name = "kei-${var.env}-${var.role}" + image = "debian-12" + server_type = var.arch == "arm64" ? "cax11" : "cx22" + location = var.location # fsn1 / nbg1 / hel1 / ash / hil / sin + ssh_keys = [hcloud_ssh_key.admin.id] + user_data = file("${path.module}/cloud-init.yaml") + firewalls { firewall_id = hcloud_firewall.base.id } + labels = { project = "kei", env = var.env } +} +``` +`ssh_keys` is **mandatory** — passing it disables the root password e-mail path. + +**Cloud Firewall (stateful, IN by default DENY):** +```hcl +resource "hcloud_firewall" "base" { + name = "kei-base" + rule { direction = "in" protocol = "tcp" port = "22" source_ips = var.admin_cidrs } + rule { direction = "in" protocol = "icmp" source_ips = ["0.0.0.0/0", "::/0"] } + # Add app ports (443, 80) only when an app is deployed behind the node. +} +``` +Attach to the server via `firewalls { firewall_id = … }`. Cloud Firewall is the FIRST line of defense — it drops traffic before it hits the VM's ufw (see `security-firewall-ufw.md`). Both layers MUST agree. + +**Locations:** `fsn1` (Falkenstein DE), `nbg1` (Nürnberg DE), `hel1` (Helsinki FI), `ash` (Ashburn US), `hil` (Hillsboro US), `sin` (Singapore). Pick region closest to users; ARM64 `cax*` available in EU only [VERIFIED 2026-04-21]. + +**Snapshots + rescue:** `hcloud_snapshot` for golden images; `hcloud server enable-rescue` before SSH lockout recovery. Back up `user_data` and TF state (remote backend: S3-compatible such as R2). + +**Primitives provided by KeiSeiKit:** +- `_primitives/provision-hetzner.sh` — wrapper around `hcloud` CLI, idempotent create/destroy, checks existing server by name first. +- Complement with `_primitives/harden-base.sh` run over SSH after first boot. + +**Forbidden:** hcloud token in `.tf` or `.tfvars` committed to git; Cloud Firewall with port 22 open to `0.0.0.0/0`; creating servers with `keep_disk = false` then snapshotting (destroys data); using Hetzner Storage Boxes for anything needing low latency (they're SFTP-over-WAN). diff --git a/_blocks/deploy-vps-generic.md b/_blocks/deploy-vps-generic.md new file mode 100644 index 0000000..8807f87 --- /dev/null +++ b/_blocks/deploy-vps-generic.md @@ -0,0 +1,79 @@ +# DEPLOY — Generic VPS (provider-agnostic cloud-init + ssh-first-contact) + +**Target providers:** DigitalOcean Droplets, Vultr, UpCloud, Linode/Akamai. Each has slightly different Terraform providers + CLIs, but the Day-0 contract is identical: **boot a Debian/Ubuntu image with a cloud-init user-data blob; add one admin SSH key; nothing else.** + +**Day-0 cloud-init blob (`cloud-init.yaml`) — universal:** +```yaml +#cloud-config +hostname: kei-${env}-${role} +timezone: UTC +package_update: true +package_upgrade: true +packages: + - ufw + - fail2ban + - unattended-upgrades + - auditd + - needrestart + - curl + - jq +users: + - name: keiadmin + groups: sudo + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - ${ADMIN_PUBKEY} +ssh_pwauth: false +disable_root: true +write_files: + - path: /etc/ssh/sshd_config.d/99-kei.conf + permissions: '0644' + content: | + PasswordAuthentication no + PermitRootLogin no + MaxAuthTries 3 + AllowUsers keiadmin + ClientAliveInterval 120 + ClientAliveCountMax 2 +runcmd: + - [ systemctl, restart, ssh ] + - [ ufw, default, deny, incoming ] + - [ ufw, default, allow, outgoing ] + - [ ufw, allow, 22/tcp ] + - [ ufw, --force, enable ] +``` +The blob is intentionally provider-neutral. Provider-specific bits (private-network bring-up, metadata service quirks) go in a short appendix the provisioner appends. See `_primitives/harden-base.sh` for post-boot hardening re-runs. + +**SSH-first-contact (`ssh-first-contact.sh` pattern):** +```bash +# Wait for cloud-init to finish AND sshd to be ready on the new IP. +for i in $(seq 1 60); do + ssh -o ConnectTimeout=3 -o StrictHostKeyChecking=accept-new \ + "keiadmin@$IP" "cloud-init status --wait" && break + sleep 5 +done +ssh "keiadmin@$IP" "sudo test -f /var/lib/cloud/instance/boot-finished" +``` +`StrictHostKeyChecking=accept-new` is OK only for the FIRST contact (TOFU). Store the fingerprint to `~/.ssh/known_hosts`; subsequent connects use default strict mode. Never use `StrictHostKeyChecking=no` — accepts MitM silently. + +**Terraform skeleton (provider-agnostic via vars):** +```hcl +variable "provider_kind" {} # "digitalocean" | "vultr" | "upcloud" | "linode" +variable "region" {} +variable "size_slug" {} # provider-specific size id +variable "admin_pubkey" {} # raw ssh-ed25519 … +locals { + user_data = templatefile("${path.module}/cloud-init.yaml", { ADMIN_PUBKEY = var.admin_pubkey }) +} +# ... then a module-per-provider resource that all read `local.user_data` +``` +Keep TF state **local per-env-per-dev by default**; upgrade to remote backend (R2, S3, Terraform Cloud) only when ≥ 2 humans share state. + +**Per-provider gotchas (verified 2026-04-21):** +- **DigitalOcean:** Marketplace "Docker" images skip unattended-upgrades — start from plain Debian 12 instead. IPv6 requires `ipv6 = true` on the droplet. +- **Vultr:** `vultr-cli` needs `VULTR_API_KEY`; default firewall is OPEN — attach a firewall group or rely solely on ufw. +- **UpCloud:** IPs rotate on full stop+start unless you request `floating_ip`. Finnish ASN often preferred over Hetzner in RU-routed workloads (see `project-vortex.md`). +- **Linode:** cloud-init runs before disk resize on some plans → `growpart` may need a rerun on first `ssh`. + +**Forbidden:** baking the admin private key into an AMI/snapshot; reusing one SSH keypair across envs; letting cloud-init pull scripts from a mutable URL (`curl … | bash` in `runcmd:` — pin to a hash); running `apt-get dist-upgrade -y` in `runcmd` without `needrestart` to surface pending reboots. diff --git a/_blocks/security-audit-logging.md b/_blocks/security-audit-logging.md new file mode 100644 index 0000000..27009e3 --- /dev/null +++ b/_blocks/security-audit-logging.md @@ -0,0 +1,77 @@ +# SECURITY — Audit Logging (auditd + journald forwarding) + +**Goal:** every privileged action (sudo, ssh login, sensitive file edit) leaves a tamper-evident trail that survives the VM being reimaged. + +**Stack:** +- `auditd` — Linux kernel audit framework, writes to `/var/log/audit/audit.log` in human-unfriendly but machine-parseable K/V format. +- `journald` — systemd's binary journal (`/var/log/journal/`), captures stdout/stderr of every service plus syslog stream. +- **Off-box shipping** (optional but recommended) — forward journald to a remote log collector (Loki, Vector, rsyslog+TLS). Local logs are destroyed on reimage. + +**Install + enable:** +``` +sudo apt install -y auditd audispd-plugins +sudo systemctl enable --now auditd +``` + +**Reference `/etc/audit/rules.d/99-kei.rules`:** +``` +# KeiSeiKit audit baseline — pinned 2026-04-21. Loaded by augenrules on boot. +## 1. SSH events +-w /etc/ssh/sshd_config -p wa -k sshd_config +-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config +-w /root/.ssh/ -p wa -k ssh_keys_root +-w /home/keiadmin/.ssh/ -p wa -k ssh_keys_admin + +## 2. Sudo events +-w /etc/sudoers -p wa -k sudoers +-w /etc/sudoers.d/ -p wa -k sudoers +-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k sudo_root + +## 3. Privilege / identity changes +-w /etc/passwd -p wa -k identity +-w /etc/group -p wa -k identity +-w /etc/shadow -p wa -k identity +-w /etc/gshadow -p wa -k identity + +## 4. Loading / unloading kernel modules +-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k module + +## 5. Time changes (detect attempts to skew audit timestamps) +-a always,exit -F arch=b64 -S adjtimex -S settimeofday -S clock_settime -k time +-w /etc/localtime -p wa -k time + +## 6. Make the config itself immutable (place LAST) +-e 2 +``` +`-e 2` locks the ruleset until reboot (tamper-resistant). Load with `sudo augenrules --load && sudo systemctl restart auditd`. Test with `sudo ausearch -k sshd_config | tail`. + +**Human-readable summaries:** `sudo aureport -au` (auth events), `aureport -m` (module loads), `aureport -k` (keyed rule hits). Use these in incident response; raw `audit.log` is only for ingest pipelines. + +**journald tuning — `/etc/systemd/journald.conf.d/99-kei.conf`:** +``` +[Journal] +Storage=persistent +Compress=yes +SystemMaxUse=500M +SystemKeepFree=1G +MaxFileSec=1week +ForwardToSyslog=no +``` +`Storage=persistent` creates `/var/log/journal/` — without it, `journalctl` history disappears on reboot. `MaxFileSec=1week` rotates weekly; combine with off-box shipping so you don't lose events. + +**Off-box shipping patterns:** +- **systemd-journal-upload** — built-in, ships via HTTPS to a `systemd-journal-remote` receiver. Mutual-TLS recommended. +- **Vector** () — pull from `journald` source, push to Loki/S3/syslog-TLS. Modern, Rust-native. Uses `/run/log/journal/` + unix socket. +- **rsyslog → remote** — legacy path; useful if you already operate a syslog collector. + +Any choice: use TLS, authenticate the receiver, do NOT push cleartext logs across the internet. Logs often contain secrets even when the app tries not to log them. + +**Failure-mode handling:** `auditd` can be configured to panic the kernel when the audit queue fills — reasonable for high-compliance, DANGEROUS for general VMs. Default `/etc/audit/auditd.conf` has `disk_full_action = SUSPEND` and `disk_error_action = SUSPEND` — keep these; tune to `HALT` only if regulatory driver requires it. + +**Verification (skill Phase 5):** +- `sudo auditctl -l` returns the non-empty rule list. +- `systemctl is-active auditd` = `active`. +- `journalctl --disk-usage` shows a non-zero persistent journal. +- (Optional) an off-box log-receiver shows entries within the last N minutes. + +**Forbidden:** deleting `/var/log/audit/audit.log` or `/var/log/journal/*` on a live host (breaks chain-of-custody); running auditd with `-e 0` (unlocked, attacker can disable the kernel audit); shipping logs in cleartext; logging secrets (app-level concern — redact before `logger()`); disabling persistent journald. diff --git a/_blocks/security-firewall-ufw.md b/_blocks/security-firewall-ufw.md new file mode 100644 index 0000000..95649de --- /dev/null +++ b/_blocks/security-firewall-ufw.md @@ -0,0 +1,62 @@ +# SECURITY — Firewall (ufw default-deny + rate limiting + nftables alt) + +**Posture — default-deny-in / allow-out:** +``` +ufw default deny incoming +ufw default allow outgoing +ufw default deny routed # do NOT forward unless explicitly routing +ufw limit 22/tcp comment 'ssh (rate-limited: 6 conn / 30s)' +ufw logging medium +ufw --force enable +``` +`ufw limit` = per-source-IP brute-force mitigation at the kernel level (iptables `recent` module). Use for SSH — *never* use it for app traffic (false positives on shared-NAT clients). + +**Layer ordering (read top-down):** +1. **Cloud Firewall** (Hetzner Cloud Firewall / AWS Security Group / DO Firewall) — drops at the provider edge, BEFORE packets hit the VM. Cheapest layer. +2. **ufw** on the VM — defence in depth; also covers provider-firewall misconfigs and private-network paths. +3. **App-level auth** — sshd keys, TLS client certs, app tokens. + +Both the Cloud Firewall AND ufw must agree on the port allow-list. A mismatch means "it works from provider console but not from Tailscale" or vice-versa. Use `_primitives/_rust/firewall-diff/` to compare intended rules (YAML) against running `ufw status`. + +**Intended-rules YAML schema (`firewall-intent.yaml`):** +```yaml +default: + incoming: deny + outgoing: allow + routed: deny +rules: + - port: 22 + proto: tcp + action: limit + from: any + comment: "ssh (rate-limited)" + - port: 443 + proto: tcp + action: allow + from: any + comment: "https / caddy" + - port: 80 + proto: tcp + action: allow + from: any + comment: "http / acme-http-01" +``` +`firewall-diff` round-trips this against live `ufw status numbered` JSON-parse and prints additions/deletions. Exit 0 iff live ≡ intent. + +**Rate limiting patterns:** +- `limit` — built-in; 6 connections / 30 s per IP. Good for SSH. +- Per-app — do it inside the app or a reverse proxy (nginx `limit_req`, Caddy `rate_limit`), not in ufw. Kernel rate-limit doesn't understand HTTP methods. +- ICMP — `ufw default allow outgoing` covers outbound; inbound ICMP should be `allow` (echo) for monitoring, NOT blanket-blocked (blocks path-MTU discovery). + +**IPv6:** `/etc/default/ufw` → `IPV6=yes` (default Debian 12). Verify via `ufw status verbose` shows the (v6) rules. Missing IPv6 rules = a trivial bypass on dual-stack VMs. + +**Logging:** `ufw logging medium` writes to `/var/log/ufw.log`. Forward to journald (default on systemd) or an off-box log collector. Logging `high` is too chatty for steady state; use it only during incident response. + +**nftables alternative (for hosts that have Docker-installed iptables-nft):** +ufw is a thin wrapper over iptables/nftables; on Docker-heavy hosts, Docker's daemon aggressively rewrites iptables and can bypass ufw. Two options: +1. **DOCKER_OPTS=`--iptables=false`** (and do NAT yourself — advanced). +2. **`ufw-docker`** companion (, not bundled in Debian — pin a tagged release, review the script BEFORE install). + +On non-Docker hosts, ufw is sufficient. On Docker hosts, EITHER isolate (dedicated host + Cloud Firewall only) OR use `ufw-docker` — don't half-configure. + +**Forbidden:** `ufw default allow incoming` "temporarily"; `allow from any to any port 22` without `limit`; skipping the IPv6 rule set; letting Docker silently override ufw without disabling its iptables chain; relying on `ufw` as the ONLY layer when a Cloud Firewall is available. diff --git a/_blocks/security-patching.md b/_blocks/security-patching.md new file mode 100644 index 0000000..a0b5a41 --- /dev/null +++ b/_blocks/security-patching.md @@ -0,0 +1,62 @@ +# SECURITY — Patching (unattended-upgrades + needrestart + reboot window) + +**Goal:** security patches applied within 24 h of release, service restarts + kernel reboots happen within a declared maintenance window (NOT ad-hoc at 3 AM UTC on a random Tuesday). + +**Install:** +``` +sudo apt install -y unattended-upgrades needrestart +``` + +**`/etc/apt/apt.conf.d/50unattended-upgrades` (essential lines, Debian 12 / Ubuntu 22.04+):** +``` +Unattended-Upgrade::Origins-Pattern { + "origin=Debian,codename=${distro_codename}-security"; + "origin=Debian,codename=${distro_codename}-updates"; +}; +Unattended-Upgrade::Automatic-Reboot "false"; +Unattended-Upgrade::Automatic-Reboot-Time "04:00"; +Unattended-Upgrade::Mail "admin@example.com"; +Unattended-Upgrade::MailReport "on-change"; +``` +`Automatic-Reboot "false"` is the SAFE default — an automatic reboot without coordination kills in-flight requests. Pair with `needrestart` to SURFACE reboot requirement, then schedule the window explicitly (below). + +**`/etc/apt/apt.conf.d/20auto-upgrades`:** +``` +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +APT::Periodic::AutocleanInterval "7"; +``` +Triggers daily via `/lib/systemd/system/apt-daily.timer` + `apt-daily-upgrade.timer`. + +**needrestart:** after each upgrade, prints services that loaded old library versions and need restart. `/etc/needrestart/needrestart.conf`: +``` +$nrconf{restart} = 'l'; # list only; do NOT auto-restart services +$nrconf{kernelhints} = -1; # suppress "reboot hint" interactive prompt (non-TTY cron) +``` +`nrconf{restart} = 'a'` (auto) is tempting but dangerous — restarting `postgresql` or a stateful app during a migration = corruption. + +**Reboot window pattern (declared, env-var-driven):** +```bash +# /etc/systemd/system/kei-reboot-window.service + .timer +# Only reboots if /var/run/reboot-required exists AND the current time +# falls inside the declared window. +[Service] +Type=oneshot +EnvironmentFile=/etc/default/kei-reboot-window +ExecStart=/usr/local/bin/kei-reboot-window + +# /etc/default/kei-reboot-window +KEI_REBOOT_DOW="Sun" # day-of-week +KEI_REBOOT_HOUR="04" # 24h, UTC +KEI_REBOOT_MIN="15" +KEI_DRAIN_CMD="" # optional pre-reboot drain (e.g. drain a load-balancer slot) +``` +`kei-reboot-window` script checks `[ -f /var/run/reboot-required ]`, verifies it is the declared DOW/hour, runs `$KEI_DRAIN_CMD`, then `systemctl reboot`. Commit the script once; reuse the env file per-host. + +**Provider-specific:** +- **Hetzner Cloud / Vultr / UpCloud / DigitalOcean / Linode** — nothing extra; cloud-init already installs the packages per `deploy-vps-generic.md`. +- **AWS EC2** — `ec2-instance-connect` may briefly reject SSH during a reboot — tolerate in orchestration retries. + +**Auditability:** `unattended-upgrades` logs to `/var/log/unattended-upgrades/unattended-upgrades.log`. Forward via journald (see `security-audit-logging.md`). Package a short summary in the skill Phase 5 report. + +**Forbidden:** `Unattended-Upgrade::Automatic-Reboot "true"` on stateful services; `$nrconf{restart} = 'a'` on a database host; silently skipping the reboot window to "avoid downtime" (real fix: HA, not skipped patches); installing `.deb` packages from third-party repos without pinning + signature verification; disabling the `apt-daily.timer` — disables ALL security updates. diff --git a/_blocks/security-ssh-hardening.md b/_blocks/security-ssh-hardening.md new file mode 100644 index 0000000..2ad075b --- /dev/null +++ b/_blocks/security-ssh-hardening.md @@ -0,0 +1,51 @@ +# SECURITY — SSH Hardening (sshd_config.d/99-kei.conf) + +**Rule:** hardening goes into a drop-in under `/etc/ssh/sshd_config.d/`, NEVER by editing `/etc/ssh/sshd_config` directly. The main file ships with distro-owned defaults; drop-ins win on later-read order and survive package upgrades cleanly. + +**Reference file `/etc/ssh/sshd_config.d/99-kei.conf`:** +``` +# KeiSeiKit hardened SSH — pinned 2026-04-21, auditable via ssh-check. +Protocol 2 +PasswordAuthentication no +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin prohibit-password +PermitEmptyPasswords no +UsePAM yes +MaxAuthTries 3 +MaxSessions 4 +LoginGraceTime 20 +AllowUsers keiadmin +AllowTcpForwarding no +X11Forwarding no +PermitTunnel no +ClientAliveInterval 120 +ClientAliveCountMax 2 +LogLevel VERBOSE +# Modern crypto only (OpenSSH ≥ 8.9, default Debian 12 / Ubuntu 22.04+): +KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,sntrup761x25519-sha512@openssh.com +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com +HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256 +``` +Apply with `sshd -t` (config test) before `systemctl reload ssh`. `reload` NOT `restart` — restart kills existing sessions; reload re-reads config while keeping them. + +**Field-by-field rationale:** +- `PasswordAuthentication no` — passwords are the #1 SSH brute-force vector. Keys only. +- `PermitRootLogin prohibit-password` — root only via key, never password. `no` blocks even emergency cloud-console rescue paths on some providers; `prohibit-password` is the pragmatic middle. +- `MaxAuthTries 3` — reduces per-connection key/password attempts; combine with fail2ban for per-IP bans (separate concern). +- `AllowUsers keiadmin` — whitelist is simpler than group-based DENY and audits trivially. Adding users = explicit edit. +- `LogLevel VERBOSE` — logs the key fingerprint used; without it you can't tell which admin logged in after compromise. +- `ClientAliveInterval 120` + `ClientAliveCountMax 2` — idle sessions die in 4 minutes. Lost laptops don't leave open shells. +- `AllowTcpForwarding no` / `PermitTunnel no` — disables SSH-as-VPN. Enable per-use-case via `Match User tunneluser` only. + +**Modern KEX/Cipher/MAC lists (2026-04-21):** +- KEX: `sntrup761x25519-sha512@openssh.com` is post-quantum hybrid (default since OpenSSH 9.9) [VERIFIED https://www.openssh.com/releasenotes.html]; `curve25519-sha256` is the classic ECDH. +- Ciphers: AEAD only (`chacha20-poly1305`, `aes*-gcm`). Dropped CBC-mode — vulnerable to Terrapin CVE-2023-48795 without strict-KEX. +- MACs: ETM (Encrypt-Then-MAC) only. Legacy MAC-Then-Encrypt is dropped. +- HostKey: prefer `ssh-ed25519`; keep `rsa-sha2-*` for older client compatibility. Drop `ssh-rsa` (SHA-1, broken). + +**Verification (KeiSeiKit primitive):** +`_primitives/_rust/ssh-check/` parses BOTH `sshd_config` AND every `sshd_config.d/*.conf` (in filename sort order, last wins per directive), reports violations of the matrix above with `file:line` precision. Run BEFORE every `systemctl reload ssh` and BEFORE the skill phase-5 verify gate. + +**Forbidden:** editing `/etc/ssh/sshd_config` in-place when a drop-in directory exists; `PermitRootLogin yes`; `PasswordAuthentication yes`; accepting any `diffie-hellman-group1-*` / `ssh-rsa` / CBC ciphers; restarting sshd before `sshd -t` passes; relying on fail2ban alone without key-only auth. diff --git a/_blocks/security-tls-caddy.md b/_blocks/security-tls-caddy.md new file mode 100644 index 0000000..c7736a3 --- /dev/null +++ b/_blocks/security-tls-caddy.md @@ -0,0 +1,68 @@ +# SECURITY — TLS via Caddy (automatic ACME, HTTP-01 / DNS-01) + +**Why Caddy:** zero-config TLS. Caddy 2 auto-provisions certificates via Let's Encrypt / ZeroSSL on first request for a domain that resolves to it, auto-renews, and stores state under `/var/lib/caddy/`. Official docs: [VERIFIED 2026-04-21]. + +**One-liner install (Debian/Ubuntu, official repo):** +``` +# Pinned to official Cloudsmith repo — NEVER `curl … | bash` a random domain. +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update && sudo apt install -y caddy +``` +This installs the `caddy` systemd service owned by `caddy:caddy`. **Never run Caddy as root** — it uses `CAP_NET_BIND_SERVICE` ambient capability to bind low ports. + +**Minimal `/etc/caddy/Caddyfile`:** +``` +{ + # Global options + email admin@example.com # ACME account contact (change!) + # auto_https disable_redirects # uncomment only if fronted by another TLS-terminating proxy +} + +api.example.com { + encode zstd gzip + log { + output file /var/log/caddy/api.log { + roll_size 10mb + roll_keep 10 + } + } + reverse_proxy 127.0.0.1:8080 + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } +} +``` +`caddy validate --config /etc/caddy/Caddyfile` BEFORE `systemctl reload caddy`. Reload ≠ restart; reload is zero-downtime. + +**ACME challenge choice:** +- **HTTP-01** (default) — Caddy binds port 80, LE connects back, serves challenge. Requires: port 80 open to the internet, DNS pointing to the VM. Works for single-host public services. +- **DNS-01** — Caddy writes a TXT record via DNS provider API, doesn't need port 80 open. **Required for wildcard certs** (`*.example.com`) and for LAN-only hosts. Needs a DNS-provider plugin (e.g. `caddy-dns/cloudflare`) compiled into the binary — use `xcaddy build` or the Cloudsmith `caddy-dns-*` packages. + +**DNS-01 with Cloudflare (`caddy-dns/cloudflare`):** +``` +*.internal.example.com, internal.example.com { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + reverse_proxy 127.0.0.1:8080 +} +``` +`CF_API_TOKEN` — store in `/etc/caddy/caddy.env` (chmod 0640, `caddy:caddy`), load via systemd drop-in `EnvironmentFile=`. Never bake the token into the Caddyfile (RULE 0.8 — see `domain-has-secrets.md`). + +**CT log awareness:** every LE cert is published to Certificate Transparency logs. **Any subdomain you cert is publicly searchable** via crt.sh. Use DNS-01 + wildcard for internal services whose names should not leak. + +**Firewall interop (see `security-firewall-ufw.md`):** `ufw allow 80,443/tcp` is required for HTTP-01 and for public HTTPS. Do NOT open 80 if using DNS-01 exclusively and not redirecting HTTP→HTTPS publicly; skip the redirect with `auto_https disable_redirects`. + +**Hardening:** +- `HSTS` as shown above — 1 year, include subdomains. Add `preload` only after submitting to the HSTS preload list. +- `-Server` header strip — removes Caddy version disclosure. +- Rate limit via `caddy-ratelimit` module (needs `xcaddy build` with the plugin) for per-IP throttling; otherwise rely on cloud/ufw layer. + +**Forbidden:** running Caddy as root; embedding DNS/ACME API tokens in the Caddyfile; using `tls internal` (self-signed, ephemeral CA) for anything reachable from outside localhost; skipping `caddy validate` before reload; self-hosting ACME (step-ca is great, but needs its own runbook — out of scope here). diff --git a/_primitives/_rust/.gitignore b/_primitives/_rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/_primitives/_rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock new file mode 100644 index 0000000..4e8eae3 --- /dev/null +++ b/_primitives/_rust/Cargo.lock @@ -0,0 +1,608 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "firewall-diff" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "serde_yaml", + "tempfile", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "ssh-check" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 5b72ffe..9c8e779 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -1,14 +1,25 @@ [workspace] resolver = "2" -members = ["mock-render", "visual-diff", "tokens-sync", "kei-ledger"] +members = [ + "kei-ledger", + "kei-migrate", + "kei-changelog", + "ssh-check", + "firewall-diff", + "mock-render", + "visual-diff", + "tokens-sync", +] [workspace.package] edition = "2021" rust-version = "1.75" [workspace.dependencies] +clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" sha2 = "0.10" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/_primitives/_rust/firewall-diff/Cargo.toml b/_primitives/_rust/firewall-diff/Cargo.toml new file mode 100644 index 0000000..b7ac79d --- /dev/null +++ b/_primitives/_rust/firewall-diff/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "firewall-diff" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "firewall-diff" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/firewall-diff/src/diff.rs b/_primitives/_rust/firewall-diff/src/diff.rs new file mode 100644 index 0000000..993e3e9 --- /dev/null +++ b/_primitives/_rust/firewall-diff/src/diff.rs @@ -0,0 +1,193 @@ +//! Compare Intent × Live and emit a structured report. + +use crate::intent::{Action, Intent, Rule}; +use crate::ufw::{Live, LiveRule}; +use serde::Serialize; +use std::collections::HashSet; + +#[derive(Debug, Clone, Serialize)] +pub struct Report { + pub active_ok: bool, + pub default_mismatches: Vec, + pub missing: Vec, // in intent, not in live + pub extra: Vec, // in live, not in intent +} + +impl Report { + pub fn is_clean(&self) -> bool { + self.active_ok + && self.default_mismatches.is_empty() + && self.missing.is_empty() + && self.extra.is_empty() + } +} + +pub fn compare(intent: &Intent, live: &Live) -> Report { + let active_ok = live.active; + + let mut default_mismatches = Vec::new(); + if !matches!(intent.default.incoming, Action::Deny | Action::Reject) { + default_mismatches + .push("intent.default.incoming must be deny/reject for production".to_string()); + } + + // Build key sets. + let intent_keys: HashSet = intent.rules.iter().map(Rule::key).collect(); + let live_keys: HashSet = live.rules.iter().map(LiveRule::key).collect(); + + let missing: Vec = intent + .rules + .iter() + .filter(|r| !live_keys.contains(&r.key())) + .cloned() + .collect(); + let extra: Vec = live + .rules + .iter() + .filter(|r| !intent_keys.contains(&r.key())) + .cloned() + .collect(); + + Report { + active_ok, + default_mismatches, + missing, + extra, + } +} + +pub fn render_human(r: &Report) { + if !r.active_ok { + println!("[FAIL] ufw is not active."); + } + for m in &r.default_mismatches { + println!("[WARN] default: {m}"); + } + for m in &r.missing { + println!( + "[MISS] intent rule not live: {}/{} from={} action={:?}", + m.port, m.proto, m.from, m.action + ); + } + for e in &r.extra { + println!( + "[EXTRA] live rule not in intent: {}/{} from={} action={:?} family={:?}", + e.port, e.proto, e.from, e.action, e.family + ); + } + if r.is_clean() { + println!("firewall-diff: OK — intent ≡ live."); + } else { + println!( + "firewall-diff: {} missing, {} extra, default-issues={}", + r.missing.len(), + r.extra.len(), + r.default_mismatches.len() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::intent::{Action, Defaults, Intent, Rule}; + use crate::ufw::{self, Family, Live, LiveRule}; + + fn intent_fx() -> Intent { + Intent { + default: Defaults { + incoming: Action::Deny, + outgoing: Action::Allow, + routed: Action::Deny, + }, + rules: vec![ + Rule { + port: 22, + proto: "tcp".into(), + action: Action::Limit, + from: "any".into(), + comment: "ssh".into(), + }, + Rule { + port: 443, + proto: "tcp".into(), + action: Action::Allow, + from: "any".into(), + comment: "".into(), + }, + ], + } + } + + fn live_fx(items: &[(u16, &str, Action, &str)]) -> Live { + Live { + active: true, + rules: items + .iter() + .map(|(p, pr, a, f)| LiveRule { + port: *p, + proto: (*pr).into(), + action: a.clone(), + from: (*f).into(), + family: Family::V4, + }) + .collect(), + } + } + + #[test] + fn exact_match_is_clean() { + let i = intent_fx(); + let l = live_fx(&[ + (22, "tcp", Action::Limit, "any"), + (443, "tcp", Action::Allow, "any"), + ]); + let r = compare(&i, &l); + assert!(r.is_clean(), "{:#?}", r); + } + + #[test] + fn missing_rule_surfaced() { + let i = intent_fx(); + let l = live_fx(&[(22, "tcp", Action::Limit, "any")]); + let r = compare(&i, &l); + assert_eq!(r.missing.len(), 1); + assert_eq!(r.missing[0].port, 443); + } + + #[test] + fn extra_live_rule_surfaced() { + let i = intent_fx(); + let l = live_fx(&[ + (22, "tcp", Action::Limit, "any"), + (443, "tcp", Action::Allow, "any"), + (8080, "tcp", Action::Allow, "any"), + ]); + let r = compare(&i, &l); + assert_eq!(r.extra.len(), 1); + assert_eq!(r.extra[0].port, 8080); + } + + #[test] + fn inactive_ufw_fails() { + let i = intent_fx(); + let l = Live { + active: false, + rules: vec![], + }; + let r = compare(&i, &l); + assert!(!r.is_clean()); + assert!(!r.active_ok); + } + + #[test] + fn integration_parse_then_diff() { + // Mimic real `ufw status numbered` column padding (double-space gaps). + let text = "Status: active\n\n\ + [ 1] 22/tcp LIMIT IN Anywhere\n\ + [ 2] 443/tcp ALLOW IN Anywhere\n"; + let live = ufw::parse(text).unwrap(); + let r = compare(&intent_fx(), &live); + assert!(r.is_clean(), "{:#?}", r); + } +} diff --git a/_primitives/_rust/firewall-diff/src/intent.rs b/_primitives/_rust/firewall-diff/src/intent.rs new file mode 100644 index 0000000..cf62e27 --- /dev/null +++ b/_primitives/_rust/firewall-diff/src/intent.rs @@ -0,0 +1,111 @@ +//! Intent YAML schema + loader. See `_blocks/security-firewall-ufw.md` for +//! the reference format. Anything missing is treated as "don't care". + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Action { + Allow, + Deny, + Limit, + Reject, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Defaults { + #[serde(default = "default_deny")] + pub incoming: Action, + #[serde(default = "default_allow")] + pub outgoing: Action, + #[serde(default = "default_deny")] + pub routed: Action, +} +fn default_deny() -> Action { + Action::Deny +} +fn default_allow() -> Action { + Action::Allow +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Rule { + pub port: u16, + #[serde(default = "default_proto")] + pub proto: String, + pub action: Action, + #[serde(default = "default_from")] + pub from: String, + #[serde(default)] + pub comment: String, +} +fn default_proto() -> String { + "tcp".into() +} +fn default_from() -> String { + "any".into() +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Intent { + pub default: Defaults, + #[serde(default)] + pub rules: Vec, +} + +pub fn load(path: &Path) -> Result { + let body = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + serde_yaml::from_str(&body).map_err(|e| format!("yaml: {e}")) +} + +impl Rule { + /// Canonical key used to match against a live rule: port/proto/from/action. + pub fn key(&self) -> String { + format!( + "{}/{}::{}::{}", + self.port, + self.proto.to_ascii_lowercase(), + self.from.to_ascii_lowercase(), + format!("{:?}", self.action).to_ascii_lowercase() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn load_minimal_intent() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("intent.yaml"); + let mut f = fs::File::create(&p).unwrap(); + writeln!( + f, + r#"default: + incoming: deny + outgoing: allow + routed: deny +rules: + - port: 22 + proto: tcp + action: limit + from: any + comment: "ssh" + - port: 443 + proto: tcp + action: allow + from: any +"# + ) + .unwrap(); + let i = load(&p).unwrap(); + assert_eq!(i.default.incoming, Action::Deny); + assert_eq!(i.rules.len(), 2); + assert_eq!(i.rules[0].action, Action::Limit); + assert_eq!(i.rules[1].port, 443); + } +} diff --git a/_primitives/_rust/firewall-diff/src/main.rs b/_primitives/_rust/firewall-diff/src/main.rs new file mode 100644 index 0000000..1d78bfd --- /dev/null +++ b/_primitives/_rust/firewall-diff/src/main.rs @@ -0,0 +1,101 @@ +//! firewall-diff — compare an intended ufw rule set (YAML) against the +//! running firewall (parsed from `ufw status numbered` output). +//! +//! USAGE +//! firewall-diff --intent firewall-intent.yaml --status-file live.txt +//! ufw status numbered | firewall-diff --intent firewall-intent.yaml --stdin +//! firewall-diff --intent firewall-intent.yaml --json +//! +//! The tool does NOT execute `ufw` itself (defensive-only). Feed it the +//! output of `ufw status numbered` or have the skill pipe it in. +//! +//! EXIT +//! 0 intent ≡ live (no diff) +//! 1 usage / parse error +//! 2 differences found (live deviates from intent) + +mod diff; +mod intent; +mod ufw; + +use clap::Parser; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command(name = "firewall-diff", about = "Diff intended ufw rules (YAML) vs live status.")] +struct Cli { + /// Path to the intent YAML file. + #[arg(long)] + intent: PathBuf, + + /// Path to a file holding captured `ufw status numbered` output. + #[arg(long, conflicts_with = "stdin")] + status_file: Option, + + /// Read the ufw status text from stdin (use when piping from the host). + #[arg(long)] + stdin: bool, + + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let intent = match intent::load(&cli.intent) { + Ok(i) => i, + Err(e) => { + eprintln!("firewall-diff: intent: {e}"); + return ExitCode::from(1); + } + }; + + let status_txt = match (&cli.status_file, cli.stdin) { + (Some(p), false) => match fs::read_to_string(p) { + Ok(s) => s, + Err(e) => { + eprintln!("firewall-diff: read {}: {e}", p.display()); + return ExitCode::from(1); + } + }, + (None, true) => { + let mut s = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut s) { + eprintln!("firewall-diff: stdin: {e}"); + return ExitCode::from(1); + } + s + } + _ => { + eprintln!("firewall-diff: need --status-file or --stdin"); + return ExitCode::from(1); + } + }; + + let live = match ufw::parse(&status_txt) { + Ok(l) => l, + Err(e) => { + eprintln!("firewall-diff: parse ufw status: {e}"); + return ExitCode::from(1); + } + }; + + let report = diff::compare(&intent, &live); + + if cli.json { + println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default()); + } else { + diff::render_human(&report); + } + + if report.is_clean() { + ExitCode::SUCCESS + } else { + ExitCode::from(2) + } +} diff --git a/_primitives/_rust/firewall-diff/src/ufw.rs b/_primitives/_rust/firewall-diff/src/ufw.rs new file mode 100644 index 0000000..b68c9b8 --- /dev/null +++ b/_primitives/_rust/firewall-diff/src/ufw.rs @@ -0,0 +1,173 @@ +//! Parse `ufw status numbered` output. +//! +//! Typical shape (Ubuntu 22.04, ufw 0.36): +//! +//! Status: active +//! +//! To Action From +//! -- ------ ---- +//! [ 1] 22/tcp LIMIT IN Anywhere +//! [ 2] 443/tcp ALLOW IN Anywhere +//! [ 3] 22/tcp (v6) LIMIT IN Anywhere (v6) +//! +//! We normalise "(v6)" to a separate family tag but key rules on port/proto +//! only (v6 and v4 rules with the same port/proto are treated as duplicates +//! of intent, which is usually the desired behaviour for parity checks). + +use crate::intent::Action; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct LiveRule { + pub port: u16, + pub proto: String, + pub action: Action, + pub from: String, + pub family: Family, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub enum Family { + V4, + V6, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Live { + pub active: bool, + pub rules: Vec, +} + +pub fn parse(text: &str) -> Result { + let mut active = false; + let mut rules = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("Status:") { + active = rest.trim().eq_ignore_ascii_case("active"); + continue; + } + if line.starts_with("To") || line.starts_with("--") { + continue; + } + if let Some(r) = parse_rule(line) { + rules.push(r); + } + } + if text.trim().is_empty() { + return Err("could not detect an `ufw status` block (empty input)".into()); + } + Ok(Live { active, rules }) +} + +/// Parse one numbered rule line. Returns None if the line is not a rule. +fn parse_rule(line: &str) -> Option { + // Strip leading "[ N]" if present. + let body = if let Some(idx) = line.find(']') { + line[idx + 1..].trim() + } else { + line + }; + // Columns: + // We split on 2+ whitespace runs which ufw pads with. + let parts: Vec<&str> = body.split(" ").filter(|s| !s.is_empty()).map(str::trim).collect(); + if parts.len() < 3 { + return None; + } + let to = parts[0]; + let action_raw = parts[1]; + let from = parts[2]; + + let (to_clean, family) = if to.contains("(v6)") { + (to.replace("(v6)", "").trim().to_string(), Family::V6) + } else { + (to.to_string(), Family::V4) + }; + + let (port, proto) = split_port_proto(&to_clean)?; + let action = parse_action(action_raw)?; + Some(LiveRule { + port, + proto, + action, + from: from.replace("(v6)", "").trim().to_string(), + family, + }) +} + +fn split_port_proto(tok: &str) -> Option<(u16, String)> { + // "22/tcp" | "53" | "443/udp" + if let Some((port_s, proto_s)) = tok.split_once('/') { + Some((port_s.parse().ok()?, proto_s.to_ascii_lowercase())) + } else { + Some((tok.parse().ok()?, "tcp".into())) + } +} + +fn parse_action(raw: &str) -> Option { + let up = raw.to_ascii_uppercase(); + if up.starts_with("ALLOW") { + Some(Action::Allow) + } else if up.starts_with("DENY") { + Some(Action::Deny) + } else if up.starts_with("LIMIT") { + Some(Action::Limit) + } else if up.starts_with("REJECT") { + Some(Action::Reject) + } else { + None + } +} + +impl LiveRule { + pub fn key(&self) -> String { + let from = if self.from.eq_ignore_ascii_case("Anywhere") { + "any" + } else { + &self.from + }; + format!( + "{}/{}::{}::{}", + self.port, + self.proto, + from.to_ascii_lowercase(), + format!("{:?}", self.action).to_ascii_lowercase() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +Status: active + + To Action From + -- ------ ---- +[ 1] 22/tcp LIMIT IN Anywhere +[ 2] 443/tcp ALLOW IN Anywhere +[ 3] 22/tcp (v6) LIMIT IN Anywhere (v6) +"#; + + #[test] + fn parses_active_and_rules() { + let l = parse(SAMPLE).unwrap(); + assert!(l.active); + assert_eq!(l.rules.len(), 3); + assert_eq!(l.rules[0].port, 22); + assert_eq!(l.rules[0].proto, "tcp"); + assert_eq!(l.rules[0].action, Action::Limit); + assert_eq!(l.rules[2].family, Family::V6); + } + + #[test] + fn inactive_status_rejects_only_if_no_rules() { + let l = parse("Status: inactive\n").unwrap(); + assert!(!l.active); + assert!(l.rules.is_empty()); + } +} diff --git a/_primitives/_rust/ssh-check/Cargo.toml b/_primitives/_rust/ssh-check/Cargo.toml new file mode 100644 index 0000000..19ec9b7 --- /dev/null +++ b/_primitives/_rust/ssh-check/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ssh-check" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[[bin]] +name = "ssh-check" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/ssh-check/src/check.rs b/_primitives/_rust/ssh-check/src/check.rs new file mode 100644 index 0000000..c0aa92b --- /dev/null +++ b/_primitives/_rust/ssh-check/src/check.rs @@ -0,0 +1,213 @@ +//! Evaluate the hardened rule matrix against a merged sshd_config view. + +use crate::parse::Merged; +use crate::rules::{Expect, Rule}; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Severity { + Ok, + Warn, + Fail, +} + +impl Severity { + pub fn label(&self) -> &'static str { + match self { + Severity::Ok => "OK", + Severity::Warn => "WARN", + Severity::Fail => "FAIL", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Finding { + pub directive: String, + pub severity: Severity, + pub source: String, + pub note: String, +} + +pub fn evaluate(merged: &Merged, rules: &[Rule]) -> Vec { + let mut out = Vec::with_capacity(rules.len()); + for r in rules { + out.push(eval_rule(merged, r)); + } + out +} + +fn eval_rule(merged: &Merged, r: &Rule) -> Finding { + let occ = merged.effective.get(r.directive); + match (occ, r.required) { + (None, true) => Finding { + directive: r.directive.into(), + severity: Severity::Fail, + source: "(missing)".into(), + note: format!("required directive absent — {}", r.rationale), + }, + (None, false) => Finding { + directive: r.directive.into(), + severity: Severity::Warn, + source: "(missing)".into(), + note: format!("recommended: {}", r.rationale), + }, + (Some(o), _) => { + let ok = value_matches(&o.value, &r.expect); + Finding { + directive: r.directive.into(), + severity: if ok { Severity::Ok } else { Severity::Fail }, + source: o.source.clone(), + note: if ok { + "ok".into() + } else { + format!("value '{}' violates policy — {}", o.value, r.rationale) + }, + } + } + } +} + +fn value_matches(value: &str, expect: &Expect) -> bool { + let v = value.trim().to_ascii_lowercase(); + match expect { + Expect::Equals(target) => v == target.to_ascii_lowercase(), + Expect::OneOf(list) => list.iter().any(|s| v == s.to_ascii_lowercase()), + Expect::MaxInt(max) => v.parse::().map(|n| n <= *max).unwrap_or(false), + Expect::ContainsAll(tokens) => tokens.iter().all(|t| v.contains(&t.to_ascii_lowercase())), + Expect::DeniesAny(tokens) => { + let parts: Vec<&str> = v.split(',').map(str::trim).collect(); + !tokens + .iter() + .any(|t| parts.iter().any(|p| p == &t.to_ascii_lowercase())) + } + Expect::AllowedUsersSubset(allow) => { + let parts: Vec = v + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + !parts.is_empty() && parts.iter().all(|u| allow.contains(u)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::{Merged, Occurrence}; + use crate::rules::hardened_matrix; + use std::collections::BTreeMap; + + fn merged(pairs: &[(&str, &str)]) -> Merged { + let mut m = Merged { + effective: BTreeMap::new(), + all: BTreeMap::new(), + }; + for (k, v) in pairs { + let occ = Occurrence { + value: (*v).to_string(), + source: "test:1".into(), + }; + m.effective.insert((*k).to_string(), occ.clone()); + m.all.insert((*k).to_string(), vec![occ]); + } + m + } + + #[test] + fn hardened_baseline_passes() { + let rules = hardened_matrix(&["keiadmin".into()]); + let mg = merged(&[ + ("passwordauthentication", "no"), + ("permitrootlogin", "prohibit-password"), + ("maxauthtries", "3"), + ("allowusers", "keiadmin"), + ("ciphers", "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com"), + ("macs", "hmac-sha2-512-etm@openssh.com"), + ("hostkeyalgorithms", "ssh-ed25519,rsa-sha2-512"), + ]); + let findings = evaluate(&mg, &rules); + let fails: Vec<_> = findings.iter().filter(|f| f.severity == Severity::Fail).collect(); + assert!(fails.is_empty(), "unexpected fails: {fails:#?}"); + } + + #[test] + fn password_auth_yes_fails() { + let rules = hardened_matrix(&["keiadmin".into()]); + let mg = merged(&[ + ("passwordauthentication", "yes"), + ("permitrootlogin", "no"), + ("maxauthtries", "3"), + ("allowusers", "keiadmin"), + ]); + let findings = evaluate(&mg, &rules); + let f = findings + .iter() + .find(|f| f.directive == "passwordauthentication") + .unwrap(); + assert_eq!(f.severity, Severity::Fail); + } + + #[test] + fn cbc_cipher_fails() { + let rules = hardened_matrix(&["keiadmin".into()]); + let mg = merged(&[ + ("passwordauthentication", "no"), + ("permitrootlogin", "no"), + ("maxauthtries", "3"), + ("allowusers", "keiadmin"), + ("ciphers", "aes256-cbc,chacha20-poly1305@openssh.com"), + ]); + let findings = evaluate(&mg, &rules); + let f = findings.iter().find(|f| f.directive == "ciphers").unwrap(); + assert_eq!(f.severity, Severity::Fail); + } + + #[test] + fn allow_users_not_in_whitelist_fails() { + let rules = hardened_matrix(&["keiadmin".into()]); + let mg = merged(&[ + ("passwordauthentication", "no"), + ("permitrootlogin", "no"), + ("maxauthtries", "3"), + ("allowusers", "root attacker"), + ]); + let findings = evaluate(&mg, &rules); + let f = findings.iter().find(|f| f.directive == "allowusers").unwrap(); + assert_eq!(f.severity, Severity::Fail); + } + + #[test] + fn missing_required_directive_fails() { + let rules = hardened_matrix(&["keiadmin".into()]); + let mg = merged(&[ + ("permitrootlogin", "no"), + ("maxauthtries", "3"), + ("allowusers", "keiadmin"), + ]); + let findings = evaluate(&mg, &rules); + let f = findings + .iter() + .find(|f| f.directive == "passwordauthentication") + .unwrap(); + assert_eq!(f.severity, Severity::Fail); + assert_eq!(f.source, "(missing)"); + } + + #[test] + fn maxauthtries_too_high_fails() { + let rules = hardened_matrix(&["keiadmin".into()]); + let mg = merged(&[ + ("passwordauthentication", "no"), + ("permitrootlogin", "no"), + ("maxauthtries", "10"), + ("allowusers", "keiadmin"), + ]); + let findings = evaluate(&mg, &rules); + let f = findings + .iter() + .find(|f| f.directive == "maxauthtries") + .unwrap(); + assert_eq!(f.severity, Severity::Fail); + } +} diff --git a/_primitives/_rust/ssh-check/src/main.rs b/_primitives/_rust/ssh-check/src/main.rs new file mode 100644 index 0000000..9f88893 --- /dev/null +++ b/_primitives/_rust/ssh-check/src/main.rs @@ -0,0 +1,102 @@ +//! ssh-check — pre-deploy sshd_config linter for KeiSeiKit. +//! +//! Reads /etc/ssh/sshd_config + every /etc/ssh/sshd_config.d/*.conf (or +//! user-supplied paths), merges directives via last-wins precedence, and +//! reports violations of the hardened-baseline rule matrix. +//! +//! USAGE +//! ssh-check # default system paths +//! ssh-check --config /etc/ssh/sshd_config --drop-in /etc/ssh/sshd_config.d +//! ssh-check --json # JSON output for CI +//! ssh-check --allow-user admin # extra allowed user +//! +//! EXIT +//! 0 no violations +//! 1 usage / parse error +//! 2 violations found + +mod check; +mod parse; +mod rules; + +use clap::Parser; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command( + name = "ssh-check", + about = "Lint sshd_config + drop-ins against the KeiSeiKit hardened baseline." +)] +struct Cli { + /// Main sshd_config file. + #[arg(long, default_value = "/etc/ssh/sshd_config")] + config: PathBuf, + + /// Drop-in directory (sshd_config.d). Pass empty string to skip. + #[arg(long, default_value = "/etc/ssh/sshd_config.d")] + drop_in: PathBuf, + + /// Usernames that are acceptable in AllowUsers (repeatable). + #[arg(long = "allow-user")] + allow_user: Vec, + + /// Emit JSON instead of human text. + #[arg(long)] + json: bool, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let merged = match parse::load_merged(&cli.config, &cli.drop_in) { + Ok(m) => m, + Err(e) => { + eprintln!("ssh-check: {e}"); + return ExitCode::from(1); + } + }; + + let allow_users: Vec = if cli.allow_user.is_empty() { + vec!["keiadmin".into()] + } else { + cli.allow_user + }; + let matrix = rules::hardened_matrix(&allow_users); + let findings = check::evaluate(&merged, &matrix); + + if cli.json { + let out = serde_json::to_string_pretty(&findings).unwrap_or_default(); + println!("{out}"); + } else { + render_human(&findings); + } + + if findings.iter().any(|f| f.severity != check::Severity::Ok) { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + } +} + +fn render_human(findings: &[check::Finding]) { + let mut bad = 0usize; + for f in findings { + if f.severity == check::Severity::Ok { + continue; + } + bad += 1; + println!( + "[{sev:<5}] {directive:<28} {source} ({note})", + sev = f.severity.label(), + directive = f.directive, + source = f.source, + note = f.note + ); + } + if bad == 0 { + println!("ssh-check: OK — hardened baseline satisfied."); + } else { + println!("ssh-check: {bad} violation(s)."); + } +} diff --git a/_primitives/_rust/ssh-check/src/parse.rs b/_primitives/_rust/ssh-check/src/parse.rs new file mode 100644 index 0000000..6de94a2 --- /dev/null +++ b/_primitives/_rust/ssh-check/src/parse.rs @@ -0,0 +1,127 @@ +//! sshd_config parser — read main file + drop-ins, merge with last-wins +//! precedence per OpenSSH rules (main file first, then drop-ins in +//! filename-sort order; first occurrence of a directive wins in sshd, +//! BUT we surface ALL occurrences to report duplicates). + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A single directive occurrence (name, value, source path, line number). +#[derive(Debug, Clone)] +pub struct Occurrence { + pub value: String, + pub source: String, // ":" +} + +/// Merged view: directive name (lowercased) → first-occurrence value + +/// every occurrence for duplicate detection. +#[derive(Debug, Default)] +pub struct Merged { + pub effective: BTreeMap, + pub all: BTreeMap>, +} + +pub fn load_merged(main: &Path, drop_in: &Path) -> Result { + let mut files: Vec = Vec::new(); + if main.exists() { + files.push(main.to_path_buf()); + } else { + return Err(format!("main config not found: {}", main.display())); + } + // Drop-in dir is optional; pass empty path to skip. + if !drop_in.as_os_str().is_empty() && drop_in.is_dir() { + let mut dropins: Vec = fs::read_dir(drop_in) + .map_err(|e| format!("read {}: {e}", drop_in.display()))? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().map(|s| s == "conf").unwrap_or(false)) + .collect(); + dropins.sort(); + files.extend(dropins); + } + + let mut merged = Merged::default(); + for path in files { + let body = + fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + for (lineno, raw) in body.lines().enumerate() { + if let Some((k, v)) = parse_line(raw) { + let occ = Occurrence { + value: v, + source: format!("{}:{}", path.display(), lineno + 1), + }; + merged + .all + .entry(k.clone()) + .or_default() + .push(occ.clone()); + // First occurrence wins in OpenSSH — do NOT overwrite. + merged.effective.entry(k).or_insert(occ); + } + } + } + Ok(merged) +} + +/// Parse one config line. Returns (lowercased_directive, raw_value) or None +/// for comments / blanks / Include (we don't recurse includes by design — +/// the skill wires explicit paths). +fn parse_line(raw: &str) -> Option<(String, String)> { + let stripped = raw.split('#').next().unwrap_or("").trim(); + if stripped.is_empty() { + return None; + } + let mut parts = stripped.splitn(2, char::is_whitespace); + let name = parts.next()?.trim().to_ascii_lowercase(); + let value = parts.next().unwrap_or("").trim().to_string(); + if name == "include" || name == "match" { + return None; + } + Some((name, value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + fs::write(&p, body).unwrap(); + p + } + + #[test] + fn parses_directives_and_ignores_comments() { + let dir = tempfile::tempdir().unwrap(); + let main = write(dir.path(), "sshd_config", "# header\nPort 22\nPasswordAuthentication no\n"); + let m = load_merged(&main, Path::new("")).unwrap(); + assert_eq!(m.effective["port"].value, "22"); + assert_eq!(m.effective["passwordauthentication"].value, "no"); + } + + #[test] + fn drop_in_does_not_override_main_effective_value() { + // OpenSSH: first occurrence wins. Main is read first. + let dir = tempfile::tempdir().unwrap(); + let main = write(dir.path(), "sshd_config", "Port 22\n"); + let d = dir.path().join("sshd_config.d"); + fs::create_dir(&d).unwrap(); + write(&d, "99-kei.conf", "Port 2222\n"); + let m = load_merged(&main, &d).unwrap(); + assert_eq!(m.effective["port"].value, "22"); + assert_eq!(m.all["port"].len(), 2, "both occurrences recorded"); + } + + #[test] + fn include_and_match_are_skipped() { + let dir = tempfile::tempdir().unwrap(); + let main = write( + dir.path(), + "sshd_config", + "Include /etc/ssh/foo.d/*.conf\nMatch User root\n\tPasswordAuthentication yes\n", + ); + let m = load_merged(&main, Path::new("")).unwrap(); + assert!(!m.effective.contains_key("include")); + assert!(!m.effective.contains_key("match")); + } +} diff --git a/_primitives/_rust/ssh-check/src/rules.rs b/_primitives/_rust/ssh-check/src/rules.rs new file mode 100644 index 0000000..0e6338c --- /dev/null +++ b/_primitives/_rust/ssh-check/src/rules.rs @@ -0,0 +1,128 @@ +//! Hardened SSH baseline — rule matrix. See block +//! `_blocks/security-ssh-hardening.md` for rationale per directive. + +#[derive(Debug, Clone)] +pub enum Expect { + /// Value must equal (case-insensitive) one of the given strings. + OneOf(Vec<&'static str>), + /// Value must equal the given string (case-insensitive). + Equals(&'static str), + /// Value must be a numeric literal ≤ given bound. + MaxInt(u32), + /// Value must contain ALL of the given tokens (comma-split, case-insensitive). + ContainsAll(Vec<&'static str>), + /// Value must NOT contain ANY of the given tokens. + DeniesAny(Vec<&'static str>), + /// Value must be present and non-empty; dynamic equality deferred to check.rs. + AllowedUsersSubset(Vec), +} + +#[derive(Debug, Clone)] +pub struct Rule { + pub directive: &'static str, + pub required: bool, + pub expect: Expect, + pub rationale: &'static str, +} + +pub fn hardened_matrix(allow_users: &[String]) -> Vec { + vec![ + Rule { + directive: "passwordauthentication", + required: true, + expect: Expect::Equals("no"), + rationale: "Passwords are the #1 brute-force vector; keys only.", + }, + Rule { + directive: "permitrootlogin", + required: true, + expect: Expect::OneOf(vec!["no", "prohibit-password"]), + rationale: "Root via key only (or not at all).", + }, + Rule { + directive: "permitemptypasswords", + required: false, + expect: Expect::Equals("no"), + rationale: "Empty passwords never.", + }, + Rule { + directive: "challengeresponseauthentication", + required: false, + expect: Expect::Equals("no"), + rationale: "Disables keyboard-interactive fallback.", + }, + Rule { + directive: "kbdinteractiveauthentication", + required: false, + expect: Expect::Equals("no"), + rationale: "OpenSSH 8.7+ directive; supersedes ChallengeResponseAuthentication.", + }, + Rule { + directive: "maxauthtries", + required: true, + expect: Expect::MaxInt(3), + rationale: "Limits per-connection key attempts; combine with fail2ban.", + }, + Rule { + directive: "x11forwarding", + required: false, + expect: Expect::Equals("no"), + rationale: "Not needed on servers; attack surface.", + }, + Rule { + directive: "allowtcpforwarding", + required: false, + expect: Expect::OneOf(vec!["no", "local"]), + rationale: "Blocks SSH-as-VPN; enable per Match block if needed.", + }, + Rule { + directive: "permittunnel", + required: false, + expect: Expect::Equals("no"), + rationale: "Blocks tun(4) tunnel device.", + }, + Rule { + directive: "clientaliveinterval", + required: false, + expect: Expect::MaxInt(300), + rationale: "Idle sessions terminated after a few minutes.", + }, + Rule { + directive: "loglevel", + required: false, + expect: Expect::OneOf(vec!["verbose", "debug1", "debug2", "debug3"]), + rationale: "VERBOSE logs key fingerprints for audit.", + }, + Rule { + directive: "allowusers", + required: true, + expect: Expect::AllowedUsersSubset(allow_users.to_vec()), + rationale: "Explicit admin whitelist.", + }, + Rule { + directive: "ciphers", + required: false, + expect: Expect::DeniesAny(vec![ + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + "blowfish-cbc", + "rijndael-cbc@lysator.liu.se", + ]), + rationale: "CBC ciphers vulnerable to Terrapin / padding oracles.", + }, + Rule { + directive: "macs", + required: false, + expect: Expect::ContainsAll(vec!["etm"]), + rationale: "ETM (Encrypt-Then-MAC) only; legacy MAC is broken.", + }, + Rule { + directive: "hostkeyalgorithms", + required: false, + expect: Expect::DeniesAny(vec!["ssh-rsa", "ssh-dss"]), + rationale: "ssh-rsa = SHA-1 signature, deprecated. Use rsa-sha2-*.", + }, + ] +} diff --git a/_primitives/harden-base.sh b/_primitives/harden-base.sh new file mode 100755 index 0000000..472ca42 --- /dev/null +++ b/_primitives/harden-base.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# harden-base.sh — idempotent Debian/Ubuntu baseline hardening. +# Runs ON THE TARGET VPS (not the local workstation). Ports generic +# patterns from ~/Projects/vortex/control/setup/setup.sh:13-53 — strips +# Vortex-specific Xray/sing-box/Wireguard steps. +# +# USAGE +# curl -fsSL /harden-base.sh | sudo bash -s -- [options] +# OR +# scp harden-base.sh keiadmin@host:/tmp/ +# ssh keiadmin@host "sudo bash /tmp/harden-base.sh" +# +# OPTIONS +# --admin-user default: keiadmin +# --ssh-port default: 22 (opens in ufw + enforces in sshd drop-in) +# --allow-port repeatable. e.g. --allow-port 443/tcp --allow-port 80/tcp +# --no-caddy skip Caddy install (default: skip — install via its own block) +# --no-reboot default (never reboots; surfaces needrestart hints only) +# --skip repeatable; known steps: apt, ssh, ufw, fail2ban, auditd, unattended +# +# IDEMPOTENCY +# Every step is `test → configure → reload`. Re-run = diff-and-apply. +# Detects the sshd_config.d/99-kei.conf + audit rules files; overwrites with +# known-good content; never destroys /etc/ssh/sshd_config itself. +# +# ENV +# No secrets read. SECRETS SINGLE SOURCE (RULE 0.8) → harden-base does not +# touch tokens/keys. +# +# EXIT +# 0 ok +# 1 usage / platform not supported +# 2 hardening step failed (stderr) + +set -euo pipefail + +log() { printf '[%s] [harden-base] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; } +die() { log "ERROR: $*"; exit "${2:-2}"; } + +# ------------------------------------------------------------------ args +ADMIN_USER="keiadmin" +SSH_PORT="22" +ALLOW_PORTS=() +SKIPS=() +NO_CADDY=1 # default on; Caddy install is its own block +while [ $# -gt 0 ]; do + case "$1" in + --admin-user) ADMIN_USER="$2"; shift 2 ;; + --ssh-port) SSH_PORT="$2"; shift 2 ;; + --allow-port) ALLOW_PORTS+=("$2"); shift 2 ;; + --no-caddy) NO_CADDY=1; shift ;; + --no-reboot) shift ;; # accepted for clarity; we never auto-reboot + --skip) SKIPS+=("$2"); shift 2 ;; + -h|--help) cat <&2 +harden-base.sh — Debian/Ubuntu baseline hardening. +OPTIONS + --admin-user default: keiadmin + --ssh-port default: 22 + --allow-port repeatable (e.g. 443/tcp, 80/tcp) + --no-caddy (default) skip Caddy install + --skip apt|ssh|ufw|fail2ban|auditd|unattended +EOF + exit 0 ;; + *) die "unknown flag '$1'" 1 ;; + esac +done + +# ------------------------------------------------------------------ guards +[ "$(id -u)" -eq 0 ] || die "must run as root (sudo)." 1 +. /etc/os-release 2>/dev/null || die "cannot read /etc/os-release" 1 +case "${ID:-}" in + debian|ubuntu) : ;; + *) die "only Debian/Ubuntu supported (got ID=${ID:-unknown})" 1 ;; +esac + +skipped() { + local step="$1" + for s in "${SKIPS[@]}"; do [ "$s" = "$step" ] && return 0; done + return 1 +} + +# ------------------------------------------------------------------ step: apt +step_apt() { + skipped apt && { log "skip apt"; return; } + log "apt update + base packages…" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq \ + ufw fail2ban unattended-upgrades needrestart auditd audispd-plugins \ + curl wget jq ca-certificates openssh-server +} + +# ------------------------------------------------------------------ step: admin user +step_admin_user() { + if id "$ADMIN_USER" >/dev/null 2>&1; then + log "user '$ADMIN_USER' exists" + else + log "creating '$ADMIN_USER' (sudo, bash, NOPASSWD)" + useradd -m -s /bin/bash -G sudo "$ADMIN_USER" + install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "/home/$ADMIN_USER/.ssh" + fi + install -d -m 0755 /etc/sudoers.d + cat >/etc/sudoers.d/90-keiadmin </dev/null +} + +# ------------------------------------------------------------------ step: ssh +step_ssh() { + skipped ssh && { log "skip ssh"; return; } + log "ssh: writing /etc/ssh/sshd_config.d/99-kei.conf (port=$SSH_PORT, user=$ADMIN_USER)…" + install -d -m 0755 /etc/ssh/sshd_config.d + cat >/etc/ssh/sshd_config.d/99-kei.conf </dev/null || systemctl reload sshd +} + +# ------------------------------------------------------------------ step: ufw +step_ufw() { + skipped ufw && { log "skip ufw"; return; } + log "ufw: default-deny-in + allow-out + ssh rate-limit…" + ufw --force reset >/dev/null + ufw default deny incoming + ufw default allow outgoing + ufw default deny routed + ufw limit "$SSH_PORT/tcp" comment "ssh (rate-limited)" + for p in "${ALLOW_PORTS[@]}"; do + log " allow $p" + ufw allow "$p" + done + ufw logging medium + ufw --force enable +} + +# ------------------------------------------------------------------ step: fail2ban +step_fail2ban() { + skipped fail2ban && { log "skip fail2ban"; return; } + log "fail2ban: writing /etc/fail2ban/jail.local (sshd jail)…" + cat >/etc/fail2ban/jail.local </etc/audit/rules.d/99-kei.rules <<'EOF' +# GENERATED by harden-base.sh — idempotent baseline. +-w /etc/ssh/sshd_config -p wa -k sshd_config +-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config +-w /root/.ssh/ -p wa -k ssh_keys_root +-w /etc/sudoers -p wa -k sudoers +-w /etc/sudoers.d/ -p wa -k sudoers +-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k sudo_root +-w /etc/passwd -p wa -k identity +-w /etc/group -p wa -k identity +-w /etc/shadow -p wa -k identity +-w /etc/gshadow -p wa -k identity +-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k module +-a always,exit -F arch=b64 -S adjtimex -S settimeofday -S clock_settime -k time +-w /etc/localtime -p wa -k time +-e 2 +EOF + augenrules --load >/dev/null + systemctl enable --now auditd +} + +# ------------------------------------------------------------------ step: unattended-upgrades + needrestart +step_unattended() { + skipped unattended && { log "skip unattended"; return; } + log "unattended-upgrades + needrestart (list-only)…" + cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF' +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +APT::Periodic::AutocleanInterval "7"; +EOF + cat >/etc/apt/apt.conf.d/50unattended-upgrades.kei <<'EOF' +Unattended-Upgrade::Origins-Pattern { + "origin=Debian,codename=${distro_codename}-security"; + "origin=Debian,codename=${distro_codename}-updates"; + "origin=Ubuntu,archive=${distro_codename}-security"; +}; +Unattended-Upgrade::Automatic-Reboot "false"; +Unattended-Upgrade::MailReport "on-change"; +EOF + # needrestart: list services, suppress TTY prompts (non-TTY cron safe). + if [ -f /etc/needrestart/needrestart.conf ]; then + sed -i "s/^#\?\$nrconf{restart}.*/\$nrconf{restart} = 'l';/" /etc/needrestart/needrestart.conf + sed -i "s/^#\?\$nrconf{kernelhints}.*/\$nrconf{kernelhints} = -1;/" /etc/needrestart/needrestart.conf + fi +} + +# ------------------------------------------------------------------ main +log "start: admin=$ADMIN_USER ssh=$SSH_PORT extra-ports=${ALLOW_PORTS[*]:-none}" +step_apt +step_admin_user +step_ssh +step_ufw +step_fail2ban +step_auditd +step_unattended +log "done. Next: verify via _primitives/_rust/ssh-check + firewall-diff." diff --git a/_primitives/provision-hetzner.sh b/_primitives/provision-hetzner.sh new file mode 100755 index 0000000..a63c855 --- /dev/null +++ b/_primitives/provision-hetzner.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# provision-hetzner.sh — idempotent Hetzner Cloud server provisioning. +# Wraps the `hcloud` CLI. Install path: +# $HOME/.claude/agents/_primitives/provision-hetzner.sh +# +# USAGE +# provision-hetzner.sh create [--type cx22|cax11] [--location fsn1] \ +# [--image debian-12] [--ssh-key ] \ +# [--firewall ] [--user-data ] +# provision-hetzner.sh status +# provision-hetzner.sh destroy [--force] +# provision-hetzner.sh list +# +# ENV (RULE 0.8 — secrets single source) +# HCLOUD_TOKEN — Hetzner API token (REQUIRED). Source: +# $(grep ^HCLOUD_TOKEN ~/.claude/secrets/.env | cut -d= -f2) +# +# EXIT +# 0 ok +# 1 usage / missing args / missing deps / unknown command +# 2 hcloud API error (non-idempotent path — inspect stderr) +# +# IDEMPOTENCY +# `create ` on an existing server prints its IP + exits 0. +# `destroy ` on a missing server exits 0 (nothing to do). + +set -euo pipefail + +log() { printf '[%s] [provision-hetzner] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; } +die() { log "ERROR: $*"; exit "${2:-2}"; } + +check_deps() { + command -v hcloud >/dev/null 2>&1 || \ + die "hcloud CLI missing. Install: brew install hcloud (macOS) | https://github.com/hetznercloud/cli/releases" 1 + command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1 + [ -n "${HCLOUD_TOKEN:-}" ] || die "HCLOUD_TOKEN not set. Source ~/.claude/secrets/.env first." 1 +} + +# Print server JSON if it exists, empty string otherwise. Never fails. +server_json() { + local name="$1" + hcloud server describe "$name" -o json 2>/dev/null || true +} + +cmd_list() { + check_deps + hcloud server list -o 'columns=id,name,status,ipv4,location,server_type,created' +} + +cmd_status() { + check_deps + local name="${1:-}"; [ -n "$name" ] || die "status: required" 1 + local json; json=$(server_json "$name") + if [ -z "$json" ]; then + echo "absent" + return 0 + fi + printf 'name=%s\nstatus=%s\nipv4=%s\nlocation=%s\ntype=%s\n' \ + "$(jq -r .name <<<"$json")" \ + "$(jq -r .status <<<"$json")" \ + "$(jq -r '.public_net.ipv4.ip // "-"' <<<"$json")" \ + "$(jq -r .datacenter.location.name <<<"$json")" \ + "$(jq -r .server_type.name <<<"$json")" +} + +cmd_create() { + check_deps + local name="${1:-}"; shift || true + [ -n "$name" ] || die "create: required" 1 + + local type="cx22" location="fsn1" image="debian-12" + local ssh_key="" firewall="" user_data="" + while [ $# -gt 0 ]; do + case "$1" in + --type) type="$2"; shift 2 ;; + --location) location="$2"; shift 2 ;; + --image) image="$2"; shift 2 ;; + --ssh-key) ssh_key="$2"; shift 2 ;; + --firewall) firewall="$2"; shift 2 ;; + --user-data) user_data="$2"; shift 2 ;; + *) die "create: unknown flag '$1'" 1 ;; + esac + done + + # Idempotent fast-path: if the server already exists, just print its IP. + local existing; existing=$(server_json "$name") + if [ -n "$existing" ]; then + local ip; ip=$(jq -r '.public_net.ipv4.ip // "-"' <<<"$existing") + log "server '$name' already exists → $ip (no-op)" + echo "$ip" + return 0 + fi + + local args=(server create + --name "$name" + --type "$type" + --image "$image" + --location "$location" + --label "project=kei" + ) + [ -n "$ssh_key" ] && args+=(--ssh-key "$ssh_key") + [ -n "$firewall" ] && args+=(--firewall "$firewall") + [ -n "$user_data" ] && { [ -r "$user_data" ] || die "user-data not readable: $user_data" 1; args+=(--user-data-from-file "$user_data"); } + + log "creating '$name' ($type @ $location, image=$image)…" + hcloud "${args[@]}" -o json >/tmp/provision-hetzner-$$.json + local ip; ip=$(jq -r '.server.public_net.ipv4.ip' /tmp/provision-hetzner-$$.json) + rm -f /tmp/provision-hetzner-$$.json + [ "$ip" != "null" ] && [ -n "$ip" ] || die "create returned no IPv4 (check stderr)" + log "created '$name' → $ip" + echo "$ip" +} + +cmd_destroy() { + check_deps + local name="${1:-}"; shift || true + [ -n "$name" ] || die "destroy: required" 1 + local force="" + [ "${1:-}" = "--force" ] && force=1 + + local existing; existing=$(server_json "$name") + if [ -z "$existing" ]; then + log "server '$name' absent (no-op)" + return 0 + fi + + if [ -z "$force" ]; then + printf 'Destroy server "%s"? [y/N] ' "$name" >&2 + read -r ans + [ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; } + fi + + log "deleting '$name'…" + hcloud server delete "$name" >&2 + log "deleted '$name'" +} + +main() { + local cmd="${1:-}"; shift || true + case "$cmd" in + create) cmd_create "$@" ;; + destroy) cmd_destroy "$@" ;; + status) cmd_status "$@" ;; + list) cmd_list "$@" ;; + -h|--help|"") cat <&2 +provision-hetzner.sh — idempotent Hetzner Cloud server provisioning. +USAGE + provision-hetzner.sh create [--type cx22|cax11] [--location fsn1] \\ + [--image debian-12] [--ssh-key ] \\ + [--firewall ] [--user-data ] + provision-hetzner.sh status + provision-hetzner.sh destroy [--force] + provision-hetzner.sh list + +ENV + HCLOUD_TOKEN (required) — load via: source ~/.claude/secrets/.env +EOF + [ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1 + ;; + *) die "unknown command '$cmd'. Run --help." 1 ;; + esac +} + +main "$@" diff --git a/_primitives/provision-vultr.sh b/_primitives/provision-vultr.sh new file mode 100755 index 0000000..5aec773 --- /dev/null +++ b/_primitives/provision-vultr.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# provision-vultr.sh — idempotent Vultr VPS provisioning. +# Wraps the `vultr-cli` v3. Install path: +# $HOME/.claude/agents/_primitives/provision-vultr.sh +# +# USAGE +# provision-vultr.sh create