198 lines
23 KiB
JavaScript
198 lines
23 KiB
JavaScript
'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 와 다를 때만)' },
|
|||
|
|
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 };
|