#!/usr/bin/env python3
"""生成 VMess 链接与 sing-box 配置。

用法:
    ./gen-config.py <域名> <路径> [选项]

示例:
    ./gen-config.py stargazer.org.cn /ray
    ./gen-config.py example.com /abc --uuid 已有的UUID --name HK-WS-TLS
    ./gen-config.py example.com /abc --port 8443 --outdir /tmp/cfg
"""

import argparse
import base64
import json
import os
import secrets
import subprocess
import sys
import uuid as uuidlib
from datetime import date

GEO_BASE = "https://cdn.jsdelivr.net/gh/SagerNet"

# 默认 Web DocumentRoot 列表；outdir 命中时打 ⚠ 提示 vmess.md 含 UUID 不应公网可读。
DOCUMENT_ROOTS = (
    "/var/www/html",
    "/srv/www",
    "/usr/share/nginx/html",
    "/var/www",
    "/var/www/htdocs",
)


def warn_if_public_webdir(outdir):
    """outdir 落在常见 DocumentRoot 时打 ⚠；不阻断，仅提示。"""
    absout = os.path.abspath(outdir)
    for root in DOCUMENT_ROOTS:
        if absout == root or absout.startswith(root + os.sep):
            try:
                host = os.uname()[1]
            except Exception:
                host = "<host>"
            print(
                f"\n⚠️  outdir={absout} 命中 Web DocumentRoot。",
                f"\n    `vmess.md` 含明文 UUID，等价于在 https://{host}"
                f"/share/v2ray/vmess.md 公网可读；UUID 泄露即被白用。",
                "\n    立刻处理：",
                f"\n      mv {absout}/vmess.md /root/    # 或任意 Web 根外位置",
                "\n    长期方案：",
                "\n      --outdir /etc/v2ray            # 服务端片段落盘",
                "\n      --outdir /root/v2ray-cfg       # 客户端片段落盘",
                "",
                sep="",
                file=sys.stderr,
            )
            return True
    return False


def gen_uuid():
    """优先用内核 CSPRNG，与文档中 cat /proc/sys/kernel/random/uuid 一致。"""
    try:
        with open("/proc/sys/kernel/random/uuid") as f:
            return f.read().strip()
    except OSError:
        return str(uuidlib.uuid4())


def vmess_link(p):
    """vmess:// 为 base64(JSON)，字段名是 v2rayN 约定的缩写。"""
    cfg = {
        "v": "2",
        "ps": p["name"],
        "add": p["domain"],
        "port": str(p["port"]),
        "id": p["uuid"],
        "aid": "0",
        "scy": "auto",
        "net": "ws",
        "type": "none",
        "host": p["domain"],  # 留空会导致请求不带 Host 头，Apache vhost 匹配失败
        "path": p["path"],
        "tls": "tls",
        "sni": p["domain"],
        "alpn": p.get("alpn", ""),
        "fp": p.get("fp", ""),
    }
    s = json.dumps(cfg, separators=(",", ":"))
    return "vmess://" + base64.b64encode(s.encode()).decode()


def rule_sets():
    return [
        {
            "type": "remote",
            "tag": tag,
            "format": "binary",
            "url": f"{GEO_BASE}/sing-{repo}@rule-set/{tag}.srs",
            "http_client": {"detour": "proxy"},
        }
        for tag, repo in (("geosite-cn", "geosite"), ("geoip-cn", "geoip"))
    ]


def singbox_tls(p):
    """sing-box tls 字段；可选 utls 指纹与 ALPN。"""
    out = {"enabled": True, "server_name": p["domain"]}
    alpn = p.get("alpn", "")
    if alpn:
        out["alpn"] = [a.strip() for a in alpn.split(",") if a.strip()]
    fp = p.get("fp", "")
    if fp:
        out["utls"] = {"enabled": True, "fingerprint": fp}
    return out


