diff --git a/deploy/geo-guard/install-host.sh b/deploy/geo-guard/install-host.sh new file mode 100644 index 0000000..de895e5 --- /dev/null +++ b/deploy/geo-guard/install-host.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Install / update wyrs-geo-guard on the Singapore ECS (run on host or via SSH). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +DST=/opt/wyrs-geo-guard + +need_pkg() { + if ! rpm -q "$1" >/dev/null 2>&1; then + dnf install -y "$1" + fi +} + +need_pkg python3-geoip2 +# maxminddb usually pulled in; ensure present +need_pkg python3-maxminddb || true + +if [[ ! -f /usr/share/GeoIP/GeoLite2-City.mmdb ]]; then + echo "missing /usr/share/GeoIP/GeoLite2-City.mmdb" >&2 + exit 1 +fi + +mkdir -p "$DST" /etc/nginx/snippets /var/www/wyrs-geo-deny +install -m 0755 "$ROOT/wyrs_geo_guard.py" "$DST/wyrs_geo_guard.py" +install -m 0644 "$ROOT/wyrs-geo-guard.service" /etc/systemd/system/wyrs-geo-guard.service +install -m 0644 "$ROOT/../nginx/snippets/wyrs-cn-geo-access.conf" /etc/nginx/snippets/wyrs-cn-geo-access.conf +install -m 0644 "$ROOT/../nginx/static/cn-mainland-denied.html" /var/www/wyrs-geo-deny/index.html + +systemctl daemon-reload +systemctl enable --now wyrs-geo-guard.service +systemctl restart wyrs-geo-guard.service +sleep 0.3 +curl -fsS "http://127.0.0.1:18099/healthz" >/dev/null +echo "geo-guard ok" +# quick probes +curl -sS -o /dev/null -w "shanghai:%{http_code}\n" "http://127.0.0.1:18099/check?ip=114.80.80.80" +curl -sS -o /dev/null -w "hangzhou:%{http_code}\n" "http://127.0.0.1:18099/check?ip=223.5.5.5" +curl -sS -o /dev/null -w "us:%{http_code}\n" "http://127.0.0.1:18099/check?ip=8.8.8.8" diff --git a/deploy/geo-guard/probe_proxy_geo.py b/deploy/geo-guard/probe_proxy_geo.py new file mode 100644 index 0000000..fcaec96 --- /dev/null +++ b/deploy/geo-guard/probe_proxy_geo.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +import socket +import ssl + + +def http_via_proxy(sni, client_ip, path="/"): + raw = socket.create_connection(("127.0.0.1", 8443), timeout=8) + hdr = f"PROXY TCP4 {client_ip} 127.0.0.1 54321 8443\r\n".encode() + raw.sendall(hdr) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + tls = ctx.wrap_socket(raw, server_hostname=sni) + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {sni}\r\n" + f"Connection: close\r\n\r\n" + ).encode() + tls.sendall(req) + data = b"" + while True: + chunk = tls.recv(4096) + if not chunk: + break + data += chunk + tls.close() + head = data.split(b"\r\n\r\n", 1)[0].decode("latin1", "replace") + status = head.split("\r\n", 1)[0] + body = data.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in data else b"" + snippet = body[:160].decode("utf-8", "replace").replace("\n", " ") + print(f"{sni} ip={client_ip} -> {status} | {snippet!r}") + + +if __name__ == "__main__": + for sni in ("sparkmsg.wyrs365.com", "www.fullstorynovel.com"): + http_via_proxy(sni, "114.80.80.80") # Shanghai + http_via_proxy(sni, "223.5.5.5") # Hangzhou + http_via_proxy(sni, "8.8.8.8") # US diff --git a/deploy/geo-guard/wyrs-geo-guard.service b/deploy/geo-guard/wyrs-geo-guard.service new file mode 100644 index 0000000..cc27db3 --- /dev/null +++ b/deploy/geo-guard/wyrs-geo-guard.service @@ -0,0 +1,17 @@ +[Unit] +Description=Wyrs CN geo guard (mainland except Shanghai) +After=network.target +Wants=network-online.target + +[Service] +Type=simple +Environment=GEOIP_CITY_DB=/usr/share/GeoIP/GeoLite2-City.mmdb +Environment=WYRS_GEO_LISTEN=127.0.0.1 +Environment=WYRS_GEO_PORT=18099 +ExecStart=/usr/bin/python3 /opt/wyrs-geo-guard/wyrs_geo_guard.py +Restart=always +RestartSec=1 +User=root + +[Install] +WantedBy=multi-user.target diff --git a/deploy/geo-guard/wyrs_geo_guard.py b/deploy/geo-guard/wyrs_geo_guard.py new file mode 100644 index 0000000..401cba5 --- /dev/null +++ b/deploy/geo-guard/wyrs_geo_guard.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Local auth_request backend: allow overseas + Shanghai; block other mainland CN.""" + +import ipaddress +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Optional +from urllib.parse import parse_qs, urlparse + +import geoip2.database +from geoip2.errors import AddressNotFoundError + +DB_PATH = os.environ.get("GEOIP_CITY_DB", "/usr/share/GeoIP/GeoLite2-City.mmdb") +LISTEN = os.environ.get("WYRS_GEO_LISTEN", "127.0.0.1") +PORT = int(os.environ.get("WYRS_GEO_PORT", "18099")) + +_reader = None # type: Optional[geoip2.database.Reader] + + +def get_reader(): + global _reader + if _reader is None: + _reader = geoip2.database.Reader(DB_PATH) + return _reader + + +def client_ip(handler): + # Prefer query ?ip= for local probes; production uses X-Real-IP from nginx. + qs = parse_qs(urlparse(handler.path).query) + if "ip" in qs and qs["ip"]: + return qs["ip"][0].strip() + xri = handler.headers.get("X-Real-IP", "").strip() + if xri: + return xri.split(",")[0].strip() + return handler.client_address[0] + + +def is_trusted_local(ip): + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return True + return bool( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_reserved + ) + + +def is_shanghai_mainland(ip): + """Return True if Shanghai, False if other mainland CN, None if not mainland / unknown.""" + try: + rec = get_reader().city(ip) + except AddressNotFoundError: + return None + except Exception as exc: # keep site up if DB quirks + sys.stderr.write("geo lookup error ip=%s: %s\n" % (ip, exc)) + return None + + cc = (rec.country.iso_code or "").upper() + if cc != "CN": + return None + + for sub in rec.subdivisions: + iso = (sub.iso_code or "").upper() + name = (sub.name or "").strip().lower() + if iso == "SH" or name == "shanghai": + return True + + city = (rec.city.name or "").strip().lower() + if city == "shanghai": + return True + return False + + +def allowed(ip): + if is_trusted_local(ip): + return True + decision = is_shanghai_mainland(ip) + # None = overseas / HK-MO-TW / unknown → allow + # True = Shanghai → allow + # False = other mainland → deny + return decision is not False + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args) -> None: # noqa: A003 + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def do_GET(self) -> None: # noqa: N802 + path = self.path.split("?", 1)[0] + if path not in ("/check", "/healthz"): + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + + if path == "/healthz": + body = b"ok\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + ip = client_ip(self) + if allowed(ip): + self.send_response(204) + self.send_header("Content-Length", "0") + self.send_header("X-Wyrs-Geo", "allow") + self.end_headers() + return + + self.send_response(403) + self.send_header("Content-Length", "0") + self.send_header("X-Wyrs-Geo", "deny-cn") + self.end_headers() + + +def main() -> None: + if not os.path.isfile(DB_PATH): + print(f"missing GeoIP DB: {DB_PATH}", file=sys.stderr) + sys.exit(1) + get_reader() + httpd = ThreadingHTTPServer((LISTEN, PORT), Handler) + print(f"wyrs-geo-guard listening on {LISTEN}:{PORT} db={DB_PATH}", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index de88fa9..d72b870 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -19,6 +19,8 @@ On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/ - `host/sparkr.conf` → `/etc/nginx/conf.d/` (RustFS S3; DNS A record `sparkr.wyrs365.com` → this host) - `host/sparkmessenger.conf` → `/etc/nginx/conf.d/`(官网;`sparkmessenger.wyrs365.com` / `sparkmsg.wyrs365.com`) - `host/spark-sni.conf` → `/etc/nginx/stream.d/` +- `snippets/wyrs-cn-geo-access.conf` → `/etc/nginx/snippets/`(大陆除上海拦截;依赖 `wyrs-geo-guard`) +- `deploy/geo-guard/` → 本机 `127.0.0.1:18099` MaxMind City 鉴权(海外+上海放行) 官网静态文件在 `/var/www/sparkmessenger.wyrs365.com`,部署: @@ -26,6 +28,12 @@ On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/ bash deploy/website/deploy.sh ``` +大陆地区访问控制(官网 + 同机 `fullstorynovel.com`): + +- GeoIP:`/usr/share/GeoIP/GeoLite2-City.mmdb`(subdivision `SH` / city Shanghai 视为上海) +- 服务:`systemctl status wyrs-geo-guard` +- 拒绝页:`/var/www/wyrs-geo-deny/index.html`(文案:按照相关部门规定大陆地区禁止访问) + Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443. `00-spark-http.conf` sets `real_ip_header proxy_protocol` and `port_in_redirect off` so `$remote_addr` is the client and redirects stay on 443. spark-server (Docker): diff --git a/deploy/nginx/snippets/wyrs-cn-geo-access.conf b/deploy/nginx/snippets/wyrs-cn-geo-access.conf new file mode 100644 index 0000000..be51f6f --- /dev/null +++ b/deploy/nginx/snippets/wyrs-cn-geo-access.conf @@ -0,0 +1,25 @@ +# Include inside HTTPS server{} for public sites. +# Rule: overseas + Shanghai allow; other mainland CN → 403 Chinese notice. +# Requires: systemd wyrs-geo-guard on 127.0.0.1:18099 + +auth_request /_wyrs_geo_check; +error_page 403 /wyrs-cn-denied.html; + +location = /_wyrs_geo_check { + internal; + auth_request off; + proxy_pass http://127.0.0.1:18099/check; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_connect_timeout 100ms; + proxy_read_timeout 100ms; +} + +location = /wyrs-cn-denied.html { + internal; + auth_request off; + default_type text/html; + charset utf-8; + alias /var/www/wyrs-geo-deny/index.html; +} diff --git a/deploy/nginx/static/cn-mainland-denied.html b/deploy/nginx/static/cn-mainland-denied.html new file mode 100644 index 0000000..51b6c53 --- /dev/null +++ b/deploy/nginx/static/cn-mainland-denied.html @@ -0,0 +1,37 @@ + + + + + + + 访问受限 + + + +
+

