# Debian 12 two-phase bootstrap and lockdown

Sanitized example runbook for provisioning a new Debian 12 host. Replace the bracketed placeholders before you run anything. Safe to paste into an agent session alongside the provisioning prompt.

## Variables

| Variable | Example | Purpose |
| -------- | ------- | ------- |
| `HOSTNAME` | `app-edge-01` | `hostnamectl` value and Tailscale machine name |
| `PUB_IF` | `ens18` | Public WAN NIC name (`ip -br link`) |
| `ADMIN_HOME_WAN` | `203.0.113.50` | Your home public IPv4 for break-glass SSH and fail2ban whitelist |
| `ADMIN_USER` | `mark` | Non-root admin account |
| `TAILSCALE_TAGS` | `tag:edge,tag:uses-infisical` | Tags passed to `tailscale up` |
| `PUBLIC_WAN_PORTS` | `80,443` or `none` | TCP ports to allow from WAN on `PUB_IF` |
| `GUARD_TABLE` | `app_edge_guard` | nftables table name (`inet` family) |
| `TIMEZONE` | `America/New_York` | Host timezone |

Optional host-specific packages (install in Phase 1 if needed): `docker.io`, `qemu-guest-agent`, Node.js, and so on. The base list below covers a generic app VM.

## Rules

1. Run Phase 1 and Phase 2 separately. Never apply the firewall in the same pass as SSH hardening.
2. Phase 2 starts only after Tailscale is joined **and** SSH works as `ADMIN_USER` over Tailscale.
3. Do not put credentials in this file. Pull secrets at runtime from your vault when a step needs them.
4. On Docker hosts, never `flush ruleset`. Use a dedicated `inet GUARD_TABLE` table only.

## Phase 1: Bootstrap

Run as **root** over SSH. Stop before applying the host firewall.

### 1. Host identity

```bash
hostnamectl set-hostname HOSTNAME
timedatectl set-timezone TIMEZONE
```

### 2. Updates and base packages

```bash
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade
apt-get install -y sudo fail2ban unattended-upgrades apt-listchanges \
  nftables curl ca-certificates gnupg lsb-release tmux git
```

Add `qemu-guest-agent` on Proxmox VMs and enable it if you use guest tools.

### 3. Admin user and SSH key

Create `ADMIN_USER`, copy the root authorized key, and grant passwordless sudo:

```bash
useradd -m -s /bin/bash ADMIN_USER 2>/dev/null || true
usermod -aG sudo ADMIN_USER
install -d -m 700 /home/ADMIN_USER/.ssh
cp /root/.ssh/authorized_keys /home/ADMIN_USER/.ssh/authorized_keys
chown -R ADMIN_USER:ADMIN_USER /home/ADMIN_USER/.ssh
chmod 600 /home/ADMIN_USER/.ssh/authorized_keys

install -d -m 750 /etc/sudoers.d
umask 077
TMP="$(mktemp)"
cat >"$TMP" <<EOF
ADMIN_USER ALL=(ALL:ALL) NOPASSWD:ALL
EOF
visudo -c -f "$TMP"
install -m 440 "$TMP" /etc/sudoers.d/99-admin-nopasswd
rm -f "$TMP"
```

Replace `ADMIN_USER`, `HOSTNAME`, and other placeholders with the values from the variables table before running each block.

### 4. SSH hardening

```bash
install -d -m 755 /etc/ssh/sshd_config.d
cat >/etc/ssh/sshd_config.d/99-keepalive.conf <<'EOF'
ClientAliveInterval 120
ClientAliveCountMax 3
EOF
cat >/etc/ssh/sshd_config.d/99-keyonly.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
sshd -t && systemctl reload ssh
```

### 5. fail2ban

Whitelist Tailscale CGNAT (`100.64.0.0/10`) and your home WAN IP:

