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); }