t
Some checks failed
BEND FIELD CI / release (push) Has been cancelled
BEND FIELD CI / production-bridge (push) Has been cancelled

This commit is contained in:
33333-33333 2026-08-01 22:31:04 +09:00
commit 9d70afb4cc
42 changed files with 1266 additions and 630 deletions

View file

@ -0,0 +1,89 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ApacheConfig,
[Parameter(Mandatory = $true)]
[string]$PhpIni,
[switch]$Apply,
[switch]$SkipApacheSyntaxCheck
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-ConfigFile([string]$Value, [string]$Label) {
$resolved = (Resolve-Path -LiteralPath $Value -ErrorAction Stop).Path
if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) {
throw "$Label is not a file: $resolved"
}
return $resolved
}
function Replace-ManagedBlock([string]$Text, [string]$Begin, [string]$End, [string]$Block) {
$normalized = $Text -replace "`r`n?", "`n"
$pattern = '(?ms)(?:^|\n)' + [regex]::Escape($Begin) + '.*?' + [regex]::Escape($End) + '(?:\n|$)'
$without = [regex]::Replace($normalized, $pattern, "`n").TrimEnd("`n")
if ($without.Length -eq 0) { return "$Block`n" }
return "$without`n`n$Block`n"
}
function Write-Utf8NoBom([string]$File, [string]$Text) {
[System.IO.File]::WriteAllText($File, $Text, [System.Text.UTF8Encoding]::new($false))
}
$apacheFile = Resolve-ConfigFile $ApacheConfig 'Apache configuration'
$phpFile = Resolve-ConfigFile $PhpIni 'PHP configuration'
$apacheBegin = '# BEGIN LINKFIELD HOST HARDENING'
$apacheEnd = '# END LINKFIELD HOST HARDENING'
$phpBegin = '; BEGIN LINKFIELD HOST HARDENING'
$phpEnd = '; END LINKFIELD HOST HARDENING'
$apacheBlock = "$apacheBegin`nServerTokens Prod`nServerSignature Off`n$apacheEnd"
$phpBlock = "$phpBegin`nexpose_php = Off`n$phpEnd"
$apacheOriginal = [System.IO.File]::ReadAllText($apacheFile)
$phpOriginal = [System.IO.File]::ReadAllText($phpFile)
$apacheUpdated = Replace-ManagedBlock $apacheOriginal $apacheBegin $apacheEnd $apacheBlock
$phpUpdated = Replace-ManagedBlock $phpOriginal $phpBegin $phpEnd $phpBlock
$apacheAlreadySafe = $apacheUpdated -eq ($apacheOriginal -replace "`r`n?", "`n")
$phpAlreadySafe = $phpUpdated -eq ($phpOriginal -replace "`r`n?", "`n")
Write-Output "Apache config: $apacheFile"
Write-Output "PHP config: $phpFile"
Write-Output "Apache ServerTokens/ServerSignature: $(if ($apacheAlreadySafe) {'already managed'} else {'change required'})"
Write-Output "PHP expose_php: $(if ($phpAlreadySafe) {'already managed'} else {'change required'})"
if (-not $Apply) {
Write-Output 'Dry run only. Re-run with -Apply to create backups and write the managed settings.'
exit 0
}
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$apacheBackup = "$apacheFile.linkfield-backup-$stamp"
$phpBackup = "$phpFile.linkfield-backup-$stamp"
Copy-Item -LiteralPath $apacheFile -Destination $apacheBackup -Force
Copy-Item -LiteralPath $phpFile -Destination $phpBackup -Force
try {
Write-Utf8NoBom $apacheFile $apacheUpdated
Write-Utf8NoBom $phpFile $phpUpdated
if (-not $SkipApacheSyntaxCheck) {
$apacheRoot = Split-Path -Parent (Split-Path -Parent $apacheFile)
$httpd = Join-Path $apacheRoot 'bin\httpd.exe'
if (-not (Test-Path -LiteralPath $httpd -PathType Leaf)) {
throw "Apache syntax checker was not found at $httpd. Re-run with -SkipApacheSyntaxCheck only after locating another way to run httpd -t."
}
& $httpd -t -f $apacheFile
if ($LASTEXITCODE -ne 0) { throw "Apache rejected the updated configuration (exit $LASTEXITCODE)." }
}
} catch {
Copy-Item -LiteralPath $apacheBackup -Destination $apacheFile -Force
Copy-Item -LiteralPath $phpBackup -Destination $phpFile -Force
throw "Host hardening failed; both original files were restored. $($_.Exception.Message)"
}
Write-Output "Applied LinkField host hardening. Backups:"
Write-Output " $apacheBackup"
Write-Output " $phpBackup"
Write-Output 'Restart Apache, then run: npm run smoke:public -- https://2012r2.nishi.boats/~333/link-field/'