按照相关部门规定大陆地区禁止访问

+
+ + diff --git a/deploy/website/deploy.sh b/deploy/website/deploy.sh index c05e2d4..7f6ec29 100644 --- a/deploy/website/deploy.sh +++ b/deploy/website/deploy.sh @@ -36,11 +36,34 @@ if [[ ! -f "$SITE_SRC/index.html" ]]; then exit 1 fi -echo "==> upload site + nginx conf" -"${SSH[@]}" "mkdir -p '$SITE_DST' /var/www/certbot /tmp/spark-nginx-host" +echo "==> upload site + nginx conf + geo-guard" +"${SSH[@]}" "mkdir -p '$SITE_DST' /var/www/certbot /tmp/spark-nginx-host /tmp/spark-geo-guard" rsync -az --delete -e "ssh -i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \ "$SITE_SRC/" "$HOST:$SITE_DST/" "${SCP[@]}" "$ROOT/deploy/nginx/host/sparkmessenger.conf" "$HOST:/tmp/spark-nginx-host/sparkmessenger.conf" +"${SCP[@]}" \ + "$ROOT/deploy/geo-guard/wyrs_geo_guard.py" \ + "$ROOT/deploy/geo-guard/wyrs-geo-guard.service" \ + "$ROOT/deploy/geo-guard/install-host.sh" \ + "$ROOT/deploy/nginx/snippets/wyrs-cn-geo-access.conf" \ + "$ROOT/deploy/nginx/static/cn-mainland-denied.html" \ + "$HOST:/tmp/spark-geo-guard/" +"${SSH[@]}" bash -s <<'GEO' +set -euo pipefail +mkdir -p /opt/wyrs-geo-guard /etc/nginx/snippets /var/www/wyrs-geo-deny +# install-host.sh expects repo-relative paths; invoke pieces here +if ! rpm -q python3-geoip2 >/dev/null 2>&1; then + dnf install -y python3-geoip2 +fi +install -m 0755 /tmp/spark-geo-guard/wyrs_geo_guard.py /opt/wyrs-geo-guard/wyrs_geo_guard.py +install -m 0644 /tmp/spark-geo-guard/wyrs-geo-guard.service /etc/systemd/system/wyrs-geo-guard.service +install -m 0644 /tmp/spark-geo-guard/wyrs-cn-geo-access.conf /etc/nginx/snippets/wyrs-cn-geo-access.conf +install -m 0644 /tmp/spark-geo-guard/cn-mainland-denied.html /var/www/wyrs-geo-deny/index.html +systemctl daemon-reload +systemctl enable --now wyrs-geo-guard.service +systemctl restart wyrs-geo-guard.service +curl -fsS http://127.0.0.1:18099/healthz >/dev/null +GEO echo "==> ensure TLS certificates" "${SSH[@]}" bash -s -- "$PRIMARY" "${DOMAINS[@]}" <<'REMOTE'