'use strict'; // ============================================================================ // 설정 파일·연동 상태 API (v1.5.7, 요구 2·7) // // 두 곳에서 같은 함수를 쓴다. // - 초기 설정 페이지(setup-server.js): 설치 직후 운영자가 별도 포트(기본 3081)로 접속해 설정 파일을 채우는 전용 웹페이지. /api/setup/* // - 관리자 콘솔(server.js): 로그인한 관리자가 › 시스템 설정 탭에서 같은 화면을 쓴다. /api/admin/config/* // 화면(public/setup.js)은 어느 쪽에 붙어 있는지와 무관하게 아래 경로 모양만 안다. // // GET 설정 항목표(값·출처)와 연동·엔진 상태, 서버 정보, 설치 명령 안내 // PUT 값 저장 { values: { '.': 값 } } → 바뀐 항목, 재시작 필요 여부 // POST /test/ 연결 확인: source(Gitea) · mail(Arimail) · claude · codex · resend // POST /provision// 연동 준비 작업: source/ensure-repo, source/register-key // POST /restart 웹메이커 서비스 재시작 (sudo systemctl restart ) // POST /migrate-env 지금 프로세스의 환경 변수를 설정 파일로 옮겨 적기(없는 항목만) // v1.5.11 (요구 1·2) — 설치 점검표·관리자 계정·AI 엔진 인증 (처음 설치한 서버를 웹에서만 운영 가능한 상태로 만들기) // GET /ready 설치 점검표 (lib/setupstate.js: 관리자 비밀번호·AI 인증·공개 주소 + 참고 항목) // POST /admin/bootstrap 관리자 admin 을 1회용 무작위 비밀번호로 만들기 (v1.6.31, 응답에 password 한 번만) — 이미 있으면 그대로 // POST /admin/create 새 관리자 { id, name, password } // POST /admin/password 관리자 비밀번호 재설정 { id, password } — 설정 페이지에 들어온 사람(토큰·관리자)만 // PUT /ai/claude Claude 인증 { mode: server-login|oauth-token|api-key, apiKey?, oauthToken? } (관리자 콘솔 › AI 연결 탭 › Claude 모델 연결과 같은 저장) // PUT /ai/openai OpenAI { enabled, authMode: api-key|server-login, apiKey? } // ============================================================================ const os = require('os'); const path = require('path'); const { execFile } = require('child_process'); const VERSION = require('../package.json').version; const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); function run(cmd, args, timeout = 20000) { return new Promise((resolve) => { execFile(cmd, args, { timeout, env: { ...process.env, LANG: 'C' } }, (err, stdout, stderr) => resolve({ ok: !err, code: err ? err.code : 0, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() })); }); } /** systemd 유닛 상태 (sudo 없이 조회) */ async function serviceState(unit) { if (!unit) return { unit: null, state: 'unknown', detail: '설정 파일 service.systemdUnit 이 비어 있어 서비스 상태를 알 수 없습니다 (설치 스크립트가 넣습니다)' }; const r = await run('systemctl', ['is-active', unit], 8000); const state = r.stdout || (r.stderr ? 'unknown' : 'inactive'); let since = null; try { const p = await run('systemctl', ['show', unit, '-p', 'ActiveEnterTimestamp', '--value'], 8000); since = p.stdout || null; } catch (_) {} return { unit, state, active: state === 'active', since, detail: state === 'active' ? `${unit} 실행 중${since ? ` (${since})` : ''}` : `${unit}: ${state}` }; } /** 설치 스크립트 안내 명령 (현재 설정값으로 채운 예시) */ function installCommands(config) { const unit = config.get('service.systemdUnit') || 'webmaker'; const appDir = path.join(__dirname, '..'); const domain = config.get('mail.domain') || 'example.com'; const lan = config.lanIps()[0] || '192.168.0.10'; return { gitea: `${appDir}/deploy/install-gitea.sh --service ${unit} --bind ${lan}`, arimail: `${appDir}/deploy/install-arimail.sh --host mail.${domain} --mailbox admin@${domain}`, arimailRegister: `${appDir}/deploy/install-arimail.sh --register-only --url https://mail.${domain} --domain ${domain} --admin-login admin --admin-password '<비밀번호>'`, services: `${appDir}/deploy/install-services.sh --check`, cli: `node ${appDir}/scripts/config.js list`, }; } /** 전체 보기: 설정 항목표 + 상태 */ async function view() { const config = require('./config'); const connectors = require('./connectors'); const unit = config.get('service.systemdUnit') || ''; const [source, mail, service, setup] = await Promise.all([connectors.status('source'), connectors.status('mail'), serviceState(unit), serviceState('webmaker-setup')]); // v1.5.11: 실행 파일뿐 아니라 인증(서버 로그인·OAuth 토큰·API 키)까지 본다 — 설치 직후 "실행 파일은 있는데 로그인이 없어" 작업이 실패하던 상태를 미리 보이게 let claude = null; try { const c = require('./claude').claudeInfo(); const a = await require('./setupstate').claudeAuth(); claude = { ok: !!c.bin && a.ok, bin: c.bin, version: c.version, searched: c.searched, auth: a, detail: c.bin ? `claude ${c.version || ''} (${c.bin}) · ${a.detail}${a.ok ? '' : ` — ${a.hint || ''}`}` : `claude 실행 파일을 찾지 못했습니다 (탐색: ${(c.searched || []).join(', ')})` }; } catch (e) { claude = { ok: false, detail: e.message }; } let codex = null; try { const c = require('./engine-codex').codexInfo(); const sb = c.sandbox || {}; codex = { ok: !!c.bin, bin: c.bin, version: c.version, loggedIn: c.loggedIn, mode: c.mode, sandbox: sb, detail: c.bin ? `${c.version || 'codex'} · ${c.loggedIn ? `로그인됨 (${c.mode})` : '로그인 안 됨 (codex login)'} · 샌드박스 ${sb.mode === 'none' ? '없음' : sb.mode || '?'}${sb.restricted ? ' — ' + sb.reason : ''}` : 'codex 실행 파일 없음 (선택 — OpenAI 서버 로그인을 쓸 때만)' }; } catch (e) { codex = { ok: false, detail: e.message }; } let resend = null; try { const m = await require('./mailer').status(); resend = { ok: !!m.configured, detail: m.configured ? `메일 발송 키 있음 (${m.source})${m.verifiedDomains && m.verifiedDomains.length ? ` · 검증 도메인 ${m.verifiedDomains.join(', ')}` : ''}` : (m.error || '메일 발송 키 없음 — 관리자 콘솔 › 설정 › 메일 발송') }; } catch (e) { resend = { ok: false, detail: e.message }; } // v1.5.11: 설치 점검표·관리자 계정·AI 인증 방식·접속 주소 — 설정 페이지 맨 위 카드들이 쓴다 const ready = await require('./setupstate').check(true); const admins = require('./auth').adminState(); const s = require('./settings').load(); const aiAuth = { claude: { mode: (s.auth || {}).mode || 'server-login', hasApiKey: !!require('./settings').authSecret('apiKey'), hasOauthToken: !!require('./settings').authSecret('oauthToken') } }; try { const p = require('./providers').listAdminProviders().find((x) => x.id === 'openai'); aiAuth.openai = p ? { enabled: p.enabled, authMode: p.authMode || 'api-key', hasKey: p.hasKey, apiKeyMasked: p.apiKeyMasked, codex: p.codex || null } : null; } catch (_) { aiAuth.openai = null; } return { version: VERSION, now: new Date().toISOString(), server: { hostname: os.hostname(), ips: config.lanIps(), node: process.version, pid: process.pid, appDir: path.join(__dirname, '..'), dataRoot: process.env.DATA_ROOT || path.join(__dirname, '..', 'data'), configFile: config.FILE, user: os.userInfo().username, setupUrls: config.setupUrls(''), publicBaseUrl: config.get('service.publicBaseUrl') || '' }, config: config.publicView(), connectors: connectors.list(), status: { service, setup, source, mail, claude, codex, resend }, ready, admins, aiAuth, bootstrap: { id: require('./auth').BOOTSTRAP_ID }, /* v1.6.31: 고정 비밀번호 없음 */ install: installCommands(config), }; } /** v1.5.11 관리자 계정 작업 (토큰 또는 관리자 세션으로 설정 페이지에 들어온 사람만 — 호출 전에 인증은 서버 쪽에서 끝났다) */ function adminAction(action, body, by) { const auth = require('./auth'); const setupstate = require('./setupstate'); const b = body || {}; if (action === 'bootstrap') { const r = auth.bootstrapAdmin(by || 'setup'); setupstate.invalidate(); if (r.created) try { require('./audit').log({ by: by || 'setup', action: 'admin.bootstrap', targetType: 'user', target: auth.BOOTSTRAP_ID }); } catch (_) {} return { ok: true, password: r.created ? r.password : undefined, created: r.created, user: r.user, detail: r.created ? `기본 관리자 ${auth.BOOTSTRAP_ID} / ${auth.BOOTSTRAP_PASSWORD} 를 만들었습니다. 로그인 뒤 비밀번호를 바꿔야 합니다` : `${auth.BOOTSTRAP_ID} 계정이 이미 있습니다${r.user.mustChangePassword ? ' (비밀번호 기본값 — 바꾸거나 아래에서 재설정하세요)' : ''}` }; } if (action === 'create') { const id = String(b.id || '').trim().toLowerCase(); if (!id) throw httpError('아이디를 입력하세요'); const u = auth.createUser({ id, password: String(b.password || ''), name: String(b.name || id), role: 'admin' }); setupstate.invalidate(); return { ok: true, user: u, detail: `관리자 ${u.id} 를 만들었습니다` }; } if (action === 'password') { const id = String(b.id || '').trim().toLowerCase(); if (!id) throw httpError('아이디를 입력하세요'); const u0 = auth.getUser(id); if (!u0) throw httpError('사용자가 없습니다', 404); if (u0.role !== 'admin') throw httpError('관리자 계정만 여기서 재설정할 수 있습니다 (일반 회원은 관리자 콘솔 › 사용자)'); const u = auth.resetPassword(id, String(b.password || '')); setupstate.invalidate(); return { ok: true, user: u, detail: `${u.id} 의 비밀번호를 바꿨습니다. 이제 이 계정으로 로그인할 수 있습니다` }; } throw httpError('지원하지 않는 작업입니다'); } /** v1.5.11 AI 엔진 인증 저장 — 관리자 콘솔 › AI 연결 탭(settings.auth / providers.openai, v1.5.16 에 설정 탭에서 옮김)과 같은 저장소 */ async function aiAction(kind, body, by) { const setupstate = require('./setupstate'); const b = body || {}; if (kind === 'claude') { const settings = require('./settings'); const mode = String(b.mode || ''); if (!['server-login', 'oauth-token', 'api-key'].includes(mode)) throw httpError('인증 방식은 server-login / oauth-token / api-key 중 하나입니다'); const patch = { auth: { mode } }; if (typeof b.apiKey === 'string' && b.apiKey.trim()) patch.auth.apiKey = b.apiKey.trim(); if (typeof b.oauthToken === 'string' && b.oauthToken.trim()) patch.auth.oauthToken = b.oauthToken.trim(); if (mode === 'api-key' && !patch.auth.apiKey && !settings.authSecret('apiKey')) throw httpError('API 키를 입력하세요 (sk-ant-…)'); if (mode === 'oauth-token' && !patch.auth.oauthToken && !settings.authSecret('oauthToken')) throw httpError('OAuth 토큰을 입력하세요 (서버에서 claude setup-token 으로 발급)'); settings.save(patch); setupstate.invalidate(); const a = await setupstate.claudeAuth(); return { ok: a.ok, detail: a.ok ? `저장했습니다 — ${a.detail}` : `저장했지만 아직 쓸 수 없습니다: ${a.detail}${a.hint ? ' · ' + a.hint : ''}`, auth: a }; } if (kind === 'openai') { const providers = require('./providers'); const patch = {}; if (b.enabled !== undefined) patch.enabled = !!b.enabled; if (['api-key', 'server-login'].includes(b.authMode)) patch.authMode = b.authMode; if (typeof b.apiKey === 'string' && b.apiKey.trim()) patch.apiKey = b.apiKey.trim(); const p = providers.saveAdminProvider('openai', patch); setupstate.invalidate(); const st = setupstate.openaiState(); return { ok: st.ok, detail: st.ok ? `저장했습니다 — ${st.detail}` : `저장했습니다 (${st.detail}${st.hint ? ' · ' + st.hint : ''})`, provider: { enabled: p.enabled, authMode: p.authMode, hasKey: p.hasKey } }; } throw httpError('지원하지 않는 엔진입니다'); } /** 저장 */ function save(body, by) { const config = require('./config'); const values = body && body.values && typeof body.values === 'object' ? body.values : body; const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산 return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' }; } /** 연결 확인 */ async function test(kind) { const connectors = require('./connectors'); if (kind === 'source' || kind === 'mail') return connectors.test(kind); const v = await view(); if (kind === 'claude' || kind === 'codex' || kind === 'resend') return { ok: !!v.status[kind].ok, detail: v.status[kind].detail, status: v.status[kind] }; if (kind === 'service') return { ok: !!v.status.service.active, detail: v.status.service.detail, status: v.status.service }; throw httpError('알 수 없는 확인 대상입니다'); } /** 연동 준비 작업 (API 토큰이 있는 Gitea) */ async function provision(kind, action) { const connectors = require('./connectors'); const m = connectors.active(kind); if (!m) throw httpError(`${kind} 연동 모듈이 꺼져 있습니다`); if (kind === 'source' && action === 'ensure-repo' && m.ensureRepo) { const r = await m.ensureRepo(); return { ok: true, detail: r.created ? `저장소 ${r.repo} 를 만들었습니다` : `저장소 ${r.repo} 가 이미 있습니다`, ...r }; } if (kind === 'source' && action === 'register-key' && m.registerKey) { const r = await m.registerKey(); return { ok: true, detail: r.registered ? `이 서버의 공개키를 등록했습니다 (${r.title})` : '공개키가 이미 등록되어 있습니다', ...r }; } throw httpError('지원하지 않는 작업입니다'); } /** * 서비스 재시작. 실행 계정에 `sudo -n systemctl restart <유닛>` 권한이 있어야 한다 * (설치 스크립트가 /etc/sudoers.d/webmaker-setup 에 넣는다). 실행 중인 AI 작업이 있으면 끝날 때까지(최대 10분) 기다린다. */ async function restart() { const config = require('./config'); const unit = config.get('service.systemdUnit'); if (!unit) throw httpError('설정 파일 service.systemdUnit(서비스 이름)이 비어 있어 재시작할 수 없습니다. 서버에서 직접: sudo systemctl restart webmaker'); // v1.5.13: codex(OpenAI 서버 로그인 엔진) 작업도 기다린다 — claude 만 보던 1.5.7 코드는 codex 작업 중에 재시작해 작업을 끊었다 for (let i = 0; i < 120; i++) { const p = await run('pgrep', ['-u', os.userInfo().username, '-f', '[c]laude -p|[c]odex exec'], 5000); if (!p.ok) break; await new Promise((r) => setTimeout(r, 5000)); } const r = await run('sudo', ['-n', 'systemctl', 'restart', unit], 60000); if (!r.ok) throw httpError(`재시작 실패: ${r.stderr || r.stdout || `exit ${r.code}`}. 서버에서 직접 실행하세요: sudo systemctl restart ${unit} (sudo 권한: deploy/install.sh 가 /etc/sudoers.d/webmaker-setup 을 만듭니다)`, 500); await new Promise((r2) => setTimeout(r2, 1500)); const st = await serviceState(unit); return { ok: st.active, detail: st.active ? `${unit} 를 다시 시작했습니다` : `${unit} 가 시작되지 않았습니다 (${st.state}) — journalctl -u ${unit} -n 50 을 확인하세요`, status: st }; } /** * 공통 라우터. base 아래 경로(sub)와 메서드로 분기한다. * @param {object} o { method, sub(배열: base 뒤 경로 조각), body(객체|null), by } * @returns {Promise<{status:number, body:object}>} */ async function handle({ method, sub, body, by }) { const [a, b, c] = sub || []; if (!a) { if (method === 'GET') return { status: 200, body: await view() }; if (method === 'PUT') return { status: 200, body: save(body || {}, by) }; } if (a === 'ready' && method === 'GET') return { status: 200, body: await require('./setupstate').check(sub.includes('force')) }; // v1.5.11 설치 점검표 if (a === 'admin' && b && method === 'POST') return { status: 200, body: adminAction(b, body, by) }; // v1.5.11 관리자 계정 if (a === 'ai' && b && method === 'PUT') return { status: 200, body: await aiAction(b, body, by) }; // v1.5.11 AI 엔진 인증 if (a === 'test' && b && method === 'POST') return { status: 200, body: await test(b) }; if (a === 'provision' && b && c && method === 'POST') return { status: 200, body: await provision(b, c) }; if (a === 'restart' && method === 'POST') return { status: 200, body: await restart() }; if (a === 'migrate-env' && method === 'POST') { const moved = require('./config').migrateFromEnv(`migrate:${by || 'setup'}`); return { status: 200, body: { ok: true, moved, detail: moved.length ? `${moved.length}개 항목을 환경 변수에서 옮겼습니다: ${moved.join(', ')}` : '옮길 항목이 없습니다 (이미 설정 파일에 있거나 환경 변수가 비어 있음)' } }; } return { status: 404, body: { error: 'not found' } }; } module.exports = { view, save, test, provision, restart, handle, serviceState, installCommands };