feat: 合并边缘节点运维并完善 Surge 配置安全

迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
2026-10-03 23:19:59 +08:00
parent 7358008936
commit 5326c572a2
93 changed files with 6780 additions and 320 deletions

View File

@@ -0,0 +1,764 @@
#!/usr/bin/env python3
"""Build the offline fleet delivery page from a private JSON inventory.
Share links and QR codes are read locally and embedded in the output. No network
requests are made. The default inventory and output live in Git-ignored private/.
Add sites to the inventory; do not embed live credentials in this source file.
"""
import argparse
import base64
import html
import json
import os
import sys
import urllib.parse
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fleet", type=Path, default=Path(__file__).resolve().parent / "private/fleet.json",
help="private inventory; relative links_dir paths resolve beside this file")
parser.add_argument("--output", type=Path, help="output HTML (default: beside the inventory)")
args = parser.parse_args()
fleet_path = args.fleet.resolve()
try:
fleet = json.loads(fleet_path.read_text(encoding="utf-8"))
except FileNotFoundError:
sys.exit("ERROR: fleet inventory not found; see edge-nodes/README.md")
except json.JSONDecodeError:
sys.exit("ERROR: fleet inventory is not valid JSON")
SITES = fleet.get("sites")
if not isinstance(SITES, list) or not SITES:
sys.exit("ERROR: fleet inventory must contain a non-empty sites list")
UPDATED = fleet["updated"]
for site in SITES:
site["links_dir"] = str((fleet_path.parent / site["links_dir"]).resolve())
OUT = str(args.output.resolve() if args.output else fleet_path.parent / "边缘节点交付文档.html")
# ---------------------------------------------------------------- asset loading
def read_link(site, slug):
p = os.path.join(site["links_dir"], slug + ".link")
return open(p).read().strip() if os.path.exists(p) else None
def read_qr(site, slug):
p = os.path.join(site["links_dir"], slug + "-qr.png")
if not os.path.exists(p):
return None
return base64.b64encode(open(p, "rb").read()).decode()
def read_json(site, slug):
p = os.path.join(site["links_dir"], slug + "-singbox.json")
return open(p).read().strip() if os.path.exists(p) else None
missing = []
for s in SITES:
for ln in s["lines"]:
ln["link"] = read_link(s, ln["slug"])
ln["qr"] = read_qr(s, ln["slug"])
ln["json"] = read_json(s, ln["slug"])
if ln["has_link"] and not ln["link"]:
missing.append("%s/%s.link" % (s["id"], ln["slug"]))
if missing:
sys.exit("ERROR: missing link files: " + ", ".join(missing))
TOTAL = sum(len(s["lines"]) for s in SITES)
# ---------------------------------------------------------------- subscription
# One aggregated subscription so the client picks the exit itself, instead of us
# baking a routing policy into a config. Node names follow a fixed grammar:
# [国家码] [站点码] [协议] [Direct|Warp]
# The site code is dropped when it would repeat the country code (SG's only node
# would otherwise read "SG SG ..."), so Singapore lines are three words wide.
#
# Everything here is computed at build time and inlined; the page never fetches
# anything. There is no hosted https:// endpoint on purpose -- see the "自建 URL"
# note in the rendered block.
def sub_name(site, ln):
parts = [site["country"]]
if site["code"] != site["country"]:
parts.append(site["code"])
parts.append(ln["proto"])
parts.append("Direct" if ln["egress"] == "direct" else "Warp")
return " ".join(parts)
def rename_link(link, name):
"""Rewrite a share link's display name, leaving every transport field alone."""
if link.startswith("vmess://"):
raw = link[len("vmess://"):]
raw += "=" * (-len(raw) % 4)
cfg = json.loads(base64.b64decode(raw).decode())
cfg["ps"] = name
blob = json.dumps(cfg, ensure_ascii=False, separators=(",", ":"))
return "vmess://" + base64.b64encode(blob.encode()).decode()
return link.split("#", 1)[0] + "#" + urllib.parse.quote(name)
for s in SITES:
for ln in s["lines"]:
ln["sub_name"] = sub_name(s, ln)
ln["sub_link"] = rename_link(ln["link"], ln["sub_name"]) if ln["link"] else None
names = [ln["sub_name"] for s in SITES for ln in s["lines"]]
dupes = sorted({n for n in names if names.count(n) > 1})
if dupes:
sys.exit("ERROR: duplicate node names: " + ", ".join(dupes))
def group(gid, label, sites):
lines = [ln for s in sites for ln in s["lines"] if ln["sub_link"]]
plain = "\n".join(ln["sub_link"] for ln in lines)
return {
"id": gid, "label": label, "n": len(lines),
"plain": plain,
"b64": base64.b64encode(plain.encode()).decode(),
"items": [{"name": ln["sub_name"], "egress": ln["egress"],
"transport": ln["transport"]} for ln in lines],
}
GROUPS = [group("all", "全部站点", SITES)] + \
[group(s["id"], "%s %s" % (s["code"], s["city"]), [s]) for s in SITES]
# ---------------------------------------------------------------- render helpers
e = html.escape
def kv_rows(pairs, mono_value=True):
cls = ' class="v mono"' if mono_value else ' class="v"'
return "\n".join(
' <div class="kv"><dt>%s</dt><dd%s>%s</dd></div>' % (e(k), cls, e(v))
for k, v in pairs)
def copy_btn(value, label="复制"):
return ('<button class="copy" type="button" data-copy="%s" aria-label="复制%s">%s</button>'
% (e(value), e(label), e(label)))
def line_card(ln, idx):
eg = ln["egress"]
egtxt = "直连出口" if eg == "direct" else "WARP 净出口"
qr_html = ""
if ln["qr"]:
qr_html = ('<button class="qr" type="button" data-qr="%s" data-title="%s" data-link="%s" '
'aria-label="放大查看 %s 的二维码">'
'<img src="data:image/png;base64,%s" alt=""></button>'
% (ln["qr"], e(ln["proto"] + " · " + egtxt), e(ln["link"] or ""),
e(ln["proto"]), ln["qr"]))
else:
qr_html = '<div class="qr noqr" aria-hidden="true"><span>无标准<br>分享链接</span></div>'
body = []
body.append('<div class="ln-head">'
'<span class="proto">%s</span>'
'<span class="pill %s">%s</span>'
'</div>' % (e(ln["proto"]), eg, e(egtxt)))
body.append('<div class="ln-meta"><span>%s</span><span class="sep">·</span><span class="mono">%s</span></div>'
% (e(ln["transport"]), e(ln["endpoint"])))
if ln.get("plaintext"):
body.append('<p class="ln-warn">认证与流量均为明文,链路上可被读取,且特征易被识别。'
'仅适合可信网络内使用,不要用于穿越审查。</p>')
if ln["link"]:
body.append('<div class="ln-link"><code>%s</code>%s</div>'
% (e(ln["link"]), copy_btn(ln["link"], "复制链接")))
if ln["json"]:
body.append('<details class="ln-json"><summary>sing-box 配置片段</summary>'
'<pre>%s</pre>%s</details>'
% (e(ln["json"]), copy_btn(ln["json"], "复制配置")))
cls = eg + (" plain" if ln.get("plaintext") else "")
return (' <article class="line %s">\n %s\n <div class="ln-body">%s</div>\n </article>'
% (cls, qr_html, "".join(body)))
def matrix(site):
"""Compact routing strip: one cell per line, colour = egress. The fleet's shape at a glance."""
cells = []
for ln in site["lines"]:
cls = "plain" if ln.get("plaintext") else ln["egress"]
label = "明文" if ln.get("plaintext") else ("直连" if ln["egress"] == "direct" else "WARP")
cells.append('<span class="cell %s" title="%s · %s">%s</span>'
% (cls, e(ln["proto"]), label, e(ln["proto"][:2])))
return '<div class="matrix">%s</div>' % "".join(cells)
def render_site(site):
p = []
p.append(' <section class="panel" id="panel-%s" role="tabpanel" aria-labelledby="tab-%s" tabindex="0">' % (site["id"], site["id"]))
# identity
p.append(' <div class="ident">')
p.append(' <div class="ident-main"><span class="flag">%s</span>'
'<h2>%s<span class="city">%s</span></h2></div>' % (site["flag"], e(site["code"]), e(site["city"])))
p.append(' <div class="ident-sub"><code>%s</code><span class="tagline">%s</span></div>' % (e(site["domain"]), e(site["tagline"])))
p.append(' %s' % matrix(site))
p.append(' </div>')
# 01 站点
p.append(' <section class="block"><h3><i>01</i>站点</h3>')
p.append(' <dl class="grid">')
p.append(kv_rows([("服务商", site["provider"]), ("区域", site["region"])], mono_value=False))
p.append(kv_rows(site["spec"], mono_value=False))
p.append(kv_rows(site["net"]))
p.append(kv_rows(site["access"]))
p.append(' <div class="kv full"><dt>拥塞控制</dt><dd class="v">%s</dd></div>' % e(site["bbr"]))
p.append(' </dl>')
if site["notes"]:
p.append(' <ul class="notes">%s</ul>' % "".join("<li>%s</li>" % e(n) for n in site["notes"]))
p.append(' </section>')
# 02 账号
p.append(' <section class="block"><h3><i>02</i>账号</h3>')
for pan in site["panels"]:
p.append(' <div class="acct">')
p.append(' <div class="acct-top"><span class="acct-name">%s</span>'
'<a href="%s" target="_blank" rel="noopener">%s</a></div>' % (e(pan["name"]), e(pan["url"]), e(pan["url"])))
p.append(' <div class="acct-cred">'
'<span class="lbl">用户名</span><code>%s</code>%s'
'<span class="lbl">密码</span><code>%s</code>%s</div>'
% (e(pan["user"]), copy_btn(pan["user"], "复制用户名"),
e(pan["pass"]), copy_btn(pan["pass"], "复制密码")))
if pan.get("note"):
p.append(' <p class="acct-note">%s</p>' % e(pan["note"]))
p.append(' </div>')
p.append(' <dl class="grid plain">')
p.append("\n".join(
' <div class="kv"><dt>%s</dt><dd class="v"><a href="%s" target="_blank" rel="noopener">%s</a>'
'<span class="hint">%s</span></dd></div>' % (e(n), e(u), e(u), e(d))
for n, u, d in site["sites_plain"]))
p.append(' </dl>')
p.append(' </section>')
# 03 节点
p.append(' <section class="block"><h3><i>03</i>节点</h3>')
p.append(' <div class="shared"><div class="shared-hd">共用凭据 · 全部 %d 条线路同一套身份</div>' % len(site["lines"]))
for label, val, scope in site["creds"]:
p.append(' <div class="cred"><span class="lbl">%s</span><code>%s</code>%s'
'<span class="scope">%s</span></div>' % (e(label), e(val), copy_btn(val, "复制"+label), e(scope)))
p.append(' <div class="cred"><span class="lbl">实测出口</span>'
'<span class="pill direct">直连 %s</span>'
'<span class="pill warp">WARP Cloudflare</span></div>' % e(site["egress_direct"]))
p.append(' </div>')
p.append(' <div class="lines">')
for i, ln in enumerate(site["lines"]):
p.append(line_card(ln, i))
p.append(' </div>')
p.append(' </section>')
p.append(' </section>')
return "\n".join(p)
def render_subs():
chips = "\n".join(
' <button class="chip" type="button" data-group="%s" aria-pressed="%s">'
'%s<span class="chip-n">%d</span></button>'
% (g["id"], "true" if i == 0 else "false", e(g["label"]), g["n"])
for i, g in enumerate(GROUPS))
data = json.dumps({g["id"]: {"plain": g["plain"], "b64": g["b64"], "items": g["items"]}
for g in GROUPS}, ensure_ascii=False).replace("<", "\\u003c")
return """
<section class="subs" id="subs">
<div class="subs-hd">
<h2>订阅</h2>
<p>把全部线路打成一份订阅,节点名统一为 <code>[国家码] [站点码] [协议] [Direct/Warp]</code>,
导入后在客户端里自行挑线路。内容由本页离线生成,不访问任何服务器。</p>
</div>
<div class="chips" role="group" aria-label="订阅范围">
%(chips)s
</div>
<div class="subs-act">
<button class="act primary" type="button" id="sub-dl">下载订阅文件</button>
<button class="act copy" type="button" id="sub-b64" data-copy="">复制订阅内容(Base64)</button>
<button class="act copy" type="button" id="sub-plain" data-copy="">复制明文链接</button>
<span class="subs-count" id="sub-count"></span>
</div>
<ol class="sub-list" id="sub-list"></ol>
<details class="subs-how">
<summary>怎么导入</summary>
<div class="how-body">
<p><b>v2rayN / v2rayNG / NekoBox</b> 点「复制明文链接」,在客户端里选「从剪贴板导入批量 URL」。
也可先「下载订阅文件」,再用本地文件导入。</p>
<p><b>Shadowrocket / Stash</b> 点「复制订阅内容(Base64)」,回到 App 会提示识别到订阅内容;
或把下载的 .txt 通过 AirDrop / 文件 App 传到手机后用「打开方式」导入。</p>
<p><b>Clash 系(Verge / Meta)</b> 不吃这种 Base64 订阅,需要先转成 Clash 配置;
建议改用 sing-box 或 Xray 内核的客户端,或直接使用上方各线路的 sing-box 配置片段。</p>
</div>
</details>
<details class="subs-how">
<summary>使用 Proxy Station 托管订阅</summary>
<div class="how-body">
<p>在 Proxy Station 的「节点」页粘贴本页对应的 VMess、Trojan、Shadowsocks、Hysteria2
分享链接,解析预览并导入,再到「订阅」页创建订阅并复制客户端 URL。</p>
<p>SOCKS5 和 TUIC 线路目前不属于 Proxy Station 支持的节点类型,需单独使用。
Shadowsocks over WebSocket 在 Surge 订阅中会被排除。</p>
<p>本页含真实凭据,请只在本机或通过受控文件交付使用。</p>
</div>
</details>
</section>
<script type="application/json" id="sub-data">%(data)s</script>
""" % {"chips": chips, "data": data}
subs = render_subs()
tabs = "\n".join(
' <button class="tab" id="tab-%s" role="tab" aria-controls="panel-%s" aria-selected="%s" tabindex="%s">'
'<span class="t-flag">%s</span><span class="t-code">%s</span>'
'<span class="t-city">%s</span><span class="t-n">%d</span></button>'
% (s["id"], s["id"], "true" if i == 0 else "false", "0" if i == 0 else "-1",
s["flag"], e(s["code"]), e(s["city"]), len(s["lines"]))
for i, s in enumerate(SITES))
panels = "\n".join(render_site(s) for s in SITES)
CSS = """
:root{
--ink:#0A0D13; --panel:#121722; --panel2:#171E2B; --rule:#222B3A;
--text:#D9E1EB; --dim:#78848F; --faint:#4C5666;
--direct:#E39B3D; --warp:#3FC7BB; --live:#56C86E; --risk:#E06C6C;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--ink);color:var(--text);font-family:var(--sans);
line-height:1.65;font-size:15px;padding:0 0 64px}
.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums}
a{color:#6FB2F5;text-decoration:none}
a:hover{text-decoration:underline}
.wrap{max-width:1000px;margin:0 auto;padding:0 20px}
/* masthead */
.masthead{border-bottom:1px solid var(--rule);background:linear-gradient(180deg,#0D121B,#0A0D13);
padding:26px 0 20px;margin-bottom:0}
.mh-in{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
.brand{font-size:1.35rem;font-weight:700;letter-spacing:.06em}
.brand span{color:var(--faint);font-weight:400;margin:0 .5em}
.mh-meta{font-family:var(--mono);font-size:.76rem;color:var(--dim);letter-spacing:.04em}
.mh-meta b{color:var(--text);font-weight:600}
/* tabs */
.tabs{display:flex;gap:8px;padding:14px 0 0;flex-wrap:wrap;
position:sticky;top:0;z-index:20;background:var(--ink);border-bottom:1px solid var(--rule)}
.tab{appearance:none;background:transparent;border:1px solid transparent;border-bottom:none;
border-radius:9px 9px 0 0;color:var(--dim);font-family:var(--sans);font-size:.9rem;
padding:9px 16px 11px;cursor:pointer;display:flex;align-items:center;gap:8px;
transition:color .15s,background .15s;margin-bottom:-1px}
.tab:hover{color:var(--text);background:#0F141E}
.tab[aria-selected="true"]{color:var(--text);background:var(--panel);
border-color:var(--rule);box-shadow:inset 0 2px 0 var(--warp)}
.tab:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.t-flag{font-size:1rem;line-height:1}
.t-code{font-family:var(--mono);font-weight:700;letter-spacing:.06em}
.t-city{color:var(--faint);font-size:.82rem}
.tab[aria-selected="true"] .t-city{color:var(--dim)}
.t-n{font-family:var(--mono);font-size:.7rem;color:var(--faint);
border:1px solid var(--rule);border-radius:20px;padding:0 7px}
/* panel */
.panel{background:var(--panel);border:1px solid var(--rule);border-top:none;
border-radius:0 0 12px 12px;padding:26px 26px 30px}
.panel[hidden]{display:none}
.panel:focus-visible{outline:2px solid var(--warp);outline-offset:-2px}
/* identity */
.ident{border-bottom:1px solid var(--rule);padding-bottom:18px;margin-bottom:4px}
.ident-main{display:flex;align-items:center;gap:12px}
.ident-main .flag{font-size:1.6rem;line-height:1}
.ident h2{margin:0;font-size:1.5rem;letter-spacing:.08em;font-family:var(--mono);font-weight:700;
display:flex;align-items:baseline;gap:12px}
.ident h2 .city{font-family:var(--sans);font-size:.95rem;font-weight:400;letter-spacing:0;color:var(--dim)}
.ident-sub{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px}
.ident-sub code{font-size:.86rem;color:#9DB2C8;background:none;padding:0}
.tagline{font-size:.76rem;color:var(--faint);border-left:1px solid var(--rule);padding-left:14px}
/* routing matrix */
.matrix{display:flex;gap:4px;margin-top:14px;flex-wrap:wrap}
.matrix .cell{font-family:var(--mono);font-size:.66rem;letter-spacing:.04em;
padding:3px 7px;border-radius:4px;border:1px solid;line-height:1.4}
.matrix .cell.direct{color:var(--direct);border-color:rgba(227,155,61,.35);background:rgba(227,155,61,.08)}
.matrix .cell.warp{color:var(--warp);border-color:rgba(63,199,187,.35);background:rgba(63,199,187,.08)}
.matrix .cell.plain{color:var(--risk);border-color:rgba(224,108,108,.4);background:rgba(224,108,108,.09)}
/* blocks */
.block{margin-top:30px;scroll-margin-top:72px} /* clear the sticky tab bar */
.block h3{display:flex;align-items:center;gap:12px;margin:0 0 14px;font-size:.95rem;
font-weight:600;letter-spacing:.14em;color:var(--text);
border-bottom:1px solid var(--rule);padding-bottom:9px}
.block h3 i{font-style:normal;font-family:var(--mono);font-size:.72rem;color:var(--faint);
border:1px solid var(--rule);border-radius:4px;padding:2px 6px;letter-spacing:.06em}
/* key-value grid */
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;
background:var(--rule);border:1px solid var(--rule);border-radius:9px;overflow:hidden}
.grid.plain{grid-template-columns:1fr;margin-top:14px}
.kv{background:var(--panel2);padding:9px 13px;display:flex;gap:12px;align-items:baseline;min-width:0}
.kv dt{color:var(--dim);font-size:.78rem;flex:0 0 84px;white-space:nowrap}
/* break long unbroken tokens (IPv6, UUID) but never mid-number in a spaced list
like "UDP 443 / 34941 / 52888" -- break-all would split 52888 across lines */
.kv dd{margin:0;min-width:0;word-break:normal;overflow-wrap:break-word;font-size:.85rem}
.kv.full{grid-column:1/-1}
.kv dd.mono{font-family:var(--mono);font-size:.8rem}
.kv .hint{display:block;color:var(--faint);font-size:.74rem;font-family:var(--sans)}
.notes{margin:14px 0 0;padding:0;list-style:none;display:grid;gap:7px}
.notes li{position:relative;padding-left:18px;font-size:.8rem;color:var(--dim);line-height:1.6}
.notes li::before{content:"";position:absolute;left:4px;top:.62em;width:5px;height:5px;
border-radius:50%;background:var(--faint)}
/* accounts */
.acct{background:var(--panel2);border:1px solid var(--rule);border-radius:9px;
padding:14px 16px;margin-bottom:10px}
.acct-top{display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:9px}
.acct-name{font-weight:600;font-size:.92rem}
.acct-top a{font-family:var(--mono);font-size:.79rem;word-break:break-all}
.acct-cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.acct-cred code,.cred code{background:#0D121B;border:1px solid var(--rule);border-radius:5px;
padding:3px 9px;font-size:.8rem;color:#9FE0B0}
.lbl{color:var(--dim);font-size:.75rem}
.acct-note{margin:10px 0 0;font-size:.78rem;color:var(--direct);line-height:1.6}
/* shared credentials */
.shared{background:var(--panel2);border:1px solid var(--rule);border-radius:9px;padding:14px 16px}
.shared-hd{font-size:.78rem;color:var(--dim);margin-bottom:11px;letter-spacing:.03em}
.cred{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:5px 0}
.cred .scope{font-size:.73rem;color:var(--faint)}
/* pills */
.pill{font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;padding:2px 9px;
border-radius:20px;border:1px solid;white-space:nowrap}
.pill.direct{color:var(--direct);border-color:rgba(227,155,61,.4);background:rgba(227,155,61,.09)}
.pill.warp{color:var(--warp);border-color:rgba(63,199,187,.4);background:rgba(63,199,187,.09)}
/* line cards */
.lines{display:grid;gap:10px;margin-top:14px}
.line{display:flex;gap:15px;background:var(--panel2);border:1px solid var(--rule);
border-left:3px solid var(--rule);border-radius:9px;padding:13px 15px;align-items:flex-start}
.line.direct{border-left-color:var(--direct)}
.line.warp{border-left-color:var(--warp)}
.line.plain{border-left-color:var(--risk)}
.ln-warn{margin:8px 0 0;font-size:.75rem;color:var(--risk);line-height:1.6}
.line .qr{flex:0 0 84px;width:84px;height:84px;padding:5px;background:#fff;border:none;
border-radius:7px;cursor:zoom-in;display:block}
.line .qr img{width:100%;height:100%;display:block;image-rendering:pixelated}
.line .qr:focus-visible{outline:2px solid var(--warp);outline-offset:3px}
.line .qr.noqr{background:#0D121B;border:1px dashed var(--rule);cursor:default;
display:flex;align-items:center;justify-content:center;text-align:center}
.line .qr.noqr span{font-size:.66rem;color:var(--faint);line-height:1.5}
.ln-body{min-width:0;flex:1}
.ln-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.proto{font-weight:600;font-size:.95rem}
.ln-meta{font-size:.77rem;color:var(--dim);margin-top:3px;display:flex;gap:7px;align-items:baseline;flex-wrap:wrap}
.ln-meta .sep{color:var(--faint)}
.ln-link{display:flex;align-items:flex-start;gap:8px;margin-top:9px}
/* a VMess link is a 500-char base64 blob; cap it so it can't dominate the card.
The QR and the copy button are the real affordances -- the text is for reference. */
.ln-link code{flex:1;min-width:0;background:#0D121B;border:1px solid var(--rule);border-radius:6px;
padding:7px 10px;font-size:.71rem;line-height:1.55;word-break:break-all;color:#8FCBA0;
max-height:4.9em;overflow:auto}
.ln-json{margin-top:9px}
.ln-json summary{cursor:pointer;font-size:.77rem;color:var(--dim);width:fit-content}
.ln-json summary:hover{color:var(--text)}
.ln-json summary:focus-visible{outline:2px solid var(--warp);outline-offset:2px;border-radius:3px}
.ln-json pre{margin:8px 0 6px;background:#0D121B;border:1px solid var(--rule);border-radius:6px;
padding:10px 12px;font-size:.71rem;line-height:1.6;overflow-x:auto;color:#A8BDD4}
/* copy button */
.copy{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--dim);
font-family:var(--sans);font-size:.71rem;padding:3px 10px;border-radius:5px;cursor:pointer;
white-space:nowrap;transition:color .15s,border-color .15s}
.copy:hover{color:var(--text);border-color:var(--faint)}
.copy:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.copy.done{color:var(--live);border-color:rgba(86,200,110,.5)}
/* subscription */
.subs{margin-top:26px;background:var(--panel);border:1px solid var(--rule);
border-radius:12px;padding:22px 24px 24px;scroll-margin-top:72px} /* clear the sticky tab bar */
.subs-hd h2{margin:0;font-size:1.05rem;letter-spacing:.14em;font-weight:600}
.subs-hd p{margin:8px 0 0;font-size:.81rem;color:var(--dim);line-height:1.75;max-width:74ch}
.subs-hd code{font-size:.78rem;color:#9DB2C8;background:#0D121B;border:1px solid var(--rule);
border-radius:5px;padding:1px 7px}
.chips{display:flex;gap:7px;flex-wrap:wrap;margin:16px 0 14px}
.chip{appearance:none;background:#0F141E;border:1px solid var(--rule);border-radius:20px;
color:var(--dim);font-family:var(--sans);font-size:.79rem;padding:5px 8px 5px 13px;
cursor:pointer;display:flex;align-items:center;gap:8px;transition:color .15s,border-color .15s}
.chip:hover{color:var(--text);border-color:var(--faint)}
.chip[aria-pressed="true"]{color:var(--ink);background:var(--warp);border-color:var(--warp);font-weight:600}
.chip:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.chip-n{font-family:var(--mono);font-size:.68rem;border:1px solid currentColor;border-radius:20px;
padding:0 6px;opacity:.75}
.subs-act{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
.subs-act .act{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--text);
font-family:var(--sans);font-size:.8rem;padding:7px 15px;border-radius:7px;cursor:pointer;
white-space:nowrap;transition:border-color .15s,color .15s}
.subs-act .act:hover{border-color:var(--faint)}
.subs-act .act:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
.subs-act .act.primary{background:rgba(63,199,187,.12);border-color:rgba(63,199,187,.45);color:var(--warp)}
.subs-act .act.done{color:var(--live);border-color:rgba(86,200,110,.5)}
.subs-count{font-family:var(--mono);font-size:.73rem;color:var(--faint)}
.sub-list{list-style:none;margin:16px 0 0;padding:0;display:grid;gap:1px;
grid-template-columns:repeat(2,minmax(0,1fr));background:var(--rule);
border:1px solid var(--rule);border-radius:9px;overflow:hidden}
.sub-list li{background:var(--panel2);padding:8px 13px;display:flex;align-items:baseline;
gap:10px;min-width:0}
.sub-list .dot{flex:0 0 6px;width:6px;height:6px;border-radius:50%;align-self:center}
.sub-list li.direct .dot{background:var(--direct)}
.sub-list li.warp .dot{background:var(--warp)}
.sub-list .nm{font-family:var(--mono);font-size:.79rem;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
.sub-list .tr{margin-left:auto;font-size:.7rem;color:var(--faint);white-space:nowrap}
.subs-how{margin-top:14px;border-top:1px solid var(--rule);padding-top:12px}
.subs-how summary{cursor:pointer;font-size:.8rem;color:var(--dim);width:fit-content}
.subs-how summary:hover{color:var(--text)}
.subs-how summary:focus-visible{outline:2px solid var(--warp);outline-offset:2px;border-radius:3px}
.how-body{font-size:.79rem;color:var(--dim);line-height:1.8;max-width:78ch}
.how-body p{margin:9px 0 0}
.how-body b{color:var(--text);font-weight:600}
.how-body code{font-family:var(--mono);font-size:.75rem;color:#9DB2C8}
.how-body pre{margin:10px 0 0;background:#0D121B;border:1px solid var(--rule);border-radius:6px;
padding:10px 12px;font-size:.72rem;line-height:1.65;overflow-x:auto;color:#A8BDD4}
.mh-jump{font-family:var(--mono);font-size:.74rem;border:1px solid var(--rule);border-radius:6px;
padding:3px 11px;color:var(--warp)}
.mh-jump:hover{border-color:var(--warp);text-decoration:none}
/* warn */
.warn{margin-top:26px;background:rgba(227,155,61,.07);border:1px solid rgba(227,155,61,.28);
border-radius:11px;padding:15px 19px;font-size:.81rem;color:#DEBB86;line-height:1.7}
.warn b{color:var(--direct)}
.foot{text-align:center;color:var(--faint);font-size:.74rem;margin-top:22px}
/* lightbox */
.lb{position:fixed;inset:0;z-index:60;display:none;place-items:center;padding:24px;
background:rgba(5,7,11,.88);backdrop-filter:blur(3px)}
.lb[open]{display:grid}
.lb-card{background:var(--panel);border:1px solid var(--rule);border-radius:13px;
padding:22px;max-width:min(440px,100%);text-align:center}
.lb-card img{width:280px;max-width:100%;height:auto;background:#fff;padding:12px;border-radius:9px;
image-rendering:pixelated;display:block;margin:0 auto}
.lb-title{font-size:.95rem;font-weight:600;margin:14px 0 4px}
.lb-link{font-family:var(--mono);font-size:.68rem;color:var(--dim);word-break:break-all;
max-height:80px;overflow:auto;margin-bottom:14px;text-align:left;
background:#0D121B;border:1px solid var(--rule);border-radius:6px;padding:8px 10px}
.lb-act{display:flex;gap:9px;justify-content:center}
.lb-close{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--text);
font-family:var(--sans);font-size:.8rem;padding:6px 16px;border-radius:6px;cursor:pointer}
.lb-close:hover{border-color:var(--faint)}
.lb-close:focus-visible,.lb .copy:focus-visible{outline:2px solid var(--warp);outline-offset:2px}
@media(max-width:720px){
.grid,.sub-list{grid-template-columns:1fr}
.panel,.subs{padding:20px 16px 24px}
.subs-act .act{flex:1 1 auto;text-align:center}
.block,.subs{scroll-margin-top:112px} /* the tab bar wraps to two rows here */
.line{flex-direction:column}
.line .qr{flex:0 0 auto}
.kv dt{flex-basis:76px}
}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
"""
JS = """
(function(){
var tabs = Array.prototype.slice.call(document.querySelectorAll('.tab'));
var panels = Array.prototype.slice.call(document.querySelectorAll('.panel'));
// writeHash is off during boot: overwriting the hash there would swallow a
// deep link like #subs before the browser has scrolled to it.
function select(i, focus, writeHash){
tabs.forEach(function(t,j){
var on = i===j;
t.setAttribute('aria-selected', on ? 'true':'false');
t.tabIndex = on ? 0 : -1;
panels[j].hidden = !on;
});
if(focus) tabs[i].focus();
if(writeHash){ try{ location.hash = tabs[i].id.replace('tab-',''); }catch(e){} }
}
tabs.forEach(function(t,i){
t.addEventListener('click', function(){ select(i,false,true); });
t.addEventListener('keydown', function(ev){
var k = ev.key, n = null;
if(k==='ArrowRight') n = (i+1)%tabs.length;
else if(k==='ArrowLeft') n = (i-1+tabs.length)%tabs.length;
else if(k==='Home') n = 0;
else if(k==='End') n = tabs.length-1;
if(n!==null){ ev.preventDefault(); select(n,true,true); }
});
});
var initial = 0, h = (location.hash||'').replace('#','');
tabs.forEach(function(t,i){ if(t.id === 'tab-'+h) initial = i; });
select(initial,false,false);
// copy
document.addEventListener('click', function(ev){
var b = ev.target.closest('.copy');
if(!b) return;
var val = b.getAttribute('data-copy'), old = b.textContent;
function done(){ b.textContent='已复制'; b.classList.add('done');
setTimeout(function(){ b.textContent=old; b.classList.remove('done'); },1400); }
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(val).then(done, fallback);
} else fallback();
function fallback(){
var ta=document.createElement('textarea'); ta.value=val;
ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta);
ta.select(); try{ document.execCommand('copy'); done(); }catch(e){}
document.body.removeChild(ta);
}
});
// subscription: everything is inlined, so switching scope is a pure re-render
var SUB = JSON.parse(document.getElementById('sub-data').textContent),
chips = Array.prototype.slice.call(document.querySelectorAll('.chip')),
list = document.getElementById('sub-list'),
count = document.getElementById('sub-count'),
bB64 = document.getElementById('sub-b64'),
bPlain = document.getElementById('sub-plain'),
bDl = document.getElementById('sub-dl'),
cur = 'all';
function renderSub(){
var g = SUB[cur];
list.innerHTML = '';
g.items.forEach(function(it){
var li = document.createElement('li');
li.className = it.egress;
li.innerHTML = '<span class="dot"></span><span class="nm"></span><span class="tr"></span>';
li.querySelector('.nm').textContent = it.name;
li.querySelector('.tr').textContent = it.transport;
list.appendChild(li);
});
count.textContent = g.items.length + ' 条线路';
bB64.setAttribute('data-copy', g.b64);
bPlain.setAttribute('data-copy', g.plain);
}
chips.forEach(function(c){
c.addEventListener('click', function(){
cur = c.getAttribute('data-group');
chips.forEach(function(o){ o.setAttribute('aria-pressed', o === c ? 'true':'false'); });
renderSub();
});
});
renderSub();
bDl.addEventListener('click', function(){
var blob = new Blob([SUB[cur].b64], {type:'text/plain;charset=utf-8'}),
url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = 'edge-nodes-' + cur + '.txt';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(url); }, 1000);
var old = bDl.textContent;
bDl.textContent = '已下载'; bDl.classList.add('done');
setTimeout(function(){ bDl.textContent = old; bDl.classList.remove('done'); }, 1400);
});
// QR lightbox
var lb = document.getElementById('lb'),
lbImg = document.getElementById('lb-img'),
lbTitle = document.getElementById('lb-title'),
lbLink = document.getElementById('lb-link'),
lbCopy = document.getElementById('lb-copy'),
lbClose = document.getElementById('lb-close'),
lastFocus = null;
function openLb(btn){
lastFocus = btn;
lbImg.src = 'data:image/png;base64,' + btn.getAttribute('data-qr');
lbTitle.textContent = btn.getAttribute('data-title');
var link = btn.getAttribute('data-link') || '';
lbLink.textContent = link;
lbCopy.setAttribute('data-copy', link);
lb.setAttribute('open','');
lbClose.focus();
}
function closeLb(){
lb.removeAttribute('open');
if(lastFocus) lastFocus.focus();
}
document.addEventListener('click', function(ev){
var q = ev.target.closest('.qr[data-qr]');
if(q){ openLb(q); return; }
if(ev.target === lb) closeLb();
});
lbClose.addEventListener('click', closeLb);
document.addEventListener('keydown', function(ev){
if(ev.key === 'Escape' && lb.hasAttribute('open')) closeLb();
});
})();
"""
DOC = """<!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>%(css)s</style>
</head>
<body>
<header class="masthead">
<div class="wrap mh-in">
<div class="brand">边缘节点<span>/</span>交付文档</div>
<div class="mh-meta"><b>%(nsite)d</b> 站点 <b>%(nline)d</b> 条线路 更新于 %(updated)s
<a class="mh-jump" href="#subs">订阅 ↓</a></div>
</div>
</header>
<div class="wrap">
<div class="tabs" role="tablist" aria-label="站点">
%(tabs)s
</div>
%(panels)s
%(subs)s
<div class="warn">
<b>凭据安全</b> 本页含全部站点的明文密码、UUID 与可直接导入的节点链接。子域前缀、面板入口路径、WS 路径与 QUIC 端口共同构成控制面的地图 —— 请勿提交到 git 或上传至任何在线服务;需要转交时直接发文件,不要粘贴内容。<br>
<b>出口区分</b> <span class="pill direct">直连</span> 走服务器本机 IP,速度最快但暴露真实 IP;<span class="pill warp">WARP</span> 经 Cloudflare 净出口,隐藏真实 IP。连上后访问 <code>https://1.1.1.1/cdn-cgi/trace</code> 看 <code>ip=</code> 与 <code>warp=</code> 即可验证。
</div>
<p class="foot">全部线路已在服务器侧实测通过(WS 握手 / QUIC 出口 / 直连与 WARP 分流)。二维码为本地离线生成,未经过任何第三方服务。</p>
</div>
<div class="lb" id="lb" role="dialog" aria-modal="true" aria-labelledby="lb-title">
<div class="lb-card">
<img id="lb-img" alt="节点二维码">
<div class="lb-title" id="lb-title"></div>
<div class="lb-link" id="lb-link"></div>
<div class="lb-act">
<button class="copy" id="lb-copy" type="button" data-copy="">复制链接</button>
<button class="lb-close" id="lb-close" type="button">关闭</button>
</div>
</div>
</div>
<script>%(js)s</script>
</body>
</html>
"""
output = Path(OUT)
output.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(DOC % {"css": CSS, "js": JS, "tabs": tabs, "panels": panels,
"subs": subs, "nsite": len(SITES), "nline": TOTAL,
"updated": UPDATED})
print("wrote %s (%.1f KB, %d sites, %d lines)" % (OUT, os.path.getsize(OUT)/1024, len(SITES), TOTAL))