#!/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 "marketplaces

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 </health -> expect 200" echo " 2. point a domain's A record here" echo " 3. bash add-domain.sh -> issues TLS" echo " 4. add CI secrets, push to main -> first real deploy"