Files

106 lines
12 KiB
JavaScript

'use strict';
// ============================================================================
// 설치 점검표 — "운영할 수 있는 상태인가" (v1.5.11, 요구 2)
//
// 처음 설치한 서버는 설정값(관리자 비밀번호, AI 엔진 인증, 공개 주소 …)이 없어 운영할 수 없다. 이 모듈이 그 상태를 판단해
// - server.js 가 첫 화면(/, /login)을 설정 페이지(/setup)로 보낼지 정하고 (required 가 true 인 동안만),
// - 설정 페이지 맨 위의 "설치 점검표" 카드가 항목별 상태·해결 방법을 보여 준다.
// 점검표가 모두 끝나면(required=false) 포워딩은 멈추고 정상 로그인 화면이 나온다. 운영자가 포워딩 자체를 끄려면 설정 service.setupRedirect=off.
//
// 필수 항목 (하나라도 안 되면 required)
// admin : 쓸 수 있는(비밀번호 변경이 강제되지 않은) 관리자 계정이 있나 — v1.6.31: 최초 관리자는 설정 페이지에서 운영자가 만든다(고정 비밀번호 없음)
// ai : 쓸 수 있는 AI 엔진이 하나라도 있나 — Claude Code(실행 파일 + 서버 로그인/OAuth 토큰/API 키) 또는 OpenAI(API 키·codex 로그인)
// publicBaseUrl : 서비스 공개 주소가 설치값(기본값 http://192.168.20.66)이 아닌 "이 서버의 값" 으로 정해졌나
// 참고 항목 (운영은 되지만 안내)
// resend : 메일 발송 키, source : 소스 보관(Gitea) 원격, mail : 메일 서버(Arimail) 연동
//
// 판단에는 claude auth status 실행(1초 안팎)이 들어가므로 결과를 15초 동안 캐시한다. 설정·계정을 바꾼 뒤에는 invalidate() 로 비운다.
// ============================================================================
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
const CACHE_MS = 15000;
let cache = null; let cacheAt = 0; let pending = null;
/** 자식 프로세스용 환경: 상위 Claude Code 세션 변수를 뺀다 (lib/claude.js cleanEnv 와 같은 이유) */
function childEnv() { const env = {}; for (const [k, v] of Object.entries(process.env)) { if (k === 'CLAUDECODE' || k.startsWith('CLAUDE_')) continue; env[k] = v; } if (!env.HOME) env.HOME = os.homedir(); return env; }
function run(cmd, args, timeout = 15000) { return new Promise((resolve) => { execFile(cmd, args, { timeout, env: childEnv() }, (err, stdout, stderr) => resolve({ ok: !err, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() })); }); }
/**
* Claude Code 인증 상태.
* - 관리자 설정(settings.auth.mode)이 api-key / oauth-token 이면 보관소에 그 값이 있는지만 본다 (실행 때 환경 변수로 넘김).
* - server-login 이면 `claude auth status`(JSON: loggedIn, authMethod)를 실행해 본다. 명령이 없는 옛 버전이면 ~/.claude/.credentials.json 존재로 대신 판단.
* @returns {Promise<{ok:boolean, mode:string, method?:string, detail:string, hint?:string}>}
*/
async function claudeAuth() {
const settings = require('./settings'); const claude = require('./claude');
const info = claude.claudeInfo();
if (!info.bin) return { ok: false, mode: 'none', detail: `claude 실행 파일이 없습니다 (탐색: ${(info.searched || []).join(', ')})`, hint: '서비스 실행 계정으로 설치: curl -fsSL https://claude.ai/install.sh | bash (또는 npm i -g @anthropic-ai/claude-code). 설치 뒤 설정 ai.claudeBin 을 비워 두면 자동 탐색' };
const a = settings.load().auth || { mode: 'server-login' };
const env = settings.authEnv();
if (a.mode === 'api-key') return env.ANTHROPIC_API_KEY ? { ok: true, mode: 'api-key', detail: 'Anthropic API 키 (보관소)' } : { ok: false, mode: 'api-key', detail: 'API 키 방식이지만 저장된 키가 없습니다', hint: '아래 "AI 엔진 인증" 에서 API 키를 넣거나 서버 로그인으로 바꾸세요' };
if (a.mode === 'oauth-token') return env.CLAUDE_CODE_OAUTH_TOKEN ? { ok: true, mode: 'oauth-token', detail: 'Claude OAuth 토큰 (보관소)' } : { ok: false, mode: 'oauth-token', detail: 'OAuth 토큰 방식이지만 저장된 토큰이 없습니다', hint: '서버에서 claude setup-token 으로 만든 토큰을 아래 "AI 엔진 인증" 에 넣으세요' };
// 서버 로그인: claude auth status → { loggedIn, authMethod }
const r = await run(info.bin, ['auth', 'status']);
if (r.ok) { try { const j = JSON.parse(r.stdout); if (j && j.loggedIn) return { ok: true, mode: 'server-login', method: j.authMethod || '', detail: `서버 로그인됨 (${j.authMethod || 'claude'})` }; return { ok: false, mode: 'server-login', detail: '서버에 claude 로그인이 없습니다', hint: `서비스 실행 계정(${os.userInfo().username})으로 서버에서 한 번 실행: claude (브라우저 로그인) — 또는 아래에서 OAuth 토큰·API 키 방식으로 바꾸세요` }; } catch (_) {} }
const credFile = path.join(os.homedir(), '.claude', '.credentials.json');
if (fs.existsSync(credFile)) return { ok: true, mode: 'server-login', detail: `서버 로그인 자격 증명 있음 (${credFile})` };
return { ok: false, mode: 'server-login', detail: `서버 로그인 자격 증명이 없습니다 (${credFile} 없음)`, hint: `서비스 실행 계정(${os.userInfo().username})으로 서버에서 한 번 실행: claude (브라우저 로그인) — 또는 아래에서 OAuth 토큰·API 키 방식으로 바꾸세요` };
}
/** OpenAI 엔진(API 키 또는 codex 서버 로그인) 상태 */
function openaiState() {
try {
const providers = require('./providers'); const p = providers.listAdminProviders().find((x) => x.id === 'openai');
if (!p || !p.enabled) return { ok: false, detail: 'OpenAI 제공자 꺼짐 (선택)' };
if (p.authMode === 'server-login') return p.codex && p.codex.loggedIn ? { ok: true, detail: `codex 서버 로그인 (${p.codex.mode || ''})` } : { ok: false, detail: 'OpenAI 서버 로그인 방식이지만 codex 로그인이 없습니다', hint: '서버에서 codex login --device-auth' };
return p.hasKey ? { ok: true, detail: `OpenAI API 키 ${p.apiKeyMasked || ''}` } : { ok: false, detail: 'OpenAI 제공자가 켜져 있지만 API 키가 없습니다' };
} catch (e) { return { ok: false, detail: e.message }; }
}
/**
* 점검표 계산. 결과는 15초 캐시.
* @param {boolean} [force] 캐시 무시
* @returns {Promise<{required:boolean, redirect:boolean, items:object[], warnings:object[], at:string}>}
*/
async function check(force) {
if (!force && cache && Date.now() - cacheAt < CACHE_MS) return cache;
if (pending) { if (!force) return pending; await pending.catch(() => {}); } // force: 진행 중인 계산(옛 상태)을 기다렸다가 새로 계산
pending = (async () => {
const config = require('./config'); const auth = require('./auth');
const items = []; const warnings = [];
// 1) 관리자 계정
const st = auth.adminState();
if (!st.admins.length) items.push({ key: 'admin', label: '관리자 계정', ok: false, detail: '관리자 계정이 없습니다', hint: '아래 "관리자 계정" 에서 아이디·비밀번호를 정해 최초 관리자를 만드세요 (v1.6.31: 고정 비밀번호 admin/admin 은 없습니다)' });
else if (!st.usable) items.push({ key: 'admin', label: '관리자 계정', ok: false, detail: `관리자 ${st.admins.map((u) => u.id).join(', ')} 의 비밀번호가 기본값(admin)입니다`, hint: '로그인 화면에서 admin / admin 으로 들어가 새 비밀번호를 정하거나, 아래 "관리자 계정" 에서 비밀번호를 재설정하세요' });
else items.push({ key: 'admin', label: '관리자 계정', ok: true, detail: `관리자 ${st.admins.filter((u) => !u.mustChangePassword).map((u) => u.id).join(', ')}${st.defaultOpen ? ` (기본 비밀번호 그대로인 계정: ${st.admins.filter((u) => u.mustChangePassword).map((u) => u.id).join(', ')} — 바꾸거나 지우세요)` : ''}` });
// 2) AI 엔진
const cl = await claudeAuth(); const oa = openaiState();
const aiOk = cl.ok || oa.ok;
items.push({ key: 'ai', label: 'AI 엔진 인증', ok: aiOk, detail: `Claude: ${cl.detail} · OpenAI: ${oa.detail}`, hint: aiOk ? '' : (cl.hint || oa.hint || '아래 "AI 엔진 인증" 에서 Claude 또는 OpenAI 인증을 넣으세요'), claude: cl, openai: oa });
// 3) 공개 주소
const pb = config.effective('service.publicBaseUrl');
items.push({ key: 'publicBaseUrl', label: '서비스 공개 주소', ok: pb.source !== 'default', detail: pb.source === 'default' ? `설치값(${pb.value})이 그대로입니다 — 이 서버의 주소가 아닙니다` : `${pb.value} (${pb.source === 'file' ? '설정 파일' : '환경 변수'})`, hint: pb.source === 'default' ? '아래 "서비스" 묶음의 서비스 공개 주소에 이 서버의 주소(https://도메인 또는 http://IP)를 넣고 저장 → 서비스 재시작' : '' });
// 참고 항목
try { const m = await require('./mailer').status(); warnings.push({ key: 'resend', label: '메일 발송 (Resend)', ok: !!m.configured, detail: m.configured ? `키 있음 (${m.source})` : '메일 발송 키 없음 — 문의 메일·회원 메일이 나가지 않습니다 (관리자 콘솔 › 설정 › 메일 발송)' }); } catch (e) { warnings.push({ key: 'resend', label: '메일 발송 (Resend)', ok: false, detail: e.message }); }
warnings.push({ key: 'source', label: '소스 보관 (Gitea)', ok: !!config.get('source.remote'), detail: config.get('source.remote') ? config.get('source.remote') : '원격 보관 없음 — 사이트 버전은 서버 안에만 남습니다 (deploy/install-gitea.sh)' });
// v1.5.19 (요구 2): 기본 배포 서버의 vhost 구성 능력 — sudo·nginx·/etc/nginx 쓰기. 서비스 유닛의 NoNewPrivileges·ProtectSystem 때문에 안 되는 경우를 설치 때 미리 보인다
try { const deploy = require('./deploy'); const dl = deploy.list(); const t = dl.defaultId ? deploy.get(dl.defaultId) : null; if (t && t.manageVhost !== false) { const cap = t.type === 'local' || t.hasKey ? await deploy.vhostCapability(t) : { ok: false, hint: 'SSH 키 없음' }; warnings.push({ key: 'vhost', label: `배포 서버 도메인 연결 (${t.name})`, ok: cap.ok, detail: cap.ok ? 'nginx 설정 쓰기 가능 (sudo · nginx · /etc/nginx)' : `불가 — ${cap.hint || 'sudo 권한 없음'} (사이트 도메인 연결·SSL 발급이 실패합니다)` }); } } catch (e) { warnings.push({ key: 'vhost', label: '배포 서버 도메인 연결', ok: false, detail: e.message }); }
// v1.5.17 (요구 1): 플랫폼 도메인 와일드카드 인증서 — 서버 설치 때 미리 받아 둔 것(이름 무관)도 인지해 "서브 도메인에 자동 적용" 으로 보인다
try { const w = await require('./ssl').detect(); const c = w.cert; warnings.push({ key: 'wildcard', label: `와일드카드 인증서${w.platformDomain ? ` (*.${w.platformDomain})` : ''}`, ok: !!(c && c.hasCert), detail: c && c.hasCert ? `있음 — 이름 ${c.certName}${c.external ? ' (서버에 미리 발급된 인증서)' : ''}, 만료 ${c.expires}${c.daysLeft != null ? ` (${c.daysLeft}일)` : ''} → 서브 도메인 사이트에 자동 적용` : (w.reason ? `확인 못 함 — ${w.reason}` : `없음 — 서브 도메인 사이트는 HTTP 로 열립니다 (관리자 콘솔 › 설정 › SSL 에서 발급하거나, 서버에서 certbot 으로 *.${w.platformDomain} 인증서를 받아 두면 자동 적용)`) }); } catch (e) { warnings.push({ key: 'wildcard', label: '와일드카드 인증서', ok: false, detail: e.message }); }
warnings.push({ key: 'mail', label: '메일 서버 (Arimail)', ok: (config.get('mail.provider') || 'none') !== 'none', detail: (config.get('mail.provider') || 'none') !== 'none' ? `${config.get('mail.provider')} ${config.get('mail.url') || ''}` : '연동 없음 — 받는 메일·웹메일은 쓸 수 없습니다 (deploy/install-arimail.sh)' });
const required = items.some((i) => !i.ok);
const redirect = required && String(config.get('service.setupRedirect') || 'auto') !== 'off';
cache = { required, redirect, items, warnings, at: new Date().toISOString() }; cacheAt = Date.now();
return cache;
})().finally(() => { pending = null; });
return pending;
}
/** 설정·계정이 바뀐 뒤 캐시 비우기 */
function invalidate() { cache = null; cacheAt = 0; }
/** 마지막 계산 결과 (없으면 null) — 동기 경로에서 참고용 */
function last() { return cache; }
module.exports = { check, invalidate, last, claudeAuth, openaiState };