def singbox(p, split):
    """split=True 国内直连分流版，False 全局代理版。语法对应 sing-box 1.14+。"""
    resolver = {"server": "dns-direct", "strategy": "ipv4_only"}
    selfdom = [p["domain"], f"www.{p['domain']}"]

    if split:
        dns = {
            "servers": [
                {"type": "https", "tag": "dns-remote",
                 "server": "1.1.1.1", "detour": "proxy"},
                {"type": "udp", "tag": "dns-direct", "server": "223.5.5.5"},
            ],
            # 自身域名必须直连解析，否则 DNS 查询要经代理才能连上代理
            "rules": [
                {"rule_set": "geosite-cn", "server": "dns-direct"},
                {"domain": selfdom, "server": "dns-direct"},
            ],
            "final": "dns-remote",
            "strategy": "ipv4_only",
        }
        rules = [
            {"action": "sniff"},
            {"protocol": "dns", "action": "hijack-dns"},
            {"ip_is_private": True, "outbound": "direct"},
            {"domain": selfdom, "outbound": "direct"},
            {"rule_set": ["geosite-cn", "geoip-cn"], "outbound": "direct"},
        ]
    else:
        dns = {
            "servers": [{"type": "udp", "tag": "dns-direct",
                         "server": "223.5.5.5"}],
            "final": "dns-direct",
            "strategy": "ipv4_only",
        }
        rules = [
            {"action": "sniff"},
            {"ip_is_private": True, "outbound": "direct"},
            {"domain": selfdom, "outbound": "direct"},
        ]

    route = {
        "rules": rules,
        "final": "proxy",
        "default_domain_resolver": resolver,
        "auto_detect_interface": True,
    }
    if split:
        route["rule_set"] = rule_sets()

    return {
        "log": {"level": "info", "timestamp": True},
        "dns": dns,
        "inbounds": [{
            "type": "tun",
            "tag": "tun-in",
            "address": ["172.19.0.1/30"],
            "mtu": 1400,
            "auto_route": True,
            "strict_route": False,
        }],
        "outbounds": [
            {
                "type": "vmess",
                "tag": "proxy",
                "server": p["domain"],
                "server_port": p["port"],
                "uuid": p["uuid"],
                "security": "auto",
                "alter_id": 0,
                "domain_resolver": resolver,
                "tls": singbox_tls(p),
                "transport": {"type": "ws", "path": p["path"]},
            },
            {"type": "direct", "tag": "direct"},
        ],
        "route": route,
    }


def server_inbound(p):
    """服务端 v2ray ws inbound 片段，追加到 /etc/v2ray/config.json 的 inbounds。"""
    return {
        "port": p["inbound_port"],
        # 容器内必须 0.0.0.0：docker-proxy 转发进来的源地址不是 127.0.0.1。
        # 对外安全性靠宿主侧 -p 127.0.0.1:PORT:PORT 保证。
        "listen": "0.0.0.0",
        "protocol": "vmess",
        "settings": {
            "clients": [{"id": p["uuid"], "alterId": 0, "security": "auto"}]
        },
        "streamSettings": {
            "network": "ws",
            "wsSettings": {"path": p["path"]},
        },
        "tag": "vmess-ws-in",
    }


def markdown(p, link, slug):
    d = p["domain"]
    ip = p["inbound_port"]
    return f"""# VMess 节点配置

更新时间：{date.today().isoformat()}

## 导入链接

v2rayN 中复制下面整行，按 Ctrl+V 从剪贴板导入：

```
{link}
```

## 参数明细

| 字段 | 值 |
|---|---|
| 别名 | {p['name']} |
| 地址 | {d} |
| 端口 | {p['port']} |
| UUID | {p['uuid']} |
| 额外ID (alterId) | 0 |
| 加密方式 | auto |
| 传输协议 | ws |
| 伪装类型 | none |
| 伪装域名 (host) | {d} |
| 路径 | {p['path']} |
| 传输层安全 | tls |
| SNI | {d} |
| 跳过证书验证 | 不勾选 |

## 注意事项

- 伪装域名 (host) 必须填 `{d}`。留空会导致请求不带 Host 头，Apache vhost 匹配失败，表现为连接成功但无法上网。
- 路径 `{p['path']}` 带斜杠，区分大小写。
- 不要勾选"跳过证书验证"，证书为正式 Let's Encrypt 证书。

## 服务端架构

```
客户端 --{p['port']}/TLS--> Apache ({d})
                      |- {p['path']}  --ws--> 127.0.0.1:{ip}  v2ray vmess+ws
                      |- 其他         /var/www/html 网站内容
```

## sing-box 配置

- 分流版（国内直连，推荐）：`{slug}-split.json`
- 全局版：`{slug}-global.json`

导入方式：sing-box → Profiles → `+` → Remote → 填 URL。

## 服务端部署片段

`server-inbound.json` 追加到 `/etc/v2ray/config.json` 的 `inbounds` 数组，然后：

```bash
docker restart v2ray
```

若 `{ip}` 为新端口，需重建容器加映射（Docker 不支持热加端口）：

```bash
docker stop v2ray && docker rm v2ray
docker run -d --name v2ray --restart unless-stopped \\
  -v /etc/v2ray/config.json:/etc/v2ray/config.json \\
  -p 127.0.0.1:{ip}:{ip} \\
  v2fly/v2fly-core:latest run -config /etc/v2ray/config.json
```

Apache 443 vhost 需有：

```apache
RewriteEngine on
RewriteCond %{{HTTP:Upgrade}} =websocket [NC]
RewriteRule ^{p['path']}$ ws://127.0.0.1:{ip}{p['path']} [P,L]
ProxyPreserveHost On
```
"""


