From dfdfbf2562cee7f658591228765ea30d487785eb Mon Sep 17 00:00:00 2001 From: hellcat Date: Sun, 11 Oct 2026 15:55:45 +0800 Subject: [PATCH] ops: add isolated AnyTLS node onboarding workflow --- docs/maintenance/NODE_ONBOARDING.md | 45 +++++++++++++ docs/maintenance/README.md | 1 + scripts/node-onboarding/dpl-node.service | 25 ++++++++ scripts/node-onboarding/install-node.sh | 21 +++++++ scripts/node-onboarding/stage.php | 80 ++++++++++++++++++++++++ 5 files changed, 172 insertions(+) create mode 100644 docs/maintenance/NODE_ONBOARDING.md create mode 100644 scripts/node-onboarding/dpl-node.service create mode 100644 scripts/node-onboarding/install-node.sh create mode 100644 scripts/node-onboarding/stage.php diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md new file mode 100644 index 0000000..877634a --- /dev/null +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -0,0 +1,45 @@ +# 节点与订阅网关接入 + +核对日期:2026-10-11。当前仅完成只读调查和方案草案;未部署节点、修改中转规则、DNS 或现有网关。凭据仅从 CoDocs 受保护连接资料获取,不写入本文。 + +## 已核实基线 + +- gateway-01 运行 SubSieve。宿主机 Nginx 接公网 80/443;subscribe-gateway 映射到本机 8080/8443;subscribe-admin 使用 64444。两个容器共享 sgw_gw_config、sgw_gw_logs。 +- 现有设置接口使用单个 upstream_url、upstream_host、subscribe_path,并生成保护配置;没有在检查的接口与界面中发现多机场列表。不得把原上游改成 dpl。 +- 网关主机约 1GB 内存,现场可用约 448MB、磁盘余量约 13GB。同机第二套实例具备部署条件,但需限制资源并检查实际负载;共享主机仍有共同故障风险。 +- node-us01 的 XrayR 服务 active,占用 TCP 443。新节点使用独立服务、配置和空闲端口;禁止升级、重启或替换原 XrayR。 +- 中转后台现有 23 条规则;原 us01 是 haiwi.lshan.eu.org:46101 → node-us01:443,开启。只能新增 dpl 专用规则,原规则保留。 +- Namecheap 中 deeplink-subscribe.com 使用 BasicDNS;现场 Cloudflare 账户的完整 10 域名列表没有该域名。 +- 本地面板代码包含 AnyTLS 和 VLESS/REALITY 配置与多种订阅生成。线上版本及节点后端能力须独立核验,不能据此宣布已兼容。 + +## 首节点拟定方案 + +订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare HTTPS 代理)→ gateway-01 的新增域名站点 → dpl 专用 SubSieve 实例 → dpl 订阅接口。 + +流量:客户端 → 新增中转入口/端口 → node-us01 的 dpl 独立节点服务 → 目标网络。订阅网关不承载这条流量链路。 + +用户已确认首个节点采用 AnyTLS,显示名为“🇺🇸 美国 US - 01”。选择前核对面板 API、动态用户下发、计费与流量上报、节点后端版本以及实际客户端支持。AnyTLS 使用 us01.deeplink-subscribe.com 的有效证书与严格校验,客户端连接中转入口,SNI 保持该证书域名。普通 Cloudflare 橙云只用于 HTTPS 订阅,不直接代理这两种原生节点协议。 + +同机第二网关使用独立 Compose 项目、容器名称、卷、管理凭据和本机端口;绝不复用原 sgw 卷。不运行原项目 setup/update/down。宿主 Nginx 新增专用 server_name;检查通过后仅平滑重载,前后回归旧订阅。新实例内部端口待复核后分配,管理入口不直接开放公网。 + +域名在 Cloudflare 添加 zone,迁移必要现有记录,获取实际分配的 NS 后再修改 Namecheap。c2 指向网关并开启代理;源站使用有效证书与 Full (strict)。订阅路径绕过缓存、禁止交互式挑战;不能共享缓存带用户 token 的响应。开启代理可能改善入口路径,但不能保证所有地区可达,也不能据此认定历史故障原因。 + +## 每次新增节点流程 + +1. 明确机场、服务器、协议、套餐组、节点 ID、监听端口、中转入口和倍率;显示名称使用“🇺🇸 美国 US - 01”格式;服务、机器和中转规则使用 dpl 前缀。 +2. 经白色机只读检查服务、TCP/UDP 端口、防火墙、资源与原机场节点基线。记录检查时间,不能复用过期的空闲端口判断。 +3. 备份受影响配置和原状态到服务器受保护目录;确定只新增资源的清单及回滚操作。 +4. 核对后端官方版本与面板 API 兼容性,生成独立服务配置;私钥/API 密钥只存受保护文件。脚本与非敏感模板按 Git 提交、推送、明确版本发布。 +5. 新增面板节点但暂不对普通用户展示;部署独立节点服务,只操作该服务。检查语法、启动、端口和日志,不能只看 HTTP 200。 +6. 在中转后台新增专用规则到新监听端口;核对规则名、入口和目标后保存,不编辑原规则。 +7. 使用隔离测试用户检查订阅格式、导入、通过中转联网、出口 IP、DNS、流量上报、权限与超额限制;前后复核原机场服务。未做真实客户端测试时明确标记待验收。 +8. 验收通过才展示新节点,更新本文节点登记及 STATUS;分别记录配置、测试、Git 发布和真实验收结果。 +9. 回滚只停用本次新增节点/规则、停止新服务并撤回新域名配置;保留审计记录。不停止旧容器,不恢复整库覆盖后续业务。 + +## 节点登记 + +| 机场 | 节点 | 协议 | 落地端口 | 中转入口 | 面板 ID | 状态 | +| --- | --- | --- | --- | --- | --- | --- | +| dpl | 🇺🇸 美国 US - 01 / node-us01 | AnyTLS | 24443(待部署) | 拟新增47101,保留原46101 | 待创建 | 方案已授权,实施中 | + +网关双实例、节点后端兼容性与真实联调均未验收。后续按实际结果补充可复用模板,不把方案草案当作可直接执行的部署脚本。 diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index 36b73ee..199e350 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -20,6 +20,7 @@ | DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 | | TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 | | STATUS.md | 最近验证基线、已知限制与待验收项目 | +| NODE_ONBOARDING.md | 共享资源隔离、订阅网关与新增节点流程及待验收状态 | 这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。 diff --git a/scripts/node-onboarding/dpl-node.service b/scripts/node-onboarding/dpl-node.service new file mode 100644 index 0000000..17f1a38 --- /dev/null +++ b/scripts/node-onboarding/dpl-node.service @@ -0,0 +1,25 @@ +[Unit] +Description=dpl isolated AnyTLS node backend +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/var/lib/dpl-node +EnvironmentFile=/etc/dpl-node/secret.env +ExecStart=/opt/dpl-node/xboard-node -c /etc/dpl-node/config.yml +Restart=on-failure +RestartSec=10 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/dpl-node +MemoryHigh=450M +MemoryMax=600M +CPUQuota=100% +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/node-onboarding/install-node.sh b/scripts/node-onboarding/install-node.sh new file mode 100644 index 0000000..0219848 --- /dev/null +++ b/scripts/node-onboarding/install-node.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +# Credential files are installed separately with mode 0600. +[[ $(id -u) == 0 ]] || exit 1 +[[ -s /etc/dpl-node/config.yml && -s /etc/dpl-node/secret.env ]] || { echo 'Private node configuration missing'; exit 1; } +if ss -lntH '( sport = :24443 )' | grep -q .; then + echo 'Port 24443 already in use; inspect before deployment'; exit 1 +fi +systemctl is-active --quiet XrayR +install -d -m 755 /opt/dpl-node +install -d -m 700 /var/lib/dpl-node /var/lib/dpl-node/kernel +curl --fail --location --silent --show-error \ + https://github.com/cedar2025/Xboard-Node/releases/download/v1.13/xboard-node-linux-amd64 \ + -o /opt/dpl-node/xboard-node.new +echo '55bf71fa9d9f2048d3255ae7c0af929a41897ca7743f6ead34132a6ca4c79043 /opt/dpl-node/xboard-node.new' | sha256sum --check +chmod 755 /opt/dpl-node/xboard-node.new +mv /opt/dpl-node/xboard-node.new /opt/dpl-node/xboard-node +/opt/dpl-node/xboard-node -v +install -m 644 "$(dirname "$0")/dpl-node.service" /etc/systemd/system/dpl-node.service +systemctl daemon-reload +echo 'Binary and independent unit installed. Obtain certificate before starting.' diff --git a/scripts/node-onboarding/stage.php b/scripts/node-onboarding/stage.php new file mode 100644 index 0000000..5a9d663 --- /dev/null +++ b/scripts/node-onboarding/stage.php @@ -0,0 +1,80 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +use App\Models\Server; +use App\Models\ServerGroup; +use App\Models\ServerMachine; +use App\Models\User; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; + +$opts = getopt('', ['key:', 'name:', 'relay-host:', 'relay-port:', 'listen-port:', 'tls-domain:', 'backup:']); +foreach (['key', 'name', 'relay-host', 'relay-port', 'listen-port', 'tls-domain', 'backup'] as $key) { + if (empty($opts[$key])) throw new RuntimeException("Missing --$key"); +} +if (!preg_match('/^[a-z0-9-]+$/', $opts['key'])) throw new RuntimeException('Invalid key'); +foreach (['relay-port', 'listen-port'] as $key) { + if (!ctype_digit((string)$opts[$key]) || $opts[$key] < 1024 || $opts[$key] > 65535) throw new RuntimeException('Invalid port'); +} +$dir = rtrim($opts['backup'], '/'); +umask(0077); +if (!is_dir($dir) && !mkdir($dir, 0700, true)) throw new RuntimeException('Backup creation failed'); +if (is_file("$dir/manifest.json")) { + $manifest = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR); + if ($manifest['key'] !== $opts['key']) throw new RuntimeException('Backup belongs to another node'); + echo json_encode(['already_staged' => true, 'node_id' => $manifest['node_id']]) . PHP_EOL; + exit; +} +if (ServerMachine::where('name', 'dpl-' . $opts['key'])->exists() || Server::where('name', $opts['name'])->exists()) { + throw new RuntimeException('Existing resources found without this backup manifest'); +} +file_put_contents("$dir/before.json", json_encode([ + 'servers' => DB::table('v2_server')->get(), + 'groups' => DB::table('v2_server_group')->get(), + 'machines' => DB::table('v2_server_machine')->get(), +], JSON_THROW_ON_ERROR)); + +$manifest = DB::transaction(function () use ($opts) { + $group = ServerGroup::create(['name' => 'dpl 接入验收 ' . $opts['key']]); + $machine = ServerMachine::create([ + 'name' => 'dpl-' . $opts['key'], 'notes' => 'Independent AnyTLS service; existing XrayR preserved', + 'is_active' => true, 'token' => ServerMachine::generateToken(), + ]); + $node = Server::create([ + 'name' => $opts['name'], 'type' => 'anytls', 'host' => $opts['relay-host'], + 'port' => (string)$opts['relay-port'], 'server_port' => (string)$opts['listen-port'], + 'group_ids' => [(string)$group->id], 'route_ids' => [], 'tags' => [], + 'rate' => 1, 'show' => true, 'enabled' => true, 'machine_id' => $machine->id, 'sort' => 1, + 'protocol_settings' => ['tls' => ['server_name' => $opts['tls-domain'], 'allow_insecure' => false]], + 'cert_config' => [ + 'cert_mode' => 'file', 'domain' => $opts['tls-domain'], + 'cert_file' => '/etc/letsencrypt/live/' . $opts['tls-domain'] . '/fullchain.pem', + 'key_file' => '/etc/letsencrypt/live/' . $opts['tls-domain'] . '/privkey.pem', + ], + ]); + $user = new User([ + 'email' => 'node-qa-' . $opts['key'] . '-' . Str::lower(Str::random(8)) . '@example.invalid', + 'password' => password_hash(Str::random(40), PASSWORD_BCRYPT), + 'uuid' => (string)Str::uuid(), 'token' => Str::random(32), + 'group_id' => $group->id, 'transfer_enable' => 104857600, + 'expired_at' => time() + 86400, 'banned' => 0, 'is_admin' => 0, + 'remind_expire' => 0, 'remind_traffic' => 0, 'device_limit' => 2, + ]); + $user->saveQuietly(); + return ['key' => $opts['key'], 'node_id' => $node->id, 'machine_id' => $machine->id, + 'group_id' => $group->id, 'test_user_id' => $user->id]; +}); +file_put_contents("$dir/manifest.json", json_encode($manifest, JSON_THROW_ON_ERROR)); +$machine = ServerMachine::findOrFail($manifest['machine_id']); +$user = User::findOrFail($manifest['test_user_id']); +$config = "panel:\n url: \"https://dp-link.net\"\nmachine:\n machine_id: {$machine->id}\n token_env: DPL_MACHINE_TOKEN\nkernel:\n type: singbox\n config_dir: /var/lib/dpl-node/kernel\nlog:\n level: info\nruntime:\n gomemlimit: 384MiB\n gogc: 75\nnode:\n push_interval: 30\n pull_interval: 30\n"; +file_put_contents("$dir/node-config.yml", $config); +file_put_contents("$dir/node-secret.env", 'DPL_MACHINE_TOKEN=' . $machine->token . "\n"); +file_put_contents("$dir/qa-private.json", json_encode([ + 'user_id' => $user->id, 'uuid' => $user->uuid, 'subscribe_token' => $user->token, + 'host' => $opts['relay-host'], 'port' => (int)$opts['relay-port'], 'sni' => $opts['tls-domain'], +], JSON_THROW_ON_ERROR)); +echo json_encode($manifest, JSON_UNESCAPED_UNICODE) . PHP_EOL;