diff --git a/deploy/stc/README-STC.md b/deploy/stc/README-STC.md index 4c576c94..ad45dbc1 100644 --- a/deploy/stc/README-STC.md +++ b/deploy/stc/README-STC.md @@ -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:@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` / diff --git a/deploy/stc/docker-compose.env.example b/deploy/stc/docker-compose.env.example index 1d94ea45..0b9918c8 100644 --- a/deploy/stc/docker-compose.env.example +++ b/deploy/stc/docker-compose.env.example @@ -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" ' 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:@smtp.resend.com:2465 SMTP_URI= # MinIO file storage (internal). MINIO_USER/PASS are also injected as the diff --git a/deploy/stc/vps-nftables.conf b/deploy/stc/vps-nftables.conf new file mode 100644 index 00000000..6be621a6 --- /dev/null +++ b/deploy/stc/vps-nftables.conf @@ -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; + } +}