Files

200 lines
24 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';
// ============================================================================
// 외부 연동 접속 정보 보관소 (v1.3.1)
//
// 메일 발송(Resend), 메일 게이트웨이, Claude(Anthropic) 인증처럼 "다른 서비스에 접속" 하는 데 필요한
// 주소·설정·키(API Key / Token)를 한 파일(data/credentials.json, 권한 0600)에 모아 암호화해 저장한다.
//
// - 키(secret)는 AES-256-GCM 으로 암호화한다. 암호화 키는 data/session.secret 에서 용도 문자열을 섞어 파생한다
// (다른 기능의 암호화 키와 섞이지 않게 'credential-vault:' 접두사 사용). 환경변수 VAULT_KEY 가 있으면 그것을 쓴다.
// - 화면·API 에는 키 원문을 절대 돌려주지 않는다(앞뒤 몇 글자만 보이는 hint). 서버 내부 코드만 secretOf() 로 꺼낸다.
// - 키 파일 가져오기: 관리자가 서버의 키 파일 경로(예: 프로젝트 폴더의 .key)를 알려 주면 서버가 한 번 읽어
// 필요한 항목만 암호화해 보관한다. 이후에는 평문 키 파일이 없어도 동작하므로 파일을 지워도 된다.
// - 1.3.0 이전에 settings.json 에 있던 키(메일 발송 키, Claude API 키·OAuth 토큰)와 메일 키 파일은
// 기동 시 migrate() 가 이곳으로 옮기고 settings.json 에서는 지운다(암호화→복호화 검증 후).
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'credentials.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/**
* 연동 종류. keyNames/urlNames 는 키 파일을 가져올 때 찾는 항목 이름(앞에 있는 것 우선).
* fixedId 가 있는 종류는 서비스에 하나만 둔다(예: 메일 발송 키).
*/
const KINDS = {
resend: { label: '메일 발송 (Resend)', fixedId: 'resend', defaultUrl: 'https://api.resend.com', keyNames: ['RESEND_API_KEY', 'RESEND_KEY', 'resend_api_key', 'MAIL_API_KEY', 'API_KEY', 'api_key'], urlNames: ['RESEND_API_BASE'], settingNames: ['MAIL_FROM', 'FROM', 'SENDER', 'ID', 'EMAIL'], secretLabel: 'API 키 (re_…)' },
'mail-gateway': { label: '(구) 메일 게이트웨이 — 1.5.7 부터 Arimail 연동 모듈 사용', fixedId: 'mail-gateway', defaultUrl: '', keyNames: ['MAIL_GATEWAY_TOKEN', 'MAIL_GATEWAY_KEY', 'GATEWAY_TOKEN', 'API_TOKEN', 'TOKEN', 'API_KEY'], urlNames: ['MAIL_GATEWAY_URL', 'GATEWAY_URL', 'URL', 'BASE_URL'], settingNames: ['MX_HOST', 'MAIL_DOMAIN'], secretLabel: 'API 키 또는 토큰' },
// v1.5.7 (요구 5·6) 연동 모듈의 비밀값. 주소·계정 같은 비밀 아닌 값은 설정 파일(lib/config.js: mail.*, source.*)에 있고 여기에는 비밀번호·토큰만 둔다.
// v1.6.38 (요구 2): 메일 서버를 여러 대 등록할 수 있어 고정 id 대신 기본 id 'arimail'(설정 파일의 기본 서버) + 추가 서버는 'arimail-<서버 id>' (lib/mailservers.js 가 만든다)
arimail: { label: 'Arimail 관리자 비밀번호 (메일 연동 모듈)', fixedId: null, defaultId: 'arimail', defaultUrl: '', keyNames: ['ARIMAIL_ADMIN_PASSWORD', 'ADMIN_PASSWORD', 'PASSWORD'], urlNames: ['ARIMAIL_URL'], settingNames: ['ADMIN_LOGIN', 'MAIL_DOMAIN'], secretLabel: '관리자 비밀번호 (설정 파일 mail.adminLogin 계정)' },
// v1.7.3 (요구 2): Arimail 1.0.9 사용량 API 키(au_…). 서버마다 하나 — 기본 서버는 'arimail-usage', 추가 서버는 'arimail-usage-<서버 id>' (lib/mailservers.js 가 만든다). 읽기 전용 통계 키라 관리자 비밀번호와 따로 둔다
'arimail-usage': { label: 'Arimail 사용량 API 키 (메일 사용량 연동)', fixedId: null, defaultId: 'arimail-usage', defaultUrl: '', keyNames: ['USAGE_API_KEY', 'ARIMAIL_USAGE_KEY', 'KEY', 'API_KEY'], urlNames: ['ARIMAIL_URL'], settingNames: ['MAIL_DOMAIN'], secretLabel: '사용량 API 키 (au_… — Arimail 전체 설정 › 사용량 API 카드에서 만듦)' },
gitea: { label: 'Gitea API 토큰 (소스 보관 연동 모듈)', fixedId: 'gitea', defaultUrl: '', keyNames: ['GITEA_TOKEN', 'GITEA_API_TOKEN', 'TOKEN', 'API_TOKEN'], urlNames: ['GITEA_API_URL', 'GITEA_URL'], settingNames: ['GITEA_USER', 'GITEA_REPO'], secretLabel: '접근 토큰 (설정 파일 source.user 계정, repo·user 권한)' },
'anthropic-api-key': { label: 'Claude API 키 (Anthropic Console)', fixedId: 'anthropic-api-key', defaultUrl: 'https://api.anthropic.com', keyNames: ['ANTHROPIC_API_KEY', 'API_KEY'], urlNames: [], settingNames: [], secretLabel: 'API 키 (sk-ant-api03-…)' },
'anthropic-oauth': { label: 'Claude OAuth 토큰 (claude setup-token)', fixedId: 'anthropic-oauth', defaultUrl: '', keyNames: ['CLAUDE_CODE_OAUTH_TOKEN', 'OAUTH_TOKEN', 'TOKEN'], urlNames: [], settingNames: [], secretLabel: 'OAuth 토큰 (sk-ant-oat01-…)' },
// v1.3.10 구글 검색 등록 자동화: Google Cloud 서비스 계정 키(JSON 파일 내용 전체)를 키로 보관한다. OWNER_EMAIL: Search Console 에서 함께 볼 관리자 구글 계정(선택)
'google-search': { label: '구글 검색 등록 자동화 (서비스 계정 키 JSON)', fixedId: 'google-search', defaultUrl: '', keyNames: ['private_key'], urlNames: [], settingNames: ['OWNER_EMAIL'], secretLabel: '서비스 계정 키 JSON 전체 ({"type": "service_account", …})', wholeJson: true },
// v1.4.0 도메인 등록·DNS (Openprovider). 설정 USERNAME=로그인 이름, 키=비밀번호. NS_GROUP(선택, 기본 dns-openprovider)
openprovider: { label: '도메인 등록·DNS (Openprovider)', fixedId: 'openprovider', defaultUrl: 'https://api.openprovider.eu/v1beta', keyNames: ['OPENPROVIDER_PASSWORD', 'PASSWORD'], urlNames: ['OPENPROVIDER_URL'], settingNames: ['USERNAME', 'OPENPROVIDER_USERNAME', 'NS_GROUP'], secretLabel: '비밀번호 (설정 칸에 USERNAME=로그인 이름)' },
// v1.5.36 (요구 4·5) 도메인 등록·DNS 공급자
porkbun: { label: '도메인 등록·DNS (Porkbun)', fixedId: 'porkbun', defaultUrl: 'https://api.porkbun.com/api/json/v3', keyNames: ['PORKBUN_SECRET_KEY', 'SECRET_API_KEY', 'SECRET'], urlNames: ['PORKBUN_URL'], settingNames: ['API_KEY', 'PORKBUN_API_KEY'], secretLabel: 'Secret API Key (설정 칸에 API_KEY=공개 API 키)' },
route53: { label: 'DNS (Amazon Route 53)', fixedId: 'route53', defaultUrl: 'https://route53.amazonaws.com', keyNames: ['AWS_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'SECRET'], urlNames: [], settingNames: ['ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID', 'SESSION_TOKEN', 'REGION'], secretLabel: 'Secret Access Key (설정 칸에 ACCESS_KEY_ID=액세스 키 ID, 선택 REGION=us-east-1)' },
'gcloud-dns': { label: 'DNS (Google Cloud DNS, 서비스 계정 키 JSON)', fixedId: 'gcloud-dns', defaultUrl: 'https://dns.googleapis.com', keyNames: ['private_key'], urlNames: [], settingNames: ['PROJECT', 'PROJECT_ID'], secretLabel: '서비스 계정 키 JSON 전체 (roles/dns.admin; 설정 PROJECT 는 키의 project_id 와 다를 때만)' },
// [Ariweb Suite 변경] 포털 ↔ Webmaker 공유 비밀(SSO 티켓·서비스 호출 서명). lib/ariweb.js 가 읽는다. 설정 파일 ariweb.secret(secret) 이 이 항목에 저장된다.
ariweb: { label: 'Ariweb Suite 연동 공유 비밀', fixedId: 'ariweb', defaultUrl: '', keyNames: ['ARIWEB_SHARED_SECRET', 'SHARED_SECRET', 'SECRET'], urlNames: ['ARIWEB_PORTAL_URL'], settingNames: [], secretLabel: '공유 비밀 (포털 보관소 sso:webmaker 와 같은 값)' },
http: { label: '기타 외부 서비스 (HTTP API)', fixedId: null, defaultUrl: '', keyNames: ['API_KEY', 'TOKEN', 'API_TOKEN', 'KEY'], urlNames: ['URL', 'BASE_URL', 'API_URL'], settingNames: [], secretLabel: 'API 키 또는 토큰' },
};
// ---------- 암호화 ----------
function cryptKey() {
if (process.env.VAULT_KEY) return crypto.createHash('sha256').update('credential-vault:' + process.env.VAULT_KEY).digest();
let s; try { s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) { s = ''; }
// v1.5.7: 서비스가 한 번도 뜨지 않은 새 서버에서 설치 스크립트(scripts/config.js set …)가 먼저 비밀값을 넣을 수 있게, 키 파일이 없으면 lib/auth.js 와 같은 규칙으로 만든다
if (!s) { try { fs.mkdirSync(DATA_ROOT, { recursive: true }); s = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(path.join(DATA_ROOT, 'session.secret'), s, { mode: 0o600 }); } catch (e) { throw httpError(`암호화 키(data/session.secret)를 읽거나 만들 수 없어 접속 정보를 저장할 수 없습니다: ${e.message}`, 500); } }
return crypto.createHash('sha256').update('credential-vault:' + s).digest();
}
function enc(text) { const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', cryptKey(), iv); const out = Buffer.concat([c.update(String(text), 'utf8'), c.final()]); return 'v1:' + Buffer.concat([iv, c.getAuthTag(), out]).toString('base64'); }
function dec(blob) { if (!blob) return ''; const b = Buffer.from(String(blob).replace(/^v1:/, ''), 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', cryptKey(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8'); }
/** 화면 표시용: 앞 6글자…뒤 4글자 (짧은 키는 길이만) */
const hintOf = (k) => (!k ? '' : k.length <= 12 ? `(${k.length}자)` : `${k.slice(0, 6)}…${k.slice(-4)}`);
// ---------- 저장소 ----------
// v1.6.39 (요구 2): 읽기·쓰기를 lib/jsonstore 로 — 쓸 때 직전 정상본을 credentials.json.bak 으로 남기고, 파일이 깨졌으면 .bak 을 읽는다.
// (2026-10-21 webmaker.ibit.kr 에서 Arimail 관리자 비밀번호를 덮어썼을 때 되돌릴 사본이 없었던 일의 재발 방지. 비밀번호 자체의 "이전 값" 은 lib/mailservers.js 가 따로 보관한다)
const jsx = require('./jsonstore');
function load() { const d = jsx.readJson(FILE, null); if (!d || typeof d !== 'object') return { version: 1, items: {} }; d.items = d.items || {}; return d; }
function save(d) { jsx.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true, indent: 1 }); try { fs.chmodSync(FILE + '.bak', 0o600); } catch (_) {} }
/** 화면에 내보낼 형태 (키 원문 없음) */
function pub(x) { return { id: x.id, kind: x.kind, kindLabel: (KINDS[x.kind] || {}).label || x.kind, label: x.label, url: x.url || '', settings: x.settings || {}, hasSecret: !!x.secret, secretHint: x.secretHint || '', source: x.source || 'input', updatedAt: x.updatedAt, lastTest: x.lastTest || null }; }
function list() { return Object.values(load().items).map(pub).sort((a, b) => a.id.localeCompare(b.id)); }
function get(id) { const x = load().items[id]; return x ? pub(x) : null; }
/** 서버 내부 전용: 복호화한 키. 없거나 복호화 실패면 '' */
function secretOf(id) { const x = load().items[id]; if (!x || !x.secret) return ''; try { return dec(x.secret); } catch (_) { return ''; } }
/** 서버 내부 전용: 주소·설정·키를 한 번에 */
function resolve(id) { const x = load().items[id]; if (!x) return null; return { ...pub(x), secret: secretOf(id) }; }
const ID_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
/**
* 추가·수정.
* @param {object} input { id?, kind, label?, url?, settings?(객체 또는 "키=값" 줄), secret?(빈 값이면 기존 유지), clearSecret? }
* @param {string} source 'input'(화면 입력) | 'file:<경로>' | 'migrate:<원래 위치>'
*/
function set(input, source = 'input') {
const kind = String(input.kind || ''); const K = KINDS[kind]; if (!K) throw httpError('연동 종류를 고르세요');
const id = K.fixedId || String(input.id || K.defaultId || '').trim().toLowerCase(); if (!ID_RE.test(id)) throw httpError('식별자는 영문 소문자·숫자·- 2~41자로 입력하세요 (예: crm-api)');
const d = load(); const prev = d.items[id];
if (prev && prev.kind !== kind) throw httpError(`이미 "${prev.kind}" 종류로 쓰는 식별자입니다`, 409);
let url = String(input.url == null ? (prev ? prev.url : K.defaultUrl) : input.url).trim();
if (url && !/^https?:\/\/[^\s]+$/i.test(url)) throw httpError('주소는 http:// 또는 https:// 로 시작해야 합니다');
let st = input.settings == null ? (prev ? prev.settings : {}) : input.settings;
if (typeof st === 'string') { const o = {}; for (const line of st.split(/\r?\n/)) { const m = line.trim().match(/^([A-Za-z0-9_.-]+)\s*[=:]\s*(.*)$/); if (m) o[m[1]] = m[2].trim(); } st = o; }
if (st && typeof st !== 'object') throw httpError('설정은 "이름=값" 줄로 입력하세요');
const x = { id, kind, label: String(input.label || (prev && prev.label) || K.label).slice(0, 80), url, settings: st || {}, secret: prev ? prev.secret : '', secretHint: prev ? prev.secretHint : '', source: prev ? prev.source : source, createdAt: prev ? prev.createdAt : new Date().toISOString(), updatedAt: new Date().toISOString(), lastTest: prev ? prev.lastTest : null };
if (input.clearSecret) { x.secret = ''; x.secretHint = ''; }
const sec = typeof input.secret === 'string' ? input.secret.trim() : '';
if (sec && K.wholeJson) { let j = null; try { j = JSON.parse(sec); } catch (_) {} if (!j || !j.client_email || !j.private_key) throw httpError('서비스 계정 키 JSON 전체를 붙여넣으세요 (Google Cloud 콘솔 › IAM › 서비스 계정 › 키 › 새 키 만들기 › JSON 으로 받은 파일 내용). client_email·private_key 항목이 있어야 합니다'); }
if (sec) {
const blob = enc(sec); if (dec(blob) !== sec) throw httpError('암호화 확인에 실패했습니다', 500); // 저장 전에 되돌려 보기
x.secret = blob; x.secretHint = K.wholeJson ? (() => { try { return JSON.parse(sec).client_email; } catch (_) { return hintOf(sec); } })() : hintOf(sec); /* JSON 키는 계정 이메일만 표시 */
x.source = source; x.lastTest = null; // v1.3.17 (B19): 키가 바뀌면 이전 키의 연결 확인 결과·출처를 지운다 (1.3.10 에서 주석에 묻혀 실행되지 않던 줄)
}
d.items[id] = x; save(d); return pub(x);
}
function remove(id) { const d = load(); if (!d.items[id]) throw httpError('항목이 없습니다', 404); delete d.items[id]; save(d); return true; }
/** KEY=VALUE 또는 JSON 키 파일 해석 (mailer 와 같은 규칙) */
function parseKeyText(text) {
const out = {};
try { const j = JSON.parse(text); if (j && typeof j === 'object') { for (const [k, v] of Object.entries(j)) if (typeof v === 'string') out[k] = v; return out; } } catch (_) {}
for (const raw of String(text).split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const m = line.match(/^(?:export\s+)?([A-Za-z0-9_.\-]+)\s*[=:]\s*(.*)$/); if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ''); }
return out;
}
/**
* 서버의 키 파일에서 가져와 암호화 보관. 결과에는 찾은 항목 "이름" 만 돌려준다(값은 돌려주지 않음).
* @param {string} kind
* @param {string} file 서버 파일 경로 (절대 경로)
* @param {object} extra { id?, label? }
*/
function importFile(kind, file, extra = {}) {
const K = KINDS[kind]; if (!K) throw httpError('연동 종류를 고르세요');
const p = String(file || '').trim(); if (!path.isAbsolute(p)) throw httpError('키 파일은 서버의 절대 경로로 입력하세요 (예: /home/master/work/WebHoting_AI_Agent/.key)');
let text; try { const st = fs.statSync(p); if (!st.isFile() || st.size > 64 * 1024) throw new Error('파일이 아니거나 너무 큽니다'); text = fs.readFileSync(p, 'utf8'); } catch (e) { throw httpError(`키 파일을 읽을 수 없습니다: ${e.message}`); }
return importText(kind, text, extra, `file:${p}`);
}
/**
* 키 파일 "내용" 에서 가져오기 (v1.3.2: 관리자가 .key 파일을 직접 올리는 경우).
* 올린 파일은 디스크에 저장하지 않고 메모리에서 읽어 필요한 항목만 암호화 보관한다.
* @param {string} text 파일 내용 (KEY=VALUE 줄 또는 JSON)
* @param {string} source 출처 표시 (예: 'upload:.key')
*/
function importText(kind, text, extra = {}, source = 'upload') {
const K = KINDS[kind]; if (!K) throw httpError('연동 종류를 고르세요');
const kv = parseKeyText(String(text || '').replace(/^\uFEFF/, '')); const names = Object.keys(kv);
if (K.wholeJson && kv.client_email && kv.private_key) { const r = set({ kind, id: extra.id, label: extra.label, secret: String(text).replace(/^\uFEFF/, '').trim() }, source); return { item: r, found: { key: 'JSON 전체', url: null, settings: [] }, fileItems: names }; } // 서비스 계정 키 파일 그대로
const keyName = K.keyNames.find((n) => kv[n]); if (!keyName) throw httpError(`키 파일에서 ${K.keyNames.join('/')} 항목을 찾지 못했습니다 (파일의 항목: ${names.join(', ') || '없음'})`);
const urlName = K.urlNames.find((n) => kv[n] && /^https?:\/\//i.test(kv[n]));
const settings = {}; for (const n of K.settingNames) if (kv[n] && n !== keyName) settings[n] = kv[n];
const r = set({ kind, id: extra.id, label: extra.label, secret: kv[keyName], ...(urlName ? { url: kv[urlName] } : {}), ...(Object.keys(settings).length ? { settings } : {}) }, source);
return { item: r, found: { key: keyName, url: urlName || null, settings: Object.keys(settings) }, fileItems: names };
}
/**
* 연결 확인 (값은 보여 주지 않고 결과만). 종류별로 가벼운 읽기 요청을 보낸다.
* - resend : GET /domains (키가 맞으면 200)
* - mail-gateway : GET <주소> 와 /health (Authorization: Bearer <토큰>)
* - http : GET <주소>
* - anthropic-* : 관리자 콘솔의 "연결 테스트"(Claude 실행)로 확인
*/
async function test(id) {
const x = resolve(id); if (!x) throw httpError('항목이 없습니다', 404);
const call = async (u) => { try { const r = await fetch(u, { headers: x.secret ? { Authorization: `Bearer ${x.secret}` } : {}, signal: AbortSignal.timeout(15000) }); return { url: u, status: r.status, ok: r.ok }; } catch (e) { return { url: u, status: 0, ok: false, error: e.cause ? e.cause.code || e.message : e.message }; } };
let result;
if (x.kind === 'resend') { const base = process.env.RESEND_API_BASE || x.url || KINDS.resend.defaultUrl; const r = await call(base.replace(/\/+$/, '') + '/domains'); result = { ok: r.ok, detail: r.ok ? '키 확인됨 (도메인 목록 조회 성공)' : (r.status === 401 || r.status === 403 ? '키가 올바르지 않습니다' : `응답 ${r.status || r.error}`), checks: [r] }; }
else if (x.kind === 'google-search') { try { const g = require('./google'); await g.accessToken(x.secret); result = { ok: true, detail: `구글 인증 확인됨 (${g.accountOf(x.secret)})`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } }
else if (x.kind === 'openprovider') { if (!(x.settings && (x.settings.USERNAME || x.settings.OPENPROVIDER_USERNAME))) result = { ok: false, detail: '설정 칸에 USERNAME=로그인 이름 을 넣으세요', checks: [] }; else { try { const op = require('./openprovider'); await op.call('GET', '/tlds', null, { limit: 1 }); result = { ok: true, detail: 'Openprovider 로그인·조회 확인됨', checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } } }
// v1.5.36: 도메인·DNS 공급자는 실제 목록 호출로 확인한다
else if (x.kind === 'porkbun') { try { const p = require('./dns/porkbun'); const z = await p.listZones(); result = { ok: true, detail: `Porkbun 확인됨 (계정 도메인 ${z.length}개)`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } }
else if (x.kind === 'route53') { try { const p = require('./dns/route53'); const z = await p.listZones(); let dom = ''; try { const ds = await require('./registrars/route53').listDomains(); dom = ` · 등록 도메인 ${ds.length}개(Route 53 Domains 사용 가능)`; } catch (e) { dom = ` · 도메인 등록(Route 53 Domains)은 불가: ${e.message.slice(0, 120)}`; } result = { ok: true, detail: `Route 53 확인됨 (호스팅 영역 ${z.length}개${dom})`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } } // v1.6.32: 등록 업체 권한도 함께 확인
else if (x.kind === 'gcloud-dns') { try { const p = require('./dns/gcloud'); const z = await p.listZones(); result = { ok: true, detail: `Google Cloud DNS 확인됨 (프로젝트 ${(p.creds() || {}).project}, 관리 영역 ${z.length}개)`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } }
else if (x.kind.startsWith('anthropic')) result = { ok: !!x.secret, detail: x.secret ? '저장됨 — 실제 확인은 관리자 콘솔 › AI 연결 탭의 "연결 테스트" 를 사용하세요' : '키가 없습니다', checks: [] };
// v1.5.7: 연동 모듈의 비밀값은 그 모듈의 status() 로 실제 접속을 확인한다 (주소·계정은 설정 파일에 있음)
else if (x.kind === 'arimail' || x.kind === 'gitea') { const r = await require('./connectors').test(x.kind === 'arimail' ? 'mail' : 'source'); result = { ok: r.ok, detail: r.detail, checks: [] }; }
else { if (!x.url) throw httpError('주소가 없습니다'); const base = x.url.replace(/\/+$/, ''); const checks = [await call(base)]; if (x.kind === 'mail-gateway') checks.push(await call(base + '/health')); const ok = checks.some((c) => c.ok); result = { ok, detail: ok ? '접속 확인됨' : `접속 실패 (${checks.map((c) => `${c.url.replace(base, '') || '/'} → ${c.status || c.error}`).join(', ')})`, checks }; }
const d = load(); if (d.items[id]) { d.items[id].lastTest = { at: new Date().toISOString(), ok: result.ok, detail: result.detail }; save(d); }
return result;
}
/**
* 1.3.0 이전 설정의 키를 보관소로 옮긴다 (서비스 기동 시 1회, 이미 옮긴 항목은 건너뜀).
* - settings.mail.apiKey(providers.enc 암호화) → resend
* - settings.mail.keyFile(평문 키 파일) → resend (파일을 읽어 암호화 보관, 경로는 출처로만 남김)
* - settings.auth.apiKey / oauthToken(평문) → anthropic-api-key / anthropic-oauth
* 옮긴 뒤 settings.json 에서는 값을 지운다. 옮기기에 실패하면 원래 값을 그대로 둔다(동작 유지).
* @returns {string[]} 옮긴 항목 설명
*/
function migrate(settingsMod, providersMod) {
const done = []; const s = settingsMod.load(); const d = load(); let changed = false;
const mail = s.mail || {};
if (!d.items.resend) {
try {
if (mail.apiKey) { const k = providersMod.dec(mail.apiKey); if (k) { set({ kind: 'resend', secret: k }, 'migrate:settings.mail.apiKey'); done.push('메일 발송 키(설정 입력값) → 보관소'); } }
else if (mail.keyFile) { const r = importFile('resend', mail.keyFile); done.push(`메일 발송 키(키 파일 ${mail.keyFile}) → 보관소 (${r.found.key})`); }
} catch (e) { done.push(`메일 발송 키 이전 실패: ${e.message}`); }
}
if (load().items.resend && (mail.apiKey)) { s.mail = { ...mail, apiKey: '' }; changed = true; }
const a = s.auth || {};
for (const [field, kind] of [['apiKey', 'anthropic-api-key'], ['oauthToken', 'anthropic-oauth']]) {
if (!a[field]) continue;
try { if (!load().items[kind]) { set({ kind, secret: a[field] }, `migrate:settings.auth.${field}`); done.push(`Claude ${field === 'apiKey' ? 'API 키' : 'OAuth 토큰'} → 보관소`); } if (secretOf(kind) === a[field] || load().items[kind]) { a[field] = ''; changed = true; } }
catch (e) { done.push(`Claude ${field} 이전 실패: ${e.message}`); }
}
if (changed) settingsMod.replaceSecrets({ mail: s.mail, auth: a });
return done;
}
module.exports = { importText, KINDS, FILE, list, get, secretOf, resolve, set, remove, importFile, test, migrate, parseKeyText, hintOf };