Files
AriWeb-Suite/modules/webmaker/lib/setupapi.js
T

170 lines
18 KiB
JavaScript
Raw Normal View History

'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;
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 관리하는 설정 파일 항목은 설정 페이지·관리자 콘솔에서 바꿀 수 없다(409 managed_by_hal). HAL 의 적용(by 'hal')은 lib/hal-managed 가 config.set 을 직접 부른다
{ const hm = require('./hal-managed'); const mk = hm.managedKeys(); hm.assertNotManaged(Object.keys(values || {}).filter((k) => mk.includes(k))); }
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 };