Merge pull request #3 from STC-Worldwide/docs/runbook-sync

Sync deploy runbook with live VPS state
pull/314/head
Timothy Fedorko 4 weeks ago committed by GitHub
commit 5b106eb64b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -53,6 +53,31 @@ Only Caddy's 80/443 (+443/udp for HTTP/3). Docker-published ports bypass the hos
default-deny nftables — never add a `ports:` mapping to any other service unless it is
bound to `127.0.0.1` or the tailnet IP.
## Firewall (nftables)
`vps-nftables.conf` in this directory is a custody copy of the live
`/etc/nftables.conf` (backup on the host: `/etc/nftables.conf.bak-20260829`).
Two amendments were made 2026-08-29 for this stack: input accepts tcp 80/443,
and the forward chain accepts `docker0`/`br-*` traffic — without the forward
rules containers have NO outbound at all (first symptom: Caddy cannot reach
Let's Encrypt). Apply changes with `nft -c -f` (check) then `nft -f`.
## Email (Resend SMTP)
Sender domain `stc-worldwide.com` is verified in Resend (DKIM/SPF/MX + DMARC
in Cloudflare). The env uses a sending-only API key scoped to that domain.
**netcup blocks outbound 465/587** — `SMTP_URI` must use Resend's alternate
port 2465 (`smtps://resend:<key>@smtp.resend.com:2465`). Healthy boot logs
`SMTP 服务可用` from the MAIL service; `SMTP 服务不可用` means the transporter
verify failed (check port reachability first). `EMAIL_VERIFY=true` is live.
## Landing page
Caddy also serves https://stc-worldwide.com (+www) from `/opt/landing`
(read-only mount). Source: `STC-Worldwide/stc-worldwide.com`; redeploy is
`scp site/index.html site/fonts/* root@152.53.82.15:/opt/landing/...` — no
container restart needed for content changes.
## Admin panel
`https://$CHAT_DOMAIN/admin/` (trailing slash required). Credentials: `ADMIN_USER` /

@ -20,9 +20,12 @@ SECRET=
# Registration requires a verified email address.
EMAIL_VERIFY=true
# Example: SMTP_SENDER="STC Chat" chat@stcbas.com
# Value with spaces MUST be single-quote wrapped or compose's .env parser
# rejects the file. Example:
# SMTP_SENDER='"STC Chat" <chat@stc-worldwide.com>'
SMTP_SENDER=
# Example: SMTP_URI=smtp://user:password@smtp.example.com/?pool=true
# netcup blocks outbound 465/587 — use Resend's alternate port 2465. Example:
# SMTP_URI=smtps://resend:<sending-only-api-key>@smtp.resend.com:2465
SMTP_URI=
# MinIO file storage (internal). MINIO_USER/PASS are also injected as the

@ -0,0 +1,97 @@
#!/usr/sbin/nft -f
# STC baseline firewall — 152.53.82.15
# applied 2026-08-28, revised same day to be safely re-appliable.
#
# This file replaces ONLY the `inet filter` table. It deliberately does NOT
# use `flush ruleset`, because that would also delete fail2ban's `f2b-table`
# and silently drop every active ban. `destroy` is delete-if-exists and does
# not error when the table is absent (needs nft >= 1.0.4; this host is 1.1.3).
#
# Safe to re-apply at any time: nft -f /etc/nftables.conf
# or: systemctl reload nftables
destroy table inet filter
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
iif lo accept comment "loopback"
ct state established,related accept
ct state invalid drop
# ICMPv6 is NOT optional: dropping it breaks neighbour discovery
# and the box loses IPv6 entirely.
meta l4proto ipv6-icmp accept comment "ND / PMTU (v6)"
ip protocol icmp accept comment "ping / PMTU (v4)"
# ---------------------------------------------------------------
# SERVICE PORTS — add new services HERE, then:
# nft -c -f /etc/nftables.conf (check)
# nft -f /etc/nftables.conf (apply)
# A service not listed here is unreachable. That is intentional.
# ---------------------------------------------------------------
tcp dport 22 accept comment "ssh"
# Tailscale. The tailnet is the trust boundary for anything served
# here, so the interface is accepted rather than each port -- but note
# that is exactly what it means: everything bound on tailscale0 is
# reachable by every node on the tailnet.
iifname "tailscale0" accept comment "tailnet"
udp dport 41641 accept comment "tailscale direct (else it falls back to DERP relay)"
tcp dport { 80, 443 } accept comment "web"
counter comment "dropped by policy"
}
# TCP MSS clamping for the tailnet.
#
# The direct tailnet path here runs over IPv6, whose minimum MTU is 1280 -
# the same number Tailscale uses for its own inner MTU. Add WireGuard and
# IPv6 headers and the largest inner packets no longer fit, so they are
# dropped in silence. Ordinary traffic is unaffected, which is why ping and
# small requests look perfectly healthy; it is the big ones that die.
#
# It first showed up as `ssh` hanging at SSH2_MSG_KEX_ECDH_REPLY, because
# OpenSSH now negotiates mlkem768x25519-sha256 and that reply carries a
# ~1184-byte ciphertext plus host key and signature. A TLS handshake with a
# real certificate chain is the same shape, so this would have hit the web
# interface too.
#
# Clamping the advertised MSS makes both directions size their segments to
# something that survives the tunnel.
chain tailnet_mss_out {
type filter hook output priority mangle; policy accept;
oifname "tailscale0" tcp flags syn tcp option maxseg size set 1160
}
# Both directions are needed, and this is the half that actually fixed it.
# Clamping only on output rewrites the MSS this host *advertises*, which
# limits what the peer sends us. The packet that was dying went the other
# way - server to client - and its size is bounded by the MSS the CLIENT
# advertised in its SYN. Rewriting that on the way in is what constrains
# our own sends.
chain tailnet_mss_in {
type filter hook prerouting priority mangle; policy accept;
iifname "tailscale0" tcp flags syn tcp option maxseg size set 1160
}
chain forward {
type filter hook forward priority filter; policy drop;
# Docker (added 2026-08-29 for Tailchat): container traffic on the
# compose bridges (br-*) and the default bridge. Docker's own
# ip-filter FORWARD chain still filters behind this, so this only
# restores what Docker expects - it does not turn the host into a
# general router.
ct state established,related accept
iifname "docker0" accept
oifname "docker0" accept
iifname "br-*" accept
oifname "br-*" accept
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Loading…
Cancel
Save