server-setup.sh configured ufw and stopped there, which leaves SSH open
to unlimited password guessing and the kernel on defaults that are wrong
for an internet-facing host.
Adds three drop-in files, so a re-run replaces its own config and never
edits a distro file in place:
/etc/ssh/sshd_config.d/10-marketplaces-hardening.conf
password and keyboard-interactive auth off, root key-only,
no agent/X11 forwarding, MaxAuthTries 3, 30s login grace
/etc/fail2ban/jail.d/marketplaces.local
sshd, nginx-http-auth, nginx-bad-request; 5 in 10m, 1h ban
/etc/sysctl.d/99-marketplaces-hardening.conf
no redirects or source routing, rp_filter, SYN cookies,
forwarding off, restricted kernel pointers and dmesg
Both accounts on the host are key-only by construction - the deploy user
is created with no password at all - so disabling password auth cannot
lock anyone out. It only closes guessing against a credential nobody
intended to exist.
The sshd block runs `sshd -t` first and removes its own drop-in if the
test fails. A bad sshd config that takes effect on a remote box is how
people lock themselves out permanently.
DEPLOYMENT.md §3.2 documents all three plus the post-provision checks.
Not copied from the reference implementation: its hardcoded server IP.
Kept as-is because ours is already better: add-domain.sh pre-checks the
DNS A record and runs nginx -t before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
287 lines
9.8 KiB
Bash
Executable File
287 lines
9.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# One-time server provisioning for the marketplaces frontend.
|
|
# Idempotent: safe to re-run. Run as root on the target server.
|
|
#
|
|
# bash server-setup.sh --pubkey "ssh-ed25519 AAAA... ci@marketplaces"
|
|
#
|
|
# What it does NOT do: issue TLS certificates (no domain points here yet).
|
|
# Run add-domain.sh per domain once DNS resolves. See docs/DEPLOYMENT.md.
|
|
|
|
set -euo pipefail
|
|
|
|
DEPLOY_USER="deploy"
|
|
BASE="/srv/marketplaces"
|
|
PUBKEY=""
|
|
KEEP_RELEASES=5
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--pubkey) PUBKEY="$2"; shift 2 ;;
|
|
--user) DEPLOY_USER="$2"; shift 2 ;;
|
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
|
[[ -n "$PUBKEY" ]] || { echo "--pubkey is required (the CI deploy key's PUBLIC half)" >&2; exit 1; }
|
|
|
|
echo "==> packages"
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get update -qq
|
|
apt-get install -y -qq nginx certbot python3-certbot-nginx rsync ufw jq curl openssl
|
|
|
|
echo "==> deploy user: $DEPLOY_USER"
|
|
if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then
|
|
# No password is ever set: this account is key-only by construction.
|
|
adduser --system --group --shell /bin/bash --home "/home/$DEPLOY_USER" "$DEPLOY_USER"
|
|
fi
|
|
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
|
|
AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys"
|
|
touch "$AUTH"
|
|
grep -qxF "$PUBKEY" "$AUTH" || echo "$PUBKEY" >> "$AUTH"
|
|
chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH"
|
|
chmod 600 "$AUTH"
|
|
|
|
echo "==> directories"
|
|
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE" "$BASE/releases"
|
|
# First deploy creates $BASE/current as a symlink into releases/.
|
|
# Seed a placeholder so nginx starts cleanly before anything is deployed.
|
|
if [[ ! -e "$BASE/current" ]]; then
|
|
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE/releases/bootstrap/frontend"
|
|
echo "<!doctype html><title>marketplaces</title><p>Not deployed yet." \
|
|
> "$BASE/releases/bootstrap/frontend/index.html"
|
|
chown -R "$DEPLOY_USER:$DEPLOY_USER" "$BASE/releases/bootstrap"
|
|
ln -sfn "$BASE/releases/bootstrap" "$BASE/current"
|
|
chown -h "$DEPLOY_USER:$DEPLOY_USER" "$BASE/current"
|
|
fi
|
|
|
|
echo "==> nginx catch-all (multi-tenant: one bundle serves every domain)"
|
|
cat > /etc/nginx/sites-available/marketplaces.conf <<'NGINX'
|
|
# Multi-tenant by design: the SPA derives its tenant from the Host header,
|
|
# so ONE server block serves every customer domain. Do not add a per-tenant
|
|
# root here. Per-domain server blocks exist only to hold TLS certificates
|
|
# (see add-domain.sh) and proxy to this same root.
|
|
|
|
server {
|
|
listen 80 default_server;
|
|
listen [::]:80 default_server;
|
|
server_name _;
|
|
|
|
root /srv/marketplaces/current/frontend;
|
|
index index.html;
|
|
|
|
access_log /var/log/nginx/marketplaces.access.log;
|
|
error_log /var/log/nginx/marketplaces.error.log;
|
|
|
|
# Do not let the browser cache the app shell: a deploy must take effect
|
|
# on the next reload, not whenever a stale index.html expires.
|
|
location = /index.html {
|
|
add_header Cache-Control "no-store, must-revalidate" always;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# Hashed build artifacts are immutable by construction.
|
|
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)$ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable" always;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
location /health {
|
|
access_log off;
|
|
return 200 "ok\n";
|
|
add_header Content-Type text/plain;
|
|
}
|
|
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:8080;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_read_timeout 60s;
|
|
}
|
|
|
|
# SPA fallback. Must stay last: every unmatched path is a client route.
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
|
|
gzip on;
|
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
|
gzip_min_length 1024;
|
|
}
|
|
NGINX
|
|
|
|
ln -sfn /etc/nginx/sites-available/marketplaces.conf /etc/nginx/sites-enabled/marketplaces.conf
|
|
rm -f /etc/nginx/sites-enabled/default
|
|
|
|
echo "==> firewall"
|
|
ufw allow OpenSSH >/dev/null
|
|
ufw allow 80/tcp >/dev/null
|
|
ufw allow 443/tcp >/dev/null
|
|
ufw --force enable >/dev/null
|
|
|
|
# FH-D.3. ufw alone leaves SSH open to unlimited password guessing and leaves
|
|
# the kernel on defaults that are wrong for an internet-facing host. All three
|
|
# blocks below are drop-in files, so a re-run overwrites its own config and
|
|
# never edits a distro file in place.
|
|
|
|
echo "==> sshd hardening"
|
|
cat > /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf <<'SSHD'
|
|
# Both accounts on this host are key-only by construction (the deploy user is
|
|
# created with no password at all), so password auth can only ever succeed for
|
|
# a credential nobody intended to exist.
|
|
PasswordAuthentication no
|
|
KbdInteractiveAuthentication no
|
|
PermitEmptyPasswords no
|
|
PermitRootLogin prohibit-password
|
|
X11Forwarding no
|
|
AllowAgentForwarding no
|
|
MaxAuthTries 3
|
|
LoginGraceTime 30
|
|
ClientAliveInterval 300
|
|
ClientAliveCountMax 2
|
|
SSHD
|
|
# Validate before reloading: a bad sshd config that takes effect on a remote
|
|
# box is how people lock themselves out permanently.
|
|
if sshd -t; then
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
else
|
|
echo "sshd config test FAILED - removing the drop-in and leaving sshd as it was" >&2
|
|
rm -f /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf
|
|
exit 1
|
|
fi
|
|
|
|
echo "==> fail2ban"
|
|
apt-get install -y -qq fail2ban
|
|
cat > /etc/fail2ban/jail.d/marketplaces.local <<'F2B'
|
|
[DEFAULT]
|
|
backend = systemd
|
|
findtime = 10m
|
|
bantime = 1h
|
|
maxretry = 5
|
|
|
|
[sshd]
|
|
enabled = true
|
|
|
|
[nginx-http-auth]
|
|
enabled = true
|
|
|
|
[nginx-bad-request]
|
|
enabled = true
|
|
F2B
|
|
systemctl enable --now fail2ban
|
|
systemctl restart fail2ban
|
|
|
|
echo "==> kernel hardening"
|
|
cat > /etc/sysctl.d/99-marketplaces-hardening.conf <<'SYSCTL'
|
|
# Ignore ICMP redirects and source routing: this host has one gateway and
|
|
# nothing upstream should be rewriting its routing table.
|
|
net.ipv4.conf.all.accept_redirects = 0
|
|
net.ipv4.conf.default.accept_redirects = 0
|
|
net.ipv6.conf.all.accept_redirects = 0
|
|
net.ipv6.conf.default.accept_redirects = 0
|
|
net.ipv4.conf.all.send_redirects = 0
|
|
net.ipv4.conf.default.send_redirects = 0
|
|
net.ipv4.conf.all.accept_source_route = 0
|
|
net.ipv6.conf.all.accept_source_route = 0
|
|
|
|
# Reverse-path filtering and martian logging.
|
|
net.ipv4.conf.all.rp_filter = 1
|
|
net.ipv4.conf.default.rp_filter = 1
|
|
net.ipv4.conf.all.log_martians = 1
|
|
|
|
# SYN flood resistance.
|
|
net.ipv4.tcp_syncookies = 1
|
|
net.ipv4.tcp_max_syn_backlog = 2048
|
|
net.ipv4.tcp_synack_retries = 2
|
|
|
|
# No IP forwarding: this is a web server, not a router.
|
|
net.ipv4.ip_forward = 0
|
|
|
|
# Restrict kernel pointer and dmesg exposure to unprivileged users.
|
|
kernel.kptr_restrict = 2
|
|
kernel.dmesg_restrict = 1
|
|
SYSCTL
|
|
sysctl --quiet --system
|
|
|
|
echo "==> nginx config test"
|
|
nginx -t
|
|
systemctl enable --now nginx
|
|
systemctl reload nginx
|
|
|
|
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
echo "==> tenant API-domain configurator"
|
|
if [[ -f "$SRC_DIR/configure-api-domain.sh" ]]; then
|
|
install -m 755 -o root -g root "$SRC_DIR/configure-api-domain.sh" \
|
|
/usr/local/sbin/marketplaces-configure-api-domain
|
|
else
|
|
echo "configure-api-domain.sh not found next to server-setup.sh" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "==> dynamic domain reconciler"
|
|
install -d -m 755 "$BASE/bin" /etc/marketplaces "/var/lib/marketplaces"
|
|
if [[ -f "$SRC_DIR/sync-domains.sh" ]]; then
|
|
install -m 755 "$SRC_DIR/sync-domains.sh" "$BASE/bin/sync-domains.sh"
|
|
|
|
if [[ ! -f /etc/marketplaces/domains.env ]]; then
|
|
cat > /etc/marketplaces/domains.env <<'ENVFILE'
|
|
# Where the desired domain list comes from.
|
|
# file:/etc/marketplaces/domains.txt one hostname per line
|
|
# https://api.example.com/api/admin/v2/domains JSON, once the backend exists
|
|
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
|
|
|
|
# Required: certbot expiry notices.
|
|
CERTBOT_EMAIL=
|
|
|
|
# Cap per run so a bad source cannot burn the weekly ACME budget in one pass.
|
|
MAX_ISSUE_PER_RUN=10
|
|
|
|
# Set by setup-wildcard-tls.sh. Subdomains of this apex skip per-domain issuance.
|
|
#WILDCARD_APEX=
|
|
ENVFILE
|
|
chmod 600 /etc/marketplaces/domains.env
|
|
fi
|
|
touch /etc/marketplaces/domains.txt
|
|
|
|
if [[ -d "$SRC_DIR/systemd" ]]; then
|
|
install -m 644 "$SRC_DIR/systemd/marketplaces-domains.service" /etc/systemd/system/
|
|
install -m 644 "$SRC_DIR/systemd/marketplaces-domains.timer" /etc/systemd/system/
|
|
systemctl daemon-reload
|
|
# Not started yet: CERTBOT_EMAIL is still blank. Enable it after filling in
|
|
# /etc/marketplaces/domains.env, or the first run just fails on every tick.
|
|
echo " timer installed but NOT started - set CERTBOT_EMAIL first, then:"
|
|
echo " systemctl enable --now marketplaces-domains.timer"
|
|
fi
|
|
else
|
|
echo " sync-domains.sh not found next to this script - skipping"
|
|
fi
|
|
|
|
echo "==> sudoers: deployment reload plus validated tenant API provisioning"
|
|
cat > /etc/sudoers.d/marketplaces-deploy <<SUDO
|
|
Cmnd_Alias MARKETPLACES_DEPLOY = /bin/systemctl reload nginx, /usr/local/sbin/marketplaces-configure-api-domain *
|
|
$DEPLOY_USER ALL=(root) NOPASSWD: MARKETPLACES_DEPLOY
|
|
SUDO
|
|
chmod 440 /etc/sudoers.d/marketplaces-deploy
|
|
visudo -c -f /etc/sudoers.d/marketplaces-deploy
|
|
|
|
echo
|
|
echo "done."
|
|
echo " deploy user : $DEPLOY_USER (key-only, no password)"
|
|
echo " web root : $BASE/current/frontend"
|
|
echo " keep : last $KEEP_RELEASES releases"
|
|
echo
|
|
echo "next:"
|
|
echo " 1. curl -I http://<this-server>/health -> expect 200"
|
|
echo " 2. point a domain's A record here"
|
|
echo " 3. bash add-domain.sh <domain> -> issues TLS"
|
|
echo " 4. add CI secrets, push to main -> first real deploy"
|