#!/usr/bin/env node 'use strict'; // ============================================================================ // 서비스 유닛 보안 옵션 점검·보정 도구 (v1.5.31, 요구 4) // // 배경: webmaker.ariweb.net 처럼 다른 절차로 설치된 서비스 유닛에 NoNewPrivileges=yes 와 ProtectSystem=strict(ReadWritePaths 에 /etc/nginx 없음)가 있으면 // 서비스 안에서 sudo 가 막히고 /etc/nginx 가 읽기 전용이라 사이트 도메인 연결(nginx 설정)·SSL 발급·기본 오류 서버 설정이 모두 실패한다(1.5.19~1.5.27 안내). // 운영자가 유닛 파일을 손으로 고쳐야 했으므로, 이 도구가 점검하고(기본) 원하면 보정한다(--fix). // // node scripts/unit-check.js [--unit webmaker] 점검만: 문제 옵션과 보정 방법 출력. 종료 코드 0 = 문제 없음, 1 = 문제 있음 // node scripts/unit-check.js --fix [--unit webmaker] 보정: 유닛 파일을 고치지 않고 드롭인(override) 파일을 만든다 — // /etc/systemd/system/<유닛>.service.d/50-webmaker-deploy.conf // [Service] // NoNewPrivileges=no // ReadWritePaths=/etc/nginx /etc/letsencrypt /var/lib/letsencrypt /var/log/letsencrypt /var/tmp // (드롭인의 ReadWritePaths 는 원래 값에 더해지고, NoNewPrivileges 는 뒤의 값이 이긴다.) 그 뒤 daemon-reload 와 재시작은 // 사람이 한다(실행 중인 AI 작업을 끊지 않도록). root 권한이 필요하므로 sudo 로 실행한다. // --dir <경로> /etc/systemd/system 대신 다른 디렉터리 (시험용) // // 유닛 이름은 --unit, 없으면 설정 파일 service.systemdUnit, 그것도 없으면 webmaker. 보정 뒤에는 설치 점검표(배포 서버 도메인 연결)와 배포 서버 연결 테스트로 확인한다. // ============================================================================ const fs = require('fs'); const path = require('path'); const argv = process.argv.slice(2); const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }; const fix = argv.includes('--fix'); let unit = opt('--unit'); if (!unit) { try { unit = require('../lib/config').get('service.systemdUnit'); } catch (_) {} } unit = (unit || 'webmaker').replace(/\.service$/, ''); const dir = opt('--dir') || '/etc/systemd/system'; const unitFile = path.join(dir, `${unit}.service`); const dropDir = path.join(dir, `${unit}.service.d`); const dropFile = path.join(dropDir, '50-webmaker-deploy.conf'); const NEED_RW = ['/etc/nginx', '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt', '/var/tmp']; /** 유닛 파일 + 드롭인들을 합쳐 유효 값을 계산한다 (NoNewPrivileges 는 마지막 값, ReadWritePaths 는 모두 더함, ProtectSystem 은 마지막 값) */ function effective() { const files = [unitFile]; try { for (const f of fs.readdirSync(dropDir).filter((x) => x.endsWith('.conf')).sort()) files.push(path.join(dropDir, f)); } catch (_) {} const v = { nnp: null, protect: null, rw: [], files: [] }; for (const f of files) { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (_) { continue; } v.files.push(f); for (const line of t.split('\n')) { const m = line.match(/^\s*([A-Za-z]+)\s*=\s*(.*)$/); if (!m) continue; const [, k, val] = m; if (k === 'NoNewPrivileges') v.nnp = /^(yes|true|1|on)$/i.test(val.trim()); else if (k === 'ProtectSystem') v.protect = val.trim(); else if (k === 'ReadWritePaths') { if (!val.trim()) v.rw = []; else v.rw.push(...val.trim().split(/\s+/)); } } } return v; } if (!fs.existsSync(unitFile)) { console.log(`유닛 파일이 없습니다: ${unitFile}`); process.exit(0); } const v = effective(); const problems = []; if (v.nnp === true) problems.push({ key: 'nnp', text: `NoNewPrivileges=yes — 서비스 안에서 sudo 가 "no new privileges" 로 막혀 도메인 연결(nginx 설정)·SSL 발급·기본 오류 서버 설정이 실패합니다` }); const strict = v.protect && /^(strict|full)$/i.test(v.protect); const missing = NEED_RW.filter((p) => !v.rw.some((r) => r === p || p.startsWith(r.replace(/\/$/, '') + '/'))); if (strict && missing.length) problems.push({ key: 'rw', text: `ProtectSystem=${v.protect} 인데 ReadWritePaths 에 ${missing.join(' ')} 이(가) 없음 — sudo 가 되어도 그 경로가 읽기 전용이라 nginx 설정·certbot 이 실패합니다` }); console.log(`유닛 ${unit} (${v.files.join(', ')})`); if (!problems.length) { console.log('배포에 필요한 권한 옵션: 문제 없음 (NoNewPrivileges 없음/no' + (strict ? `, ProtectSystem=${v.protect} + ReadWritePaths 충분` : '') + ')'); process.exit(0); } for (const p of problems) console.log(' 문제: ' + p.text); if (!fix) { console.log(` 보정: sudo node ${path.relative(process.cwd(), __filename) || __filename} --fix --unit ${unit} (드롭인 ${dropFile} 생성) → sudo systemctl daemon-reload && sudo systemctl restart ${unit} (AI 작업이 없을 때)`); process.exit(1); } const lines = ['[Service]', '# webmaker (v1.5.31): 배포(nginx 설정·certbot)에 필요한 권한 — scripts/unit-check.js --fix 가 만든 드롭인. 지우면 원래 유닛 값으로 돌아간다']; if (v.nnp === true) lines.push('NoNewPrivileges=no'); if (strict && missing.length) lines.push('ReadWritePaths=' + missing.join(' ')); try { fs.mkdirSync(dropDir, { recursive: true }); fs.writeFileSync(dropFile, lines.join('\n') + '\n', { mode: 0o644 }); } catch (e) { console.log(` 드롭인 쓰기 실패: ${e.message} (sudo 로 실행하세요)`); process.exit(2); } console.log(` 드롭인 작성: ${dropFile}\n${lines.map((l) => ' ' + l).join('\n')}\n 다음: sudo systemctl daemon-reload && sudo systemctl restart ${unit} (실행 중인 AI 작업이 없을 때)`); process.exit(0);