def main():
    ap = argparse.ArgumentParser(
        description="生成 VMess 链接与 sing-box 配置",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="示例:\n  %(prog)s stargazer.org.cn /ray\n"
               "  %(prog)s example.com /abc --uuid 已有UUID --name HK-WS-TLS",
    )
    ap.add_argument("domain", help="域名，如 stargazer.org.cn")
    ap.add_argument("path", help="WebSocket 路径，如 /ray")
    ap.add_argument("--uuid", help="指定 UUID，默认新生成")
    ap.add_argument("--name", help="节点别名，默认 <域名首段>-WS-TLS")
    ap.add_argument("--port", type=int, default=443, help="对外端口，默认 443")
    ap.add_argument("--inbound-port", type=int, default=10087,
                    help="v2ray 本机 ws 监听端口，默认 10087")
    ap.add_argument("--outdir", default=os.path.dirname(os.path.abspath(__file__)),
                    help="输出目录，默认脚本所在目录")
    ap.add_argument("--slug", help="输出文件名前缀，默认 sb-<随机hex>")
    ap.add_argument("--fp", default="",
                    choices=["", "chrome", "chrome_r", "firefox", "firefox_r",
                             "safari", "safari_r", "edge", "edge_r",
                             "ios", "android", "random", "randomized"],
                    help="TLS 客户端指纹 (utls)；空=不启用。")
    ap.add_argument("--alpn", default="h2,http/1.1",
                    help="ALPN 逗号列表，默认 h2,http/1.1；传空串关闭")
    ap.add_argument("--no-write", action="store_true",
                    help="dry-run：仅打印预览，不实际写文件")
    ap.add_argument("--check", metavar="SING_BOX",
                    help="用指定的 sing-box 二进制校验生成的配置")
    args = ap.parse_args()

    path = args.path if args.path.startswith("/") else "/" + args.path
    p = {
        "domain": args.domain,
        "path": path,
        "port": args.port,
        "inbound_port": args.inbound_port,
        "uuid": args.uuid or gen_uuid(),
        "name": args.name or f"{args.domain.split('.')[0].upper()}-WS-TLS",
        "alpn": args.alpn,
        "fp": args.fp,
    }
    # 随机 slug：配置放在 Web 目录时避免被扫到 UUID
    slug = args.slug or f"sb-{secrets.token_hex(6)}"

    # dry-run：把预览输出重定向到 /tmp，不动用户的 outdir
    if args.no_write:
        out = "/tmp/gen-config-dryrun"
        os.makedirs(out, exist_ok=True)
    else:
        out = args.outdir
    warn_if_public_webdir(args.outdir)  # 总是检查用户传入的 outdir，dry-run 也要提醒
    if not args.no_write:
        os.makedirs(out, exist_ok=True)

    link = vmess_link(p)
    files = {
        f"{slug}-split.json": singbox(p, True),
        f"{slug}-global.json": singbox(p, False),
        "server-inbound.json": server_inbound(p),
    }

    if args.no_write:
        # 仅打预览，不落盘 → 用户确认后再去掉 --no-write 跑正式。
        print(
            f"\n[dry-run] 预览（UUID={p['uuid']}，路径实际会在 {args.outdir} 下生成，"
            f"此处仅打印到 /tmp/gen-config-dryrun/ 防止误覆盖）：",
            file=sys.stderr,
        )
        for name, data in files.items():
            preview = json.dumps(data, indent=2, ensure_ascii=False).splitlines()
            print(f"\n--- {os.path.join(args.outdir, name)} ({len(preview)} 行) ---",
                  file=sys.stderr)
            for line in preview[:6]:
                print(f"  {line}", file=sys.stderr)
            print("  ...", file=sys.stderr)
        print(f"\n--- {os.path.join(args.outdir, 'vmess.md')} ---", file=sys.stderr)
        print(f"  (将含 UUID {p['uuid']} + vmess:// 链接，见上 stdout)", file=sys.stderr)
        print("\n[dry-run] 未写任何文件。去掉 --no-write 重跑即落盘。\n",
              file=sys.stderr)
        return 0

    written = []
    for name, data in files.items():
        fp = os.path.join(out, name)
        with open(fp, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
            f.write("\n")
        written.append(fp)

    md = os.path.join(out, "vmess.md")
    with open(md, "w", encoding="utf-8") as f:
        f.write(markdown(p, link, slug))
    written.append(md)

    print(f"域名   : {p['domain']}:{p['port']}{p['path']}")
    print(f"UUID   : {p['uuid']}")
    print(f"别名   : {p['name']}")
    print(f"\n{link}\n")
    for fp in written:
        print(f"  {fp}")

    if args.check:
        print()
        ok = True
        for name in files:
            if not name.startswith(slug):
                continue
            fp = os.path.join(out, name)
            r = subprocess.run([args.check, "check", "-c", fp],
                               capture_output=True, text=True)
            tag = "OK" if r.returncode == 0 else "FAIL"
            print(f"  check {name}: {tag}")
            if r.returncode != 0:
                ok = False
                print((r.stderr or r.stdout).strip())
        if not ok:
            return 1
        # check 只做静态语法检查，弃用警告需实跑才能发现（见 v2ray.md 第五节）
        print("  注意: check 不覆盖弃用警告，建议按 v2ray.md 第五节实跑验证")
    return 0


if __name__ == "__main__":
    sys.exit(main())
