Add mainland China geo gate (allow Shanghai/overseas).

Nginx auth_request plus MaxMind City lookup blocks non-Shanghai mainland CN with a fixed notice page for sparkmessenger/sparkmsg (and deploy helpers for the shared Singapore host).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-22 15:28:04 +08:00
parent cc3085050c
commit 94dd2d819c
8 changed files with 324 additions and 2 deletions

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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/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/sparkmessenger.conf` → `/etc/nginx/conf.d/`(官网;`sparkmessenger.wyrs365.com` / `sparkmsg.wyrs365.com`)
- `host/spark-sni.conf` → `/etc/nginx/stream.d/` - `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`,部署: 官网静态文件在 `/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 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. 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): spark-server (Docker):

View File

@ -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;
}

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<title>访问受限</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1419;
color: #e7ecf3;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
main {
max-width: 28rem;
padding: 2rem;
text-align: center;
}
p {
margin: 0;
font-size: 1.05rem;
line-height: 1.7;
color: #c5ced8;
}
</style>
</head>
<body>
<main>
<p>按照相关部门规定大陆地区禁止访问</p>
</main>
</body>
</html>

View File

@ -36,11 +36,34 @@ if [[ ! -f "$SITE_SRC/index.html" ]]; then
exit 1 exit 1
fi fi
echo "==> upload site + nginx conf" echo "==> upload site + nginx conf + geo-guard"
"${SSH[@]}" "mkdir -p '$SITE_DST' /var/www/certbot /tmp/spark-nginx-host" "${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" \ rsync -az --delete -e "ssh -i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
"$SITE_SRC/" "$HOST:$SITE_DST/" "$SITE_SRC/" "$HOST:$SITE_DST/"
"${SCP[@]}" "$ROOT/deploy/nginx/host/sparkmessenger.conf" "$HOST:/tmp/spark-nginx-host/sparkmessenger.conf" "${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" echo "==> ensure TLS certificates"
"${SSH[@]}" bash -s -- "$PRIMARY" "${DOMAINS[@]}" <<'REMOTE' "${SSH[@]}" bash -s -- "$PRIMARY" "${DOMAINS[@]}" <<'REMOTE'