```bash
cat >/etc/fail2ban/jail.d/sshd.local <<EOF
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10 ADMIN_HOME_WAN/32

[sshd]
enabled = true
port    = ssh
backend = systemd
maxretry = 5
findtime = 10m
bantime  = 1h
EOF
systemctl enable --now fail2ban
```

### 6. Unattended security upgrades

```bash
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
systemctl enable --now unattended-upgrades
```

### 7. Tailscale install (join is manual)

```bash
curl -fsSL https://tailscale.com/install.sh | sh
systemctl enable --now tailscaled
```

**Stop here.** The operator joins Tailscale manually:

```bash
tailscale up --accept-dns=false --hostname=HOSTNAME --advertise-tags=TAILSCALE_TAGS
```

Use a pre-issued auth key instead of browser login if you automate that step. Tag assignment is a security decision: get it right at join time.

### Phase 1 gate

Before Phase 2, confirm:

- [ ] `tailscale status` shows the host on your tailnet
- [ ] `ssh ADMIN_USER@100.x.x.x` works with your key
- [ ] Root password login is disabled
- [ ] fail2ban is running

## Phase 2: Lockdown

Run as **root** only after the Phase 1 gate passes.

Write `/etc/nftables.conf` using a dedicated guard table. Example for a single-NIC edge host with break-glass WAN SSH and optional public web ports:

```nft
#!/usr/sbin/nft -f
# Docker-safe: do not flush ruleset.

define ADMIN_HOME_WAN = 203.0.113.50

table inet GUARD_TABLE {
  chain input {
    type filter hook input priority -100; policy accept;

    ct state established,related accept
    iif lo accept

    iifname "tailscale0" accept

    # Docker bridge SSH (if Docker is installed later)
    iifname "br-*" tcp dport 22 accept
    iifname "docker0" tcp dport 22 accept

    # Tailscale direct path + break-glass WAN SSH
    iifname "PUB_IF" udp dport 41641 accept
    iifname "PUB_IF" ip saddr $ADMIN_HOME_WAN tcp dport 22 accept

    # Public services (omit this block if PUBLIC_WAN_PORTS is none)
    iifname "PUB_IF" tcp dport { 80, 443 } accept
    iifname "PUB_IF" udp dport 443 accept

    icmp type echo-request limit rate 5/second burst 5 packets accept
    icmpv6 type echo-request limit rate 5/second burst 5 packets accept

    iifname "PUB_IF" drop
  }

  chain forward {
    type filter hook forward priority -100; policy accept;
    ct state established,related accept
    iifname "tailscale0" accept
    iifname "PUB_IF" drop
  }

  chain output {
    type filter hook output priority filter; policy accept;
  }
}
```

Replace placeholders in the file:

- `GUARD_TABLE` with your table name (e.g. `app_edge_guard`)
- `PUB_IF` with the WAN interface name
- `ADMIN_HOME_WAN` with your home IPv4 in the `define` line (example above uses a documentation-only address)
- Remove the `80, 443` accept lines if this host has no public web service

Apply and enable:

```bash
nft -c -f /etc/nftables.conf
systemctl enable nftables
systemctl restart nftables
nft list table inet GUARD_TABLE
```

### Phase 2 verify

- [ ] Tailscale SSH to `ADMIN_USER` still works
- [ ] WAN SSH works from `ADMIN_HOME_WAN` only
- [ ] Unsolicited WAN traffic to other ports is dropped
- [ ] Public web ports behave as expected (if enabled)

### Recovery

If you lock yourself out, use the provider console or hypervisor shell:

```bash
nft delete table inet GUARD_TABLE
systemctl restart nftables
```

Fix the ruleset, then reapply.

## Optional: runtime secrets

If a bootstrap step needs a Tailscale auth key, backup credentials, or vault token, pull it at runtime from your secrets manager. Do not commit values to git or paste them into shared prompts.

Example pattern:

```bash
infisical run --env=prod --path=/hosts/HOSTNAME -- tailscale up ...
```

Adjust the tool and path to match your setup.
