dpl/stage.php

110 lines
8.0 KiB
PHP

<?php
// Run only in an authorized target environment. --help never boots the app.
require __DIR__ . '/operations.php';
use App\Models\{Server,ServerGroup,ServerMachine,User};
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
$opts=getopt('', ['key:', 'name:', 'relay-host:', 'relay-port:', 'listen-port:', 'tls-domain:', 'panel-url:', 'backup:', 'help']);
if (isset($opts['help'])) {
echo "--key=KEY --name=NAME --relay-host=HOST --relay-port=PORT --listen-port=PORT --tls-domain=DOMAIN --panel-url=HTTPS_URL --backup=DIR\n";
exit;
}
try {
foreach (['key','name','relay-host','relay-port','listen-port','tls-domain','panel-url','backup'] as $key) {
if (!isset($opts[$key]) || !is_string($opts[$key]) || $opts[$key]==='') throw new RuntimeException('Missing required stage option');
}
if (!preg_match('/^[a-z0-9-]+$/D',$opts['key'])) throw new RuntimeException('Invalid deployment key');
foreach (['relay-port','listen-port'] as $key) {
if (!ctype_digit($opts[$key]) || (int)$opts[$key]<1024 || (int)$opts[$key]>65535) throw new RuntimeException('Invalid port');
$opts[$key]=(int)$opts[$key];
}
foreach (['relay-host','tls-domain'] as $key) {
if (!preg_match('/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/Di',$opts[$key])) throw new RuntimeException('Explicit hostname required');
}
$url=parse_url($opts['panel-url']);
if (!is_array($url) || ($url['scheme']??'')!=='https' || empty($url['host']) || isset($url['user']) || isset($url['pass']) || isset($url['query']) || isset($url['fragment'])) {
throw new RuntimeException('Explicit HTTPS panel URL without credentials/query required');
}
$dir=rtrim($opts['backup'],'/');
if (!str_starts_with($dir,'/') || is_link($dir)) throw new RuntimeException('Absolute private backup directory required');
umask(0077);
if (!is_dir($dir) && !mkdir($dir,0700,true)) throw new RuntimeException('Cannot create backup directory');
if ((fileperms($dir)&0077)!==0) throw new RuntimeException('Backup directory must be private');
$parameters=$opts;unset($parameters['backup']);ksort($parameters);
DplOnboarding\boot();
$m=DplOnboarding\locked($dir,function () use ($dir,$parameters) {
$path=$dir.'/manifest.json';
if (is_file($path)) {
$m=DplOnboarding\manifest($dir);
if (($m['parameters']??null)!==$parameters) throw new RuntimeException('Existing manifest has different/legacy parameters; no automatic adoption');
$node=Server::findOrFail($m['node_id']);$qa=User::findOrFail($m['test_user_id']);
$machine=ServerMachine::findOrFail($m['machine_id']);
if ((int)$node->machine_id!==$machine->id || $machine->name!=='dpl-'.$parameters['key'] ||
$node->host!==$parameters['relay-host'] || (int)$node->port!==$parameters['relay-port'] ||
(int)$node->server_port!==$parameters['listen-port'] || $node->type!=='anytls' ||
($node->protocol_settings['tls']['server_name']??null)!==$parameters['tls-domain'] ||
(int)$qa->group_id!==$m['group_id']) throw new RuntimeException('Staged resources drifted');
if (is_file($dir.'/promotion-journal.json') || is_file($dir.'/promoted.json')) throw new RuntimeException('Node already promoted; staging cannot regenerate credentials');
return $m;
}
return DB::transaction(function () use ($parameters,$path,$dir) {
if (ServerMachine::where('name','dpl-'.$parameters['key'])->exists() || Server::where('name',$parameters['name'])->exists() ||
Server::where('host',$parameters['relay-host'])->where('port',(string)$parameters['relay-port'])->exists() ||
ServerGroup::where('name','dpl 接入验收 '.$parameters['key'])->exists()) {
throw new RuntimeException('Existing resource/relay tuple without matching manifest; refusing takeover');
}
DplOnboarding\writeJson($dir.'/before.json',[
'server_ids'=>Server::orderBy('id')->pluck('id')->all(),
'machine_ids'=>ServerMachine::orderBy('id')->pluck('id')->all(),
'group_ids'=>ServerGroup::orderBy('id')->pluck('id')->all(),
]);
$group=new ServerGroup();$group->name='dpl 接入验收 '.$parameters['key'];$group->save();
$machine=ServerMachine::create(['name'=>'dpl-'.$parameters['key'],
'notes'=>'Independent AnyTLS service; existing XrayR preserved','is_active'=>true,'token'=>ServerMachine::generateToken()]);
$node=Server::create([
'name'=>$parameters['name'],'type'=>'anytls','host'=>$parameters['relay-host'],
'port'=>(string)$parameters['relay-port'],'server_port'=>(string)$parameters['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'=>$parameters['tls-domain'],'allow_insecure'=>false]],
'cert_config'=>['cert_mode'=>'file','domain'=>$parameters['tls-domain'],
'cert_file'=>'/etc/letsencrypt/live/'.$parameters['tls-domain'].'/fullchain.pem',
'key_file'=>'/etc/letsencrypt/live/'.$parameters['tls-domain'].'/privkey.pem'],
]);
$qa=new User([
'email'=>'node-qa-'.$parameters['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,
]);$qa->saveQuietly();
$m=['key'=>$parameters['key'],'node_id'=>$node->id,'machine_id'=>$machine->id,'group_id'=>$group->id,
'test_user_id'=>$qa->id,'parameters'=>$parameters];
// Save identities before commit. A failed/unknown commit must be inspected,
// never repeated into new resources merely because a response was lost.
DplOnboarding\writeJson($path,$m);
return $m;
},1);
});
// Recovery can regenerate missing transport files from the same staged objects.
$machine=ServerMachine::findOrFail($m['machine_id']);$qa=User::findOrFail($m['test_user_id']);
$panel=json_encode($parameters['panel-url'],JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES);
$config="panel:\n url: $panel\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";
foreach (['node-config.yml'=>$config,'node-secret.env'=>'DPL_MACHINE_TOKEN='.$machine->token."\n"] as $file=>$body) {
$path=$dir.'/'.$file;
if (is_link($path)) throw new RuntimeException('Refusing private transport symlink');
$tmp=tempnam($dir,'.private-');if ($tmp===false) throw new RuntimeException('Private file failed');
chmod($tmp,0600);
if (file_put_contents($tmp,$body)!==strlen($body) || !rename($tmp,$path)) {
if (is_file($tmp)) unlink($tmp);
throw new RuntimeException('Private file failed');
}
}
DplOnboarding\writeJson($dir.'/qa-private.json',['user_id'=>$qa->id,'uuid'=>$qa->uuid,'subscribe_token'=>$qa->token,
'host'=>$parameters['relay-host'],'port'=>$parameters['relay-port'],'sni'=>$parameters['tls-domain']]);
echo json_encode(['key'=>$m['key'],'node_id'=>$m['node_id'],'machine_id'=>$m['machine_id'],
'group_id'=>$m['group_id'],'test_user_id'=>$m['test_user_id']],JSON_THROW_ON_ERROR).PHP_EOL;
} catch (Throwable $e) {
$reason=get_class($e)===RuntimeException::class ? $e->getMessage() : 'Inspect manifest/DB state before retrying';
fwrite(STDERR,'Staging stopped: '.$reason.PHP_EOL);exit(1);
}