ops: complete subscription template and node activation workflow

This commit is contained in:
hellcat 2026-10-11 16:45:16 +08:00
parent 1537ebf65c
commit 12d9f2b47c
6 changed files with 231 additions and 2 deletions

View File

@ -0,0 +1,20 @@
<?php
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->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";

View File

@ -5,7 +5,7 @@ cd "$(dirname "$0")"
test -s /etc/dpl-letsencrypt/live/c2.deeplink-subscribe.com/fullchain.pem test -s /etc/dpl-letsencrypt/live/c2.deeplink-subscribe.com/fullchain.pem
docker image inspect sha256:d8807a5b2442bc25ce4fbc1312354b9dcbb0f538d75181ef735f4ea980760e70 >/dev/null docker image inspect sha256:d8807a5b2442bc25ce4fbc1312354b9dcbb0f538d75181ef735f4ea980760e70 >/dev/null
docker image inspect sha256:31ff8ea3e8f958a835edbd1579709017da75b742ff3a5984ea59d12ea0775577 >/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 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/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 ln -sfn /etc/dpl-letsencrypt/live/c2.deeplink-subscribe.com/privkey.pem /var/lib/dpl-sgw/ssl/key.pem

View File

@ -0,0 +1,43 @@
<?php
// Run only after real relay, subscription, certificate and traffic checks pass.
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->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;

View File

@ -0,0 +1,16 @@
<?php
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->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;

View File

@ -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"
}
}

View File

@ -14,6 +14,7 @@ p.add_argument('--qa', required=True)
p.add_argument('--binary', required=True) p.add_argument('--binary', required=True)
p.add_argument('--expected-exit', required=True) p.add_argument('--expected-exit', required=True)
p.add_argument('--port', type=int, default=19080) p.add_argument('--port', type=int, default=19080)
p.add_argument('--subscription')
a = p.parse_args() a = p.parse_args()
q = json.loads(pathlib.Path(a.qa).read_text()) q = json.loads(pathlib.Path(a.qa).read_text())
c = {'log': {'level': 'error'}, 'dns': {'servers': [{'type': 'local', 'tag': 'local'}]}, 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'], 'outbounds': [{'type': 'anytls', 'tag': 'qa', 'server': q['host'], 'server_port': q['port'],
'password': q['uuid'], 'tls': {'enabled': True, 'server_name': q['sni'], 'insecure': False}}], 'password': q['uuid'], 'tls': {'enabled': True, 'server_name': q['sni'], 'insecure': False}}],
'route': {'default_domain_resolver': 'local', 'final': 'qa'}} '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) os.umask(0o077)
with tempfile.TemporaryDirectory(prefix='dpl-client-') as d: with tempfile.TemporaryDirectory(prefix='dpl-client-') as d:
config = pathlib.Path(d) / 'client.json' config = pathlib.Path(d) / 'client.json'
config.write_text(json.dumps(c)) config.write_text(json.dumps(c))
env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75') env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75')
with (pathlib.Path(d) / 'client.log').open('wb') as log: 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: try:
for _ in range(50): for _ in range(50):
assert proc.poll() is None, 'Client exited during startup' assert proc.poll() is None, 'Client exited during startup'