View file

@ -0,0 +1,7 @@
'use strict';
const BuildMeta=require('../build-meta');
const {checkPublicDeployment}=require('../server/public-health');
const baseValue=String(process.argv[2]||process.env.LINK_FIELD_PUBLIC_URL||'').trim();
if(!baseValue)throw new Error('Set LINK_FIELD_PUBLIC_URL or pass the public LinkField base URL.');
(async()=>{const result=await checkPublicDeployment(baseValue,{appVersion:BuildMeta.APP_VERSION,timeoutMs:15_000});console.log(`Public LinkField v${BuildMeta.APP_VERSION} is healthy at ${result.base}`)})().catch(error=>{console.error(error);process.exitCode=1});

View file

@ -4,15 +4,27 @@ const fs = require('fs');
const fsp = fs.promises;
const os = require('os');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const {spawn, execFile} = require('child_process');
const {createWorldBackup,verifyWorldBackup,restoreWorldBackup}=require('../server/world-backup');
const {checkPublicDeployment}=require('../server/public-health');
const {renderApacheBootstrap,replaceManagedBlock}=require('../server/apache-bridge');
const ROOT = path.resolve(__dirname, '..');
const SERVER_FILE = path.join(ROOT, 'server.js');
const RUNNER_FILE = path.join(ROOT, 'scripts', 'service-runner.js');
const SERVICE_ROOT = path.resolve(process.env.LINK_FIELD_SERVICE_DIR || path.join(os.homedir(), '.local', 'share', 'LinkField', 'service'));
const PID_FILE = path.join(SERVICE_ROOT, 'server.pid');
const IDENTITY_FILE = path.join(SERVICE_ROOT, 'service.identity.json');
const LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log'));
const TEST_DATA_ROOT=String(process.env.LINK_FIELD_TEST_DATA_ROOT||'').trim();
const WORLD_DIR=TEST_DATA_ROOT?path.join(path.resolve(TEST_DATA_ROOT),'world'):path.resolve(String(process.env.LINK_FIELD_WORLD_DIR||'/link-field/world'));
const BACKUP_DIR=path.resolve(String(process.env.LINK_FIELD_BACKUP_DIR||path.join(path.dirname(WORLD_DIR),'backups')));
const STARTUP_TIMEOUT_MS = 12_000;
const POLL_INTERVAL_MS = 100;
const MAX_LOG_BYTES = 10 * 1024 * 1024;
const LOG_GENERATIONS = 5;
const PUBLIC_ENTRIES = Object.freeze([
'index.html',
'style.css',
@ -44,33 +56,84 @@ function execFileText(command, args) {
return new Promise((resolve, reject) => execFile(command, args, {encoding:'utf8'}, (error, stdout) => error ? reject(error) : resolve(stdout)));
}
async function legacyLinkFieldPids() {
if (process.platform === 'win32') return [];
function rootMatches(value){
if(typeof value!=='string'||!value.trim())return false;
const actual=path.resolve(value),expected=ROOT;
return process.platform==='win32'?actual.toLowerCase()===expected.toLowerCase():actual===expected;
}
async function readWorldInstanceLock(worldDir=WORLD_DIR){
try{
const raw=(await fsp.readFile(path.join(worldDir,'server.pid'),'utf8')).trim();
if(/^\d+$/.test(raw))return{pid:Number(raw),legacy:true};
const value=JSON.parse(raw);return value&&typeof value==='object'?value:null;
}catch(_){return null}
}
function isFreshRuntimeRecord(record,maxAgeMs){
return Boolean(record&&rootMatches(record.root)&&Number.isSafeInteger(Number(record.pid))&&Number(record.pid)>0&&isProcessRunning(Number(record.pid))&&Date.now()-Number(record.heartbeatAt||0)<maxAgeMs);
}
async function isLinkFieldRoot(value){
if(rootMatches(value))return true;
if(typeof value!=='string'||!value.trim())return false;
try{const pkg=JSON.parse(await fsp.readFile(path.join(path.resolve(value),'package.json'),'utf8'));return['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)}catch(_){return false}
}
async function isTrustedRuntimeRecord(record,maxAgeMs){
const pid=Number(record?.pid),age=Date.now()-Number(record?.heartbeatAt||0);
return Boolean(Number.isSafeInteger(pid)&&pid>0&&pid!==process.pid&&isProcessRunning(pid)&&age>=-5000&&age<maxAgeMs&&await isLinkFieldRoot(record?.root));
}
async function processExecutableIsNode(pid){
if(!Number.isSafeInteger(pid)||pid<=0||pid===process.pid||!isProcessRunning(pid))return false;
if(process.platform==='win32'){
try{const output=await execFileText('powershell.exe',['-NoProfile','-NonInteractive','-Command',`(Get-Process -Id ${pid} -ErrorAction Stop).Path`]);return['node.exe','nodejs.exe'].includes(path.basename(output.trim()).toLowerCase())}catch(_){return false}
}
try{return['node','nodejs'].includes(path.basename(await fsp.readlink(`/proc/${pid}/exe`)).toLowerCase())}catch(_){return false}
}
async function legacyLinkFieldPids({worldDir=WORLD_DIR}={}) {
const matches=[];
const [identity,lock]=await Promise.all([readIdentity(),readWorldInstanceLock(worldDir)]);
if(await isTrustedRuntimeRecord(identity,5_000))matches.push(Number(identity.pid));
if(await isTrustedRuntimeRecord(lock,15_000))matches.push(Number(lock.pid));
if(process.platform==='win32'){
return[...new Set(matches.filter(pid=>pid!==process.pid))];
}
let output;
try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); }
catch (_) { return []; }
const matches=[];
catch (_) { return [...new Set(matches.filter(pid=>pid!==process.pid))]; }
for (const line of output.split(/\r?\n/)) {
const match=line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/);if(!match)continue;
const pid=Number(match[1]),executable=path.basename(match[2]).toLowerCase(),command=match[3];
if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])server\.js(?:\s|$)/.test(command))continue;
if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])(?:server\.js|service-runner\.js)(?:\s|$)/.test(command))continue;
let cwd='';try{cwd=await fsp.readlink(`/proc/${pid}/cwd`)}catch(_){}
const candidates=[cwd];const absolute=command.match(/(?:^|\s)(\/[^\s]*\/server\.js)(?:\s|$)/);if(absolute)candidates.push(path.dirname(absolute[1]));
let linkField=false;
for(const directory of candidates.filter(Boolean)){
try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(pkg?.name==='link-field-v47-shared-world'){linkField=true;break}}catch(_){}
try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)){linkField=true;break}}catch(_){}
}
if(linkField)matches.push(pid);
}
return [...new Set(matches)];
return [...new Set(matches.filter(pid=>pid!==process.pid))];
}
async function stopLegacyLinkFieldServers() {
const pids=await legacyLinkFieldPids();if(!pids.length)return [];
for(const pid of pids)try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
const started=Date.now();while(Date.now()-started<3000&&pids.some(isProcessRunning))await sleep(100);
for(const pid of pids)if(isProcessRunning(pid))try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
return pids;
const stopped=new Set(),deadline=Date.now()+5_000;let quietSince=0;
while(Date.now()<deadline){
const active=await legacyLinkFieldPids(),pids=active.filter(pid=>!stopped.has(pid));
for(const pid of pids){
stopped.add(pid);
if(!await terminateProcess(pid,2_000))throw new Error(`Older LinkField process PID ${pid} did not stop.`);
}
if(active.length||pids.length)quietSince=0;else if(!quietSince)quietSince=Date.now();
if(quietSince&&Date.now()-quietSince>=1_500)break;
await sleep(pids.length?300:150);
}
const remaining=await legacyLinkFieldPids();
if(remaining.length)throw new Error(`Older LinkField process PID ${remaining.join(', ')} kept restarting and could not be stopped.`);
return[...stopped];
}
function isProcessRunning(pid) {
@ -83,25 +146,85 @@ function isProcessRunning(pid) {
}
}
async function readPid() {
async function readServiceRecord() {
try {
const pid = Number((await fsp.readFile(PID_FILE, 'utf8')).trim());
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
const raw=(await fsp.readFile(PID_FILE, 'utf8')).trim();
if(/^\d+$/.test(raw)){const pid=Number(raw);return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:null,legacy:true}:null}
const value=JSON.parse(raw),pid=Number(value?.pid);
return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:typeof value.nonce==='string'?value.nonce:null,root:value.root}:null;
} catch (error) {
if (error?.code === 'ENOENT') return null;
throw error;
return null;
}
}
async function readPid(){return(await readServiceRecord())?.pid||null}
async function readIdentity(){try{return JSON.parse(await fsp.readFile(IDENTITY_FILE,'utf8'))}catch(error){if(error?.code==='ENOENT')return null;return null}}
async function isManagedService(pid){
if(!isProcessRunning(pid))return false;const [identity,record]=await Promise.all([readIdentity(),readServiceRecord()]);
return Boolean(identity&&record&&record.pid===pid&&identity.pid===pid&&path.resolve(identity.root||'')===ROOT&&path.resolve(record.root||ROOT)===ROOT&&(!record.nonce||identity.nonce===record.nonce)&&Date.now()-Number(identity.heartbeatAt||0)<5000);
}
async function terminateProcess(pid,timeoutMs=5000){
if(!isProcessRunning(pid))return true;
try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH')throw error}
const started=Date.now();while(isProcessRunning(pid)&&Date.now()-started<timeoutMs)await sleep(100);
if(isProcessRunning(pid)){try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH')throw error}}
const killedAt=Date.now();while(isProcessRunning(pid)&&Date.now()-killedAt<2000)await sleep(50);
return !isProcessRunning(pid);
}
async function removeStalePid() {
const pid = await readPid();
if (pid && isProcessRunning(pid)) return pid;
if (pid && await isManagedService(pid)) return pid;
const identity=await readIdentity();
if(isFreshRuntimeRecord(identity,5_000)&&typeof identity.nonce==='string'&&identity.nonce){
await fsp.writeFile(PID_FILE,`${JSON.stringify({pid:Number(identity.pid),nonce:identity.nonce,root:ROOT})}\n`,{encoding:'utf8',mode:0o600});
return Number(identity.pid);
}
await fsp.unlink(PID_FILE).catch(error => {
if (error?.code !== 'ENOENT') throw error;
});
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
return null;
}
async function recoverWorldLock(){
await fsp.mkdir(SERVICE_ROOT,{recursive:true,mode:0o700});
const stopped=new Set(),deadline=Date.now()+15_000;let quietSince=0;
while(Date.now()<deadline){
const candidates=new Set(await legacyLinkFieldPids()),identity=await readIdentity(),lock=await readWorldInstanceLock();
if(identity&&Date.now()-Number(identity.heartbeatAt||0)<5_000&&await processExecutableIsNode(Number(identity.pid)))candidates.add(Number(identity.pid));
const lockPid=Number(lock?.pid);
if(Number.isSafeInteger(lockPid)&&lockPid>0&&isProcessRunning(lockPid)){
if(!await processExecutableIsNode(lockPid))throw new Error(`Refusing recovery: world-lock PID ${lockPid} is not a verified Node process.`);
candidates.add(lockPid);
}else if(lock){await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error})}
const active=[...candidates].filter(pid=>pid!==process.pid&&isProcessRunning(pid));
if(active.length){quietSince=0;for(const pid of active){if(!await terminateProcess(pid,3_000))throw new Error(`LinkField recovery could not stop PID ${pid}.`);stopped.add(pid)}}
else if(!quietSince)quietSince=Date.now();
if(quietSince&&Date.now()-quietSince>=2_500)break;
await sleep(active.length?300:150);
}
const remaining=await readWorldInstanceLock(),remainingPid=Number(remaining?.pid);
if(Number.isSafeInteger(remainingPid)&&remainingPid>0&&isProcessRunning(remainingPid))throw new Error(`World-lock PID ${remainingPid} is still active after recovery.`);
await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(PID_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
console.log(stopped.size?`Recovered the LinkField world lock and stopped ${stopped.size} old process${stopped.size===1?'':'es'}.`:'Removed a stale LinkField world lock.');
console.log('Run npm start now.');
return[...stopped];
}
async function rotateLogs(){
let stat;try{stat=await fsp.stat(LOG_FILE)}catch(error){if(error?.code==='ENOENT')return;if(error)throw error}
if(!stat||stat.size<MAX_LOG_BYTES)return;
await fsp.unlink(`${LOG_FILE}.${LOG_GENERATIONS}`).catch(error=>{if(error?.code!=='ENOENT')throw error});
for(let index=LOG_GENERATIONS-1;index>=1;index--){try{await fsp.rename(`${LOG_FILE}.${index}`,`${LOG_FILE}.${index+1}`)}catch(error){if(error?.code!=='ENOENT')throw error}}
await fsp.rename(LOG_FILE,`${LOG_FILE}.1`);
}
function isWithin(parent, child) {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
@ -126,7 +249,8 @@ function resolvePublicDir() {
}
async function copyEntry(source, destination) {
const stat = await fsp.stat(source);
const stat = await fsp.lstat(source);
if(stat.isSymbolicLink())return;
if (stat.isDirectory()) {
await fsp.mkdir(destination, {recursive:true, mode:0o755});
const entries = await fsp.readdir(source, {withFileTypes:true});
@ -141,25 +265,44 @@ async function copyEntry(source, destination) {
await fsp.chmod(destination, 0o644).catch(() => {});
}
function assertSafePublicDir(publicDir){
const resolved=path.resolve(publicDir),root=path.parse(resolved).root,home=path.resolve(os.homedir());
if(resolved===root||resolved===home||path.dirname(resolved)===resolved)throw new Error(`Unsafe LinkField public directory: ${resolved}`);
return resolved;
}
async function writeApacheBootstrap(publicDir){
const apacheFile=path.join(publicDir,'.htaccess');let existing='';
try{existing=await fsp.readFile(apacheFile,'utf8')}catch(error){if(error?.code!=='ENOENT')throw error}
await fsp.writeFile(apacheFile,replaceManagedBlock(existing,renderApacheBootstrap()),{encoding:'utf8',mode:0o644});
}
async function deployPublicFiles(publicDir = resolvePublicDir()) {
await fsp.mkdir(publicDir, {recursive:true, mode:0o755});
if (path.resolve(publicDir) !== ROOT) {
for (const entry of PUBLIC_ENTRIES) {
const source = path.join(ROOT, entry);
try {
await copyEntry(source, path.join(publicDir, entry));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
}
publicDir=assertSafePublicDir(publicDir);
const manifest = {
app: 'LinkField',
version: require('../build-meta').APP_VERSION,
source: ROOT,
deployedAt: new Date().toISOString(),
entries:[...PUBLIC_ENTRIES],
};
await fsp.writeFile(path.join(publicDir, '.linkfield-deployment.json'), `${JSON.stringify(manifest, null, 2)}\n`, {encoding:'utf8', mode:0o644});
if(path.resolve(publicDir)===ROOT){await writeApacheBootstrap(publicDir);await fsp.writeFile(path.join(publicDir,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});return publicDir}
const parent=path.dirname(publicDir),name=path.basename(publicDir),stamp=`${process.pid}-${Date.now()}`,stage=path.join(parent,`.${name}.linkfield-stage-${stamp}`),backup=path.join(parent,`.${name}.linkfield-old-${stamp}`);
await fsp.mkdir(parent,{recursive:true,mode:0o755});
const artifacts=(await fsp.readdir(parent,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&(entry.name.startsWith(`.${name}.linkfield-stage-`)||entry.name.startsWith(`.${name}.linkfield-old-`))).map(entry=>path.join(parent,entry.name)).sort();
if(!fs.existsSync(publicDir)){const recoverable=[...artifacts].reverse().find(item=>path.basename(item).startsWith(`.${name}.linkfield-old-`));if(recoverable){await fsp.rename(recoverable,publicDir);artifacts.splice(artifacts.indexOf(recoverable),1)}}
for(const artifact of artifacts)await fsp.rm(artifact,{recursive:true,force:true});
try{
if(fs.existsSync(publicDir))await copyEntry(publicDir,stage);else await fsp.mkdir(stage,{recursive:true,mode:0o755});
let previous=null;try{previous=JSON.parse(await fsp.readFile(path.join(stage,'.linkfield-deployment.json'),'utf8'))}catch(_){}
for(const retired of Array.isArray(previous?.entries)?previous.entries.filter(entry=>!PUBLIC_ENTRIES.includes(entry)):[]){const target=path.resolve(stage,retired);if(target.startsWith(`${stage}${path.sep}`))await fsp.rm(target,{recursive:true,force:true})}
for(const entry of PUBLIC_ENTRIES){const source=path.join(ROOT,entry),destination=path.join(stage,entry);await fsp.rm(destination,{recursive:true,force:true});try{await copyEntry(source,destination)}catch(error){if(error?.code!=='ENOENT')throw error}}
await writeApacheBootstrap(stage);
await fsp.writeFile(path.join(stage,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});
if(fs.existsSync(publicDir))await fsp.rename(publicDir,backup);
try{await fsp.rename(stage,publicDir)}catch(error){if(fs.existsSync(backup)&&!fs.existsSync(publicDir))await fsp.rename(backup,publicDir);throw error}
await fsp.rm(backup,{recursive:true,force:true});
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
return publicDir;
}
@ -172,6 +315,15 @@ async function tailLog(lines = 20) {
}
}
function localStatus(port){
return new Promise((resolve,reject)=>{const request=http.get({hostname:'127.0.0.1',port,path:'/api/cloud/status',timeout:1500,headers:{connection:'close'}},response=>{let body='';response.setEncoding('utf8');response.on('data',chunk=>{if(body.length<65536)body+=chunk});response.on('end',()=>{try{const value=JSON.parse(body);if(response.statusCode===200&&value?.available===true&&value?.appVersion===require('../build-meta').APP_VERSION)resolve(value);else reject(new Error(`Local health returned HTTP ${response.statusCode}`))}catch(error){reject(error)}})});request.on('timeout',()=>request.destroy(new Error('Local health timed out')));request.on('error',reject)});
}
async function publicStatus(publicUrl){
const result=await checkPublicDeployment(publicUrl,{appVersion:require('../build-meta').APP_VERSION,timeoutMs:10_000});return result?.api||null;
}
function configuredPublicUrl(){return String(process.env.LINK_FIELD_PUBLIC_URL||'').trim()}
async function waitForStartup(pid, publicDir) {
const portFile = path.join(publicDir, '.linkfield-port');
const started = Date.now();
@ -182,7 +334,7 @@ async function waitForStartup(pid, publicDir) {
}
try {
const port = Number((await fsp.readFile(portFile, 'utf8')).trim());
if (Number.isSafeInteger(port) && port > 0 && port <= 65535) return port;
if (Number.isSafeInteger(port) && port > 0 && port <= 65535&&await isManagedService(pid)){try{await localStatus(port);return port}catch(_){}}
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
@ -195,41 +347,52 @@ async function waitForStartup(pid, publicDir) {
async function start() {
await fsp.mkdir(SERVICE_ROOT, {recursive:true, mode:0o700});
const existingPid = await removeStalePid();
let publicDir=null;
if (existingPid) {
publicDir = await deployPublicFiles();
console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`);
await stop({quiet:true});
}
const stoppedLegacy=await stopLegacyLinkFieldServers();
const publicDir = await deployPublicFiles();
if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`);
const unresolvedLock=await readWorldInstanceLock(),unresolvedPid=Number(unresolvedLock?.pid);
if(Number.isSafeInteger(unresolvedPid)&&unresolvedPid>0&&isProcessRunning(unresolvedPid))throw new Error(`A legacy LinkField world lock is still held by PID ${unresolvedPid}. Run npm run recover once, then run npm start again.`);
if(!publicDir)publicDir = await deployPublicFiles();
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.mkdir(path.dirname(LOG_FILE), {recursive:true, mode:0o755});
await rotateLogs();
const logFd = fs.openSync(LOG_FILE, 'a');
let child;
const nonce=crypto.randomBytes(16).toString('hex');
try {
child = spawn(process.execPath, [SERVER_FILE], {
child = spawn(process.execPath, [RUNNER_FILE], {
cwd: ROOT,
detached: true,
stdio: ['ignore', logFd, logFd],
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT},
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT,LINK_FIELD_SERVICE_IDENTITY_FILE:IDENTITY_FILE,LINK_FIELD_SERVICE_NONCE:nonce,LINK_FIELD_LOG_FILE:LOG_FILE},
});
} finally {
fs.closeSync(logFd);
}
if (!child.pid) throw new Error('Could not start the LinkField background process.');
await fsp.writeFile(PID_FILE, `${child.pid}\n`, {encoding:'utf8', mode:0o600});
child.unref();
try {
await fsp.writeFile(PID_FILE, `${JSON.stringify({pid:child.pid,nonce,root:ROOT})}\n`, {encoding:'utf8', mode:0o600});
child.unref();
const port = await waitForStartup(child.pid, publicDir);
const publicEndpoint=await publicStatus(configuredPublicUrl());
console.log('LinkField started in the background. The command prompt is available again.');
console.log(`PID: ${child.pid}`);
console.log(`Local port: ${port}`);
console.log(`Public directory: ${publicDir}`);
console.log(`Log: ${LOG_FILE}`);
console.log("Check: curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'");
if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);
console.log(`Check: ${process.execPath} scripts/public-smoke-test.js https://YOUR-HOST/~333/link-field/`);
} catch (error) {
const stopped=!child?.pid||await terminateProcess(child.pid).catch(()=>false);
if(!stopped)throw new Error(`${error.message}\nLinkField supervisor PID ${child.pid} could not be stopped; its PID record was preserved for a safe manual stop.`);
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(()=>{});
throw error;
}
}
@ -238,21 +401,16 @@ async function stop({quiet = false} = {}) {
const pid = await readPid();
if (!pid || !isProcessRunning(pid)) {
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
if (!quiet) console.log('LinkField is not running.');
return false;
}
try {
process.kill(pid, 'SIGTERM');
} catch (error) {
if (error?.code !== 'ESRCH') throw error;
}
const started = Date.now();
while (isProcessRunning(pid) && Date.now() - started < 5000) await sleep(100);
if (isProcessRunning(pid)) {
try { process.kill(pid, 'SIGKILL'); }
catch (error) { if (error?.code !== 'ESRCH') throw error; }
}
if(!await isManagedService(pid))throw new Error(`Refusing to stop PID ${pid}: it is not a verified LinkField supervisor.`);
if(!await terminateProcess(pid))throw new Error(`LinkField supervisor PID ${pid} did not stop.`);
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
if (!quiet) console.log(`LinkField stopped (PID ${pid}).`);
return true;
}
@ -260,7 +418,7 @@ async function stop({quiet = false} = {}) {
async function status() {
const pid = await readPid();
const publicDir = resolvePublicDir();
if (!pid || !isProcessRunning(pid)) {
if (!pid || !await isManagedService(pid)) {
console.log('LinkField is stopped.');
process.exitCode = 1;
return;
@ -268,9 +426,10 @@ async function status() {
let port = '';
try { port = (await fsp.readFile(path.join(publicDir, '.linkfield-port'), 'utf8')).trim(); }
catch {}
console.log(`LinkField is running (PID ${pid}${port ? `, port ${port}` : ''}).`);
let healthy=false,publicEndpoint=null;if(port)try{await localStatus(Number(port));publicEndpoint=await publicStatus(configuredPublicUrl());healthy=true}catch(_){}
console.log(`LinkField is ${healthy?'healthy':'unhealthy'} (supervisor PID ${pid}${port ? `, port ${port}` : ''}).`);
console.log(`Public directory: ${publicDir}`);
console.log(`Log: ${LOG_FILE}`);
console.log(`Log: ${LOG_FILE}`);if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);if(!healthy)process.exitCode=1;
}
async function foreground() {
@ -281,6 +440,11 @@ async function foreground() {
console.log(`LinkField is running in the foreground. Public directory: ${publicDir}`);
}
async function assertServiceStopped(){const pid=await readPid();if(pid&&await isManagedService(pid))throw new Error('Stop LinkField before running an offline backup or restore.');try{const raw=(await fsp.readFile(path.join(WORLD_DIR,'server.pid'),'utf8')).trim(),lock=/^\d+$/.test(raw)?{pid:Number(raw)}:JSON.parse(raw);if(isProcessRunning(Number(lock?.pid)))throw new Error('A LinkField world process is still active. Stop it before backup or restore.')}catch(error){if(error?.code!=='ENOENT'&&!/Unexpected token|Unexpected end/.test(error?.message||''))throw error}}
async function backup(){await assertServiceStopped();const result=await createWorldBackup(WORLD_DIR,BACKUP_DIR,{retain:7,appVersion:require('../build-meta').APP_VERSION});console.log(`LinkField backup created: ${result.destination}`);return result}
async function listBackups(){await fsp.mkdir(BACKUP_DIR,{recursive:true,mode:0o700});const names=(await fsp.readdir(BACKUP_DIR,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();for(const name of names){try{const result=await verifyWorldBackup(BACKUP_DIR,name);console.log(`${name} revision=${result.manifest.worldRevision} files=${result.manifest.fileCount}`)}catch(error){console.log(`${name} INVALID ${error.message}`)}}return names}
async function restore(name){await assertServiceStopped();if(!name)throw new Error('Specify a backup name from npm run backups.');const result=await restoreWorldBackup(WORLD_DIR,BACKUP_DIR,name);console.log(`LinkField restored backup ${result.restored}. Previous data remains at ${result.previous}`);return result}
async function main() {
const command = String(process.argv[2] || 'start').toLowerCase();
if (command === 'start') return start();
@ -288,6 +452,10 @@ async function main() {
if (command === 'restart') { await stop({quiet:true}); return start(); }
if (command === 'status') return status();
if (command === 'foreground') return foreground();
if (command === 'backup') return backup();
if (command === 'backups') return listBackups();
if (command === 'restore') return restore(process.argv[3]);
if (command === 'recover') return recoverWorldLock();
if (command === 'deploy') {
const publicDir = await deployPublicFiles();
console.log(`LinkField public files deployed to ${publicDir}`);
@ -296,7 +464,7 @@ async function main() {
throw new Error(`Unknown service command: ${command}`);
}
module.exports = Object.freeze({ROOT, SERVICE_ROOT, PID_FILE, LOG_FILE, PUBLIC_ENTRIES, isProcessRunning, resolvePublicDir, deployPublicFiles, start, stop, status});
module.exports = Object.freeze({ROOT, SERVICE_ROOT, PID_FILE, IDENTITY_FILE, LOG_FILE, WORLD_DIR, BACKUP_DIR, PUBLIC_ENTRIES, isProcessRunning,legacyLinkFieldPids,recoverWorldLock,resolvePublicDir,deployPublicFiles,start,stop,status,backup,listBackups,restore});
if (require.main === module) main().catch(error => {
console.error(`LinkField service command failed: ${error.message}`);
process.exitCode = 1;

70
scripts/service-runner.js Normal file
View file

@ -0,0 +1,70 @@
'use strict';
const fs=require('fs');
const path=require('path');
const {spawn}=require('child_process');
const ROOT=path.resolve(__dirname,'..');
const SERVER_FILE=path.join(ROOT,'server.js');
const IDENTITY_FILE=path.resolve(process.env.LINK_FIELD_SERVICE_IDENTITY_FILE||path.join(process.env.LINK_FIELD_SERVICE_DIR||ROOT,'service.identity.json'));
const NONCE=String(process.env.LINK_FIELD_SERVICE_NONCE||'');
const LOG_FILE=String(process.env.LINK_FIELD_LOG_FILE||'');
const PORT_FILE=path.join(String(process.env.LINK_FIELD_PUBLIC_DIR||ROOT),'.linkfield-port');
const RESTART_WINDOW_MS=5*60*1000;
const MAX_RESTARTS=10;
const MAX_LOG_BYTES=Math.max(4096,Number(process.env.LINK_FIELD_LOG_MAX_BYTES)||10*1024*1024);
const LOG_ROTATE_INTERVAL_MS=Math.max(100,Number(process.env.LINK_FIELD_LOG_ROTATE_INTERVAL_MS)||30_000);
const LOG_GENERATIONS=5;
let child=null,stopping=false,restarts=[];
function writeIdentity(){
const value={pid:process.pid,root:ROOT,nonce:NONCE,heartbeatAt:Date.now(),childPid:child?.pid||null};
const temporary=`${IDENTITY_FILE}.${process.pid}.tmp`;
fs.mkdirSync(path.dirname(IDENTITY_FILE),{recursive:true,mode:0o700});
fs.writeFileSync(temporary,`${JSON.stringify(value)}\n`,{encoding:'utf8',mode:0o600});
fs.renameSync(temporary,IDENTITY_FILE);
}
function removeIdentity(){
try{const value=JSON.parse(fs.readFileSync(IDENTITY_FILE,'utf8'));if(value?.pid===process.pid&&value?.nonce===NONCE)fs.unlinkSync(IDENTITY_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Service identity cleanup warning: ${error.message}`)}
try{fs.unlinkSync(PORT_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Bridge-port cleanup warning: ${error.message}`)}
}
function rotateLiveLog(){
if(!LOG_FILE)return;
let stat;try{stat=fs.statSync(LOG_FILE)}catch(error){if(error?.code==='ENOENT')return;throw error}
if(stat.size<MAX_LOG_BYTES)return;
try{fs.unlinkSync(`${LOG_FILE}.${LOG_GENERATIONS}`)}catch(error){if(error?.code!=='ENOENT')throw error}
for(let index=LOG_GENERATIONS-1;index>=1;index--)try{fs.renameSync(`${LOG_FILE}.${index}`,`${LOG_FILE}.${index+1}`)}catch(error){if(error?.code!=='ENOENT')throw error}
fs.copyFileSync(LOG_FILE,`${LOG_FILE}.1`);fs.truncateSync(LOG_FILE,0);
}
function startChild(){
if(stopping)return;
const now=Date.now();restarts=restarts.filter(value=>now-value<RESTART_WINDOW_MS);
if(restarts.length>=MAX_RESTARTS){console.error(`LinkField stopped after ${MAX_RESTARTS} crashes in five minutes.`);removeIdentity();process.exit(1);return}
restarts.push(now);
child=spawn(process.execPath,[SERVER_FILE],{cwd:ROOT,stdio:'inherit',env:{...process.env,LINK_FIELD_SUPERVISED:'1'}});
writeIdentity();
child.once('exit',(code,signal)=>{
const lifetime=Date.now()-now;child=null;writeIdentity();
if(stopping){removeIdentity();process.exit(0);return}
console.error(`LinkField server exited (${signal||code}); restarting.`);
if(lifetime>60_000)restarts=[];
setTimeout(startChild,Math.min(10_000,500*Math.max(1,restarts.length)));
});
}
function shutdown(signal){
if(stopping)return;stopping=true;console.log(`LinkField supervisor received ${signal}; stopping.`);
if(child&&child.exitCode==null){child.kill('SIGTERM');const timer=setTimeout(()=>{if(child&&child.exitCode==null)child.kill('SIGKILL')},5000);timer.unref?.()}
else{removeIdentity();process.exit(0)}
}
process.once('SIGTERM',()=>shutdown('SIGTERM'));
process.once('SIGINT',()=>shutdown('SIGINT'));
process.once('exit',removeIdentity);
const heartbeat=setInterval(()=>{try{writeIdentity()}catch(error){console.error(`Service heartbeat failed: ${error.message}`)}},1000);heartbeat.unref?.();
const logRotation=setInterval(()=>{try{rotateLiveLog()}catch(error){console.error(`Live log rotation failed: ${error.message}`)}},LOG_ROTATE_INTERVAL_MS);logRotation.unref?.();
startChild();