Files

168 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ============================================================================
// 설정 파일·연동 상태 API (v1.5.7, 요구 2·7)
//
// 두 곳에서 같은 함수를 쓴다.
// - 초기 설정 페이지(setup-server.js): 설치 직후 운영자가 별도 포트(기본 3081)로 접속해 설정 파일을 채우는 전용 웹페이지. /api/setup/*
// - 관리자 콘솔(server.js): 로그인한 관리자가 › 시스템 설정 탭에서 같은 화면을 쓴다. /api/admin/config/*
// 화면(public/setup.js)은 어느 쪽에 붙어 있는지와 무관하게 아래 경로 모양만 안다.
//
// GET <base> 설정 항목표(값·출처)와 연동·엔진 상태, 서버 정보, 설치 명령 안내
// PUT <base> 값 저장 { values: { '<group>.<key>': 값 } } → 바뀐 항목, 재시작 필요 여부
// POST <base>/test/<kind> 연결 확인: source(Gitea) · mail(Arimail) · claude · codex · resend
// POST <base>/provision/<kind>/<action> 연동 준비 작업: source/ensure-repo, source/register-key
// POST <base>/restart 웹메이커 서비스 재시작 (sudo systemctl restart <service.systemdUnit>)
// POST <base>/migrate-env 지금 프로세스의 환경 변수를 설정 파일로 옮겨 적기(없는 항목만)
// v1.5.11 (요구 1·2) — 설치 점검표·관리자 계정·AI 엔진 인증 (처음 설치한 서버를 웹에서만 운영 가능한 상태로 만들기)
// GET <base>/ready 설치 점검표 (lib/setupstate.js: 관리자 비밀번호·AI 인증·공개 주소 + 참고 항목)
// POST <base>/admin/bootstrap 관리자 admin 을 1회용 무작위 비밀번호로 만들기 (v1.6.31, 응답에 password 한 번만) — 이미 있으면 그대로
// POST <base>/admin/create 새 관리자 { id, name, password }
// POST <base>/admin/password 관리자 비밀번호 재설정 { id, password } — 설정 페이지에 들어온 사람(토큰·관리자)만
// PUT <base>/ai/claude Claude 인증 { mode: server-login|oauth-token|api-key, apiKey?, oauthToken? } (관리자 콘솔 › AI 연결 탭 › Claude 모델 연결과 같은 저장)
// PUT <base>/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 };