t
This commit is contained in:
parent
4c4e767ec6
commit
9d70afb4cc
42 changed files with 1266 additions and 630 deletions
89
scripts/harden-windows-host.ps1
Normal file
89
scripts/harden-windows-host.ps1
Normal 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/'
|
||||
7
scripts/public-smoke-test.js
Normal file
7
scripts/public-smoke-test.js
Normal 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});
|
||||
|
|
@ -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
70
scripts/service-runner.js
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue