2026-10-26 11:58:15 +00:00
/**
* agent/sys.mjs — 시스템 명령 실행 계층(sudo·systemctl·nginx·certbot·tar) 과 권한 있는 파일 쓰기.
*
* Agent 는 일반 계정(master)으로 돌고 systemctl/nginx/certbot/tee 만 sudo -n(비밀번호 없음)으로 쓴다(deploy/sudoers/ariweb-agent).
* sudo 가 막혀 있으면 조용히 실패하지 않고 NEEDS_USER 로 운영자에게 이유(힌트)를 보인다.
* stub 모드(ARIWEB_AGENT_STUB_CMDS=1, 테스트): 외부 명령을 실행하지 않고 호출만 기록하며 성공으로 돌려준다. tar 는 실제로 실행한다(파일 결과를 검증하기 위해).
*/
import { spawn } from 'node:child_process' ;
import { writeFileSync , mkdirSync , existsSync , readFileSync } from 'node:fs' ;
import { dirname } from 'node:path' ;
export class NeedsUser extends Error { constructor ( message , hint ) { super ( message ); this . needsUser = true ; this . hint = hint ; } }
export function createSys ({ stub = process . env . ARIWEB_AGENT_STUB_CMDS === '1' , log = () => {} } = {}) {
const calls = [];
/** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */
2026-10-26 13:38:45 +00:00
function run ( cmd , args = [], { input = null , timeoutMs = 600000 , allowStub = true , env = null } = {}) {
2026-10-26 11:58:15 +00:00
calls . push ({ cmd , args });
if ( stub && allowStub && cmd !== 'tar' && cmd !== 'node' ) return Promise . resolve ({ code : 0 , stdout : '' , stderr : '' , stubbed : true });
2026-10-26 13:38:45 +00:00
return new Promise (( resolve ) => { const p = spawn ( cmd , args , { stdio : [ 'pipe' , 'pipe' , 'pipe' ], env : env || process . env }); let out = '' , err = '' ; const t = setTimeout (() => p . kill ( 'SIGKILL' ), timeoutMs ); p . stdout . on ( 'data' , ( c ) => { out += c ; }); p . stderr . on ( 'data' , ( c ) => { err += c ; }); p . on ( 'error' , ( e ) => { clearTimeout ( t ); resolve ({ code : 127 , stdout : out , stderr : e . message }); }); p . on ( 'close' , ( code ) => { clearTimeout ( t ); resolve ({ code , stdout : out , stderr : err }); }); if ( input != null ) p . stdin . end ( input ); else p . stdin . end (); });
2026-10-26 11:58:15 +00:00
}
const sudo = ( args , o ) => run ( 'sudo' , [ '-n' , ... args ], o );
async function hasSudo () { if ( stub ) return true ; const r = await run ( 'sudo' , [ '-n' , 'true' ]); return r . code === 0 ; }
async function requireSudo ( what ) { if ( ! ( await hasSudo ())) throw new NeedsUser ( ` ${ what } 에 sudo 권한이 필요하지만 이 서버에서 비밀번호 없는 sudo 가 허용되지 않았습니다` , 'deploy/sudoers/ariweb-agent 를 /etc/sudoers.d/ 에 설치하세요' ); }
/** 권한이 필요한 경로에 파일 쓰기: 직접 쓰기 → 안 되면 sudo tee */
async function writeFile ( path , content , mode = '644' ) {
try { mkdirSync ( dirname ( path ), { recursive : true }); writeFileSync ( path , content ); return { via : 'direct' }; } catch ( e ) { if ( e . code !== 'EACCES' && e . code !== 'EPERM' ) throw e ; }
await requireSudo ( ` ${ path } 쓰기` ); await sudo ([ 'mkdir' , '-p' , dirname ( path )]); const r = await sudo ([ 'tee' , path ], { input : content }); if ( r . code !== 0 ) throw new Error ( `쓰기 실패 ${ path } : ${ r . stderr } ` ); await sudo ([ 'chmod' , mode , path ]); return { via : 'sudo' };
}
async function systemctl ( action , unit , { optional = false } = {}) { await requireSudo ( `systemctl ${ action } ${ unit } ` ); const r = await sudo ([ 'systemctl' , action , unit ]); if ( r . code !== 0 && ! optional ) throw new Error ( `systemctl ${ action } ${ unit } 실패: ${ ( r . stderr || r . stdout ). trim (). slice ( 0 , 300 ) } ` ); return r ; }
async function isActive ( unit ) { if ( stub ) return true ; const r = await run ( 'systemctl' , [ 'is-active' , unit ]); return r . stdout . trim () === 'active' ; }
async function unitText ( unit ) { if ( stub ) return '' ; const r = await run ( 'systemctl' , [ 'cat' , unit ]); return r . code === 0 ? r . stdout : '' ; }
async function nginxReload () { await requireSudo ( 'nginx 설정 적용' ); const t = await sudo ([ 'nginx' , '-t' ]); if ( t . code !== 0 ) throw new Error ( `nginx -t 실패: ${ ( t . stderr || t . stdout ). trim (). slice ( 0 , 400 ) } ` ); const r = await sudo ([ 'systemctl' , 'reload' , 'nginx' ]); if ( r . code !== 0 ) throw new Error ( `nginx reload 실패: ${ r . stderr . slice ( 0 , 300 ) } ` ); return true ; }
async function certbot ( args ) { await requireSudo ( 'certbot' ); return sudo ([ 'certbot' , ... args ], { timeoutMs : 300000 }); }
async function tar ( args , o ) { const r = await run ( 'tar' , args , { ... o , allowStub : false }); if ( r . code !== 0 ) throw new Error ( `tar 실패: ${ r . stderr . slice ( 0 , 300 ) } ` ); return r ; }
return { stub , calls , run , sudo , hasSudo , requireSudo , writeFile , systemctl , isActive , unitText , nginxReload , certbot , tar , readIfExists : ( p ) => ( existsSync ( p ) ? readFileSync ( p , 'utf8' ) : null ) };
}