From 12d9f2b47c486e5d18c9bfdfb688026105877e43 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sun, 11 Oct 2026 16:45:16 +0800 Subject: [PATCH] ops: complete subscription template and node activation workflow --- scripts/node-onboarding/apply-template.php | 20 +++ scripts/node-onboarding/install-gateway.sh | 2 +- scripts/node-onboarding/promote.php | 43 ++++++ scripts/node-onboarding/readiness.php | 16 ++ .../node-onboarding/sing-box-template.json | 141 ++++++++++++++++++ scripts/node-onboarding/verify-node.py | 11 +- 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 scripts/node-onboarding/apply-template.php create mode 100644 scripts/node-onboarding/promote.php create mode 100644 scripts/node-onboarding/readiness.php create mode 100644 scripts/node-onboarding/sing-box-template.json diff --git a/scripts/node-onboarding/apply-template.php b/scripts/node-onboarding/apply-template.php new file mode 100644 index 0000000..e7478b0 --- /dev/null +++ b/scripts/node-onboarding/apply-template.php @@ -0,0 +1,20 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$opts = getopt('', ['backup:']); +$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup'); +umask(0077); +$old = App\Models\SubscribeTemplate::where('name', 'singbox')->firstOrFail(); +$default = json_decode(file_get_contents('resources/rules/default.sing-box.json'), true, flags: JSON_THROW_ON_ERROR); +$replacement = file_get_contents(__DIR__ . '/sing-box-template.json'); +$new = json_decode($replacement, true, flags: JSON_THROW_ON_ERROR); +$current = json_decode($old->content, true, flags: JSON_THROW_ON_ERROR); +if ($current === $new) { echo "Template already updated\n"; exit; } +if ($current !== $default) throw new RuntimeException('Custom template detected; inspect instead of overwriting'); +if (!is_dir($dir)) throw new RuntimeException('Backup directory missing'); +$path = "$dir/singbox-template-before.json"; +if (is_file($path)) throw new RuntimeException('Existing backup; inspect before retry'); +file_put_contents($path, json_encode($old->getAttributes(), JSON_THROW_ON_ERROR)); +App\Models\SubscribeTemplate::setContent('singbox', $replacement); +echo "DPL default subscription template updated; custom templates and other airports untouched\n"; diff --git a/scripts/node-onboarding/install-gateway.sh b/scripts/node-onboarding/install-gateway.sh index 04f3365..d55ace3 100644 --- a/scripts/node-onboarding/install-gateway.sh +++ b/scripts/node-onboarding/install-gateway.sh @@ -5,7 +5,7 @@ cd "$(dirname "$0")" test -s /etc/dpl-letsencrypt/live/c2.deeplink-subscribe.com/fullchain.pem docker image inspect sha256:d8807a5b2442bc25ce4fbc1312354b9dcbb0f538d75181ef735f4ea980760e70 >/dev/null docker image inspect sha256:31ff8ea3e8f958a835edbd1579709017da75b742ff3a5984ea59d12ea0775577 >/dev/null -install -d -m 700 /var/lib/dpl-sgw +install -d -m 755 /var/lib/dpl-sgw install -d -m 755 /var/lib/dpl-sgw/acme /var/lib/dpl-sgw/ssl ln -sfn /etc/dpl-letsencrypt/live/c2.deeplink-subscribe.com/fullchain.pem /var/lib/dpl-sgw/ssl/cert.pem ln -sfn /etc/dpl-letsencrypt/live/c2.deeplink-subscribe.com/privkey.pem /var/lib/dpl-sgw/ssl/key.pem diff --git a/scripts/node-onboarding/promote.php b/scripts/node-onboarding/promote.php new file mode 100644 index 0000000..453a801 --- /dev/null +++ b/scripts/node-onboarding/promote.php @@ -0,0 +1,43 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use App\Models\Plan; +use App\Models\Server; +use App\Models\ServerGroup; +use App\Models\User; +use Illuminate\Support\Facades\DB; +$opts = getopt('', ['backup:', 'plans:', 'group:']); +$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup'); +$ids = array_map('intval', explode(',', $opts['plans'] ?? '')); +if (!$ids || in_array(0, $ids, true)) throw new RuntimeException('Explicit plan IDs required'); +$m = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR); +$groupId = (int)($opts['group'] ?? $m['group_id']); +$node = Server::findOrFail($m['node_id']); +$qa = User::findOrFail($m['test_user_id']); +if (!$node->is_online || $qa->u + $qa->d <= 0) throw new RuntimeException('Online and traffic evidence missing'); +if (is_file("$dir/promoted.json")) { echo "Already promoted; verify current state separately\n"; exit; } +$plans = Plan::whereIn('id', $ids)->get(); +if ($plans->count() !== count($ids)) throw new RuntimeException('Plan list mismatch'); +foreach ($plans as $p) if ($p->group_id !== null && $p->group_id !== $groupId) throw new RuntimeException('Plan already assigned elsewhere'); +$users = User::whereIn('plan_id', $ids)->whereNull('group_id')->get(['id','group_id','updated_at']); +umask(0077); +file_put_contents("$dir/before-promotion.json", json_encode([ + 'plans' => $plans->map->getAttributes(), 'user_groups' => $users, + 'node_groups' => $node->group_ids, 'group' => ServerGroup::findOrFail($groupId)->getAttributes(), + 'subscribe_url' => admin_setting('subscribe_url'), 'qa_banned' => $qa->banned, +], JSON_THROW_ON_ERROR)); +DB::transaction(function () use ($ids, $users, $node, $qa, $groupId) { + $group = ServerGroup::findOrFail($groupId); + if (str_starts_with($group->name, 'dpl 接入验收 ')) { $group->name = '基础节点'; $group->save(); } + Plan::whereIn('id', $ids)->whereNull('group_id')->update(['group_id' => $groupId]); + User::whereIn('id', $users->pluck('id'))->whereNull('group_id')->update(['group_id' => $groupId]); + $node->group_ids = [(string)$groupId]; $node->save(); + $qa->banned = 1; $qa->saveQuietly(); + admin_setting(['subscribe_url' => 'https://c2.deeplink-subscribe.com']); +}); +$result = ['node_id' => $node->id, 'group_id' => $groupId, 'plan_ids' => $ids, + 'existing_users_assigned' => $users->count(), 'qa_disabled' => true]; +file_put_contents("$dir/promoted.json", json_encode($result, JSON_THROW_ON_ERROR)); +echo json_encode($result) . PHP_EOL; diff --git a/scripts/node-onboarding/readiness.php b/scripts/node-onboarding/readiness.php new file mode 100644 index 0000000..42650e4 --- /dev/null +++ b/scripts/node-onboarding/readiness.php @@ -0,0 +1,16 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$opts = getopt('', ['backup:']); +$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup'); +$m = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR); +$node = App\Models\Server::findOrFail($m['node_id']); +$qa = App\Models\User::findOrFail($m['test_user_id']); +echo json_encode([ + 'node_id' => $node->id, 'name' => $node->name, 'is_online' => $node->is_online, + 'last_check_at' => $node->last_check_at, 'last_push_at' => $node->last_push_at, + 'qa_upload' => $qa->u, 'qa_download' => $qa->d, 'qa_banned' => $qa->banned, + 'plans' => App\Models\Plan::orderBy('id')->get(['id', 'group_id']), + 'existing_users_with_selected_plan_and_null_group' => App\Models\User::whereIn('plan_id', [1,6,7,8])->whereNull('group_id')->count(), +], JSON_UNESCAPED_UNICODE) . PHP_EOL; diff --git a/scripts/node-onboarding/sing-box-template.json b/scripts/node-onboarding/sing-box-template.json new file mode 100644 index 0000000..66df858 --- /dev/null +++ b/scripts/node-onboarding/sing-box-template.json @@ -0,0 +1,141 @@ +{ + "dns": { + "rules": [ + { + "clash_mode": "global", + "server": "remote" + }, + { + "clash_mode": "direct", + "server": "local" + }, + { + "rule_set": [ + "geosite-cn" + ], + "server": "local" + } + ], + "servers": [ + { + "type": "https", + "server": "1.1.1.1", + "detour": "节点选择", + "tag": "remote" + }, + { + "type": "https", + "server": "223.5.5.5", + "detour": "direct", + "tag": "local" + } + ], + "strategy": "prefer_ipv4" + }, + "experimental": { + "cache_file": { + "enabled": true, + "path": "cache.db", + "cache_id": "cache_db", + "store_fakeip": true + } + }, + "inbounds": [ + { + "auto_route": true, + "address": [ + "172.19.0.1/30", + "2001:0470:f9da:fdfa::1/64" + ], + "mtu": 9000, + "stack": "system", + "strict_route": true, + "type": "tun" + }, + { + "listen": "127.0.0.1", + "listen_port": 2333, + "tag": "socks-in", + "type": "socks", + "users": [] + }, + { + "listen": "127.0.0.1", + "listen_port": 2334, + "tag": "mixed-in", + "type": "mixed", + "users": [] + } + ], + "outbounds": [ + { + "tag": "节点选择", + "type": "selector", + "default": "自动选择", + "outbounds": [ + "自动选择" + ] + }, + { + "tag": "direct", + "type": "direct" + }, + { + "tag": "自动选择", + "type": "urltest", + "outbounds": [] + } + ], + "route": { + "auto_detect_interface": true, + "rules": [ + { + "action": "resolve", + "strategy": "prefer_ipv4" + }, + { + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + }, + { + "clash_mode": "direct", + "outbound": "direct" + }, + { + "clash_mode": "global", + "outbound": "节点选择" + }, + { + "ip_is_private": true, + "outbound": "direct" + }, + { + "rule_set": [ + "geosite-cn", + "geoip-cn" + ], + "outbound": "direct" + } + ], + "rule_set": [ + { + "tag": "geosite-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", + "download_detour": "自动选择" + }, + { + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": "自动选择" + } + ], + "default_domain_resolver": "local" + } +} diff --git a/scripts/node-onboarding/verify-node.py b/scripts/node-onboarding/verify-node.py index 93a80fc..832314f 100644 --- a/scripts/node-onboarding/verify-node.py +++ b/scripts/node-onboarding/verify-node.py @@ -14,6 +14,7 @@ p.add_argument('--qa', required=True) p.add_argument('--binary', required=True) p.add_argument('--expected-exit', required=True) p.add_argument('--port', type=int, default=19080) +p.add_argument('--subscription') a = p.parse_args() q = json.loads(pathlib.Path(a.qa).read_text()) c = {'log': {'level': 'error'}, 'dns': {'servers': [{'type': 'local', 'tag': 'local'}]}, @@ -21,13 +22,21 @@ c = {'log': {'level': 'error'}, 'dns': {'servers': [{'type': 'local', 'tag': 'lo 'outbounds': [{'type': 'anytls', 'tag': 'qa', 'server': q['host'], 'server_port': q['port'], 'password': q['uuid'], 'tls': {'enabled': True, 'server_name': q['sni'], 'insecure': False}}], 'route': {'default_domain_resolver': 'local', 'final': 'qa'}} +if a.subscription: + c = json.loads(pathlib.Path(a.subscription).read_text()) + nodes = [x for x in c['outbounds'] if x.get('type') == 'anytls'] + assert len(nodes) == 1 and nodes[0]['tls'].get('insecure') is False + assert nodes[0]['server'] == q['host'] and nodes[0]['server_port'] == q['port'] + assert nodes[0]['password'] == q['uuid'] + c['inbounds'] = [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': a.port}] + c['log'] = {'level': 'error'} os.umask(0o077) with tempfile.TemporaryDirectory(prefix='dpl-client-') as d: config = pathlib.Path(d) / 'client.json' config.write_text(json.dumps(c)) env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75') with (pathlib.Path(d) / 'client.log').open('wb') as log: - proc = subprocess.Popen([a.binary, 'run', '-c', str(config)], env=env, stdout=log, stderr=log) + proc = subprocess.Popen([a.binary, 'run', '-c', str(config)], cwd=d, env=env, stdout=log, stderr=log) try: for _ in range(50): assert proc.poll() is None, 'Client exited during startup'