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:
parent
cc3085050c
commit
94dd2d819c
38
deploy/geo-guard/install-host.sh
Normal file
38
deploy/geo-guard/install-host.sh
Normal 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"
|
||||||
38
deploy/geo-guard/probe_proxy_geo.py
Normal file
38
deploy/geo-guard/probe_proxy_geo.py
Normal 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
|
||||||
17
deploy/geo-guard/wyrs-geo-guard.service
Normal file
17
deploy/geo-guard/wyrs-geo-guard.service
Normal 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
|
||||||
136
deploy/geo-guard/wyrs_geo_guard.py
Normal file
136
deploy/geo-guard/wyrs_geo_guard.py
Normal 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()
|
||||||
@ -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):
|
||||||
|
|||||||
25
deploy/nginx/snippets/wyrs-cn-geo-access.conf
Normal file
25
deploy/nginx/snippets/wyrs-cn-geo-access.conf
Normal 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;
|
||||||
|
}
|
||||||
37
deploy/nginx/static/cn-mainland-denied.html
Normal file
37
deploy/nginx/static/cn-mainland-denied.html
Normal 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>
|
||||||
@ -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'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user