* 연동 종류. keyNames/urlNames 는 키 파일을 가져올 때 찾는 항목 이름(앞에 있는 것 우선).
* fixedId 가 있는 종류는 서비스에 하나만 둔다(예: 메일 발송 키).
*/
constKINDS={
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 가 만든다)
// 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-…)'},
// 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)
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 키 또는 토큰'},
// 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){throwhttpError(`암호화 키(data/session.secret)를 읽거나 만들 수 없어 접속 정보를 저장할 수 없습니다: ${e.message}`,500);}}
if(sec&&K.wholeJson){letj=null;try{j=JSON.parse(sec);}catch(_){}if(!j||!j.client_email||!j.private_key)throwhttpError('서비스 계정 키 JSON 전체를 붙여넣으세요 (Google Cloud 콘솔 › IAM › 서비스 계정 › 키 › 새 키 만들기 › JSON 으로 받은 파일 내용). client_email·private_key 항목이 있어야 합니다');}
if(sec){
constblob=enc(sec);if(dec(blob)!==sec)throwhttpError('암호화 확인에 실패했습니다',500);// 저장 전에 되돌려 보기
x.secret=blob;x.secretHint=K.wholeJson?(()=>{try{returnJSON.parse(sec).client_email;}catch(_){returnhintOf(sec);}})():hintOf(sec);/* JSON 키는 계정 이메일만 표시 */
x.source=source;x.lastTest=null;// v1.3.17 (B19): 키가 바뀌면 이전 키의 연결 확인 결과·출처를 지운다 (1.3.10 에서 주석에 묻혀 실행되지 않던 줄)
* 서버의 키 파일에서 가져와 암호화 보관. 결과에는 찾은 항목 "이름" 만 돌려준다(값은 돌려주지 않음).
* @param {string} kind
* @param {string} file 서버 파일 경로 (절대 경로)
* @param {object} extra { id?, label? }
*/
functionimportFile(kind,file,extra={}){
constK=KINDS[kind];if(!K)throwhttpError('연동 종류를 고르세요');
constp=String(file||'').trim();if(!path.isAbsolute(p))throwhttpError('키 파일은 서버의 절대 경로로 입력하세요 (예: /home/master/work/WebHoting_AI_Agent/.key)');
lettext;try{constst=fs.statSync(p);if(!st.isFile()||st.size>64*1024)thrownewError('파일이 아니거나 너무 큽니다');text=fs.readFileSync(p,'utf8');}catch(e){throwhttpError(`키 파일을 읽을 수 없습니다: ${e.message}`);}
returnimportText(kind,text,extra,`file:${p}`);
}
/**
* 키 파일 "내용" 에서 가져오기 (v1.3.2: 관리자가 .key 파일을 직접 올리는 경우).
* 올린 파일은 디스크에 저장하지 않고 메모리에서 읽어 필요한 항목만 암호화 보관한다.
* @param {string} text 파일 내용 (KEY=VALUE 줄 또는 JSON)
if(K.wholeJson&&kv.client_email&&kv.private_key){constr=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};}// 서비스 계정 키 파일 그대로
constkeyName=K.keyNames.find((n)=>kv[n]);if(!keyName)throwhttpError(`키 파일에서 ${K.keyNames.join('/')} 항목을 찾지 못했습니다 (파일의 항목: ${names.join(', ')||'없음'})`);
if(x.kind==='resend'){constbase=process.env.RESEND_API_BASE||x.url||KINDS.resend.defaultUrl;constr=awaitcall(base.replace(/\/+$/,'')+'/domains');result={ok:r.ok,detail:r.ok?'키 확인됨 (도메인 목록 조회 성공)':(r.status===401||r.status===403?'키가 올바르지 않습니다':`응답 ${r.status||r.error}`),checks:[r]};}
elseif(x.kind==='google-search'){try{constg=require('./google');awaitg.accessToken(x.secret);result={ok:true,detail:`구글 인증 확인됨 (${g.accountOf(x.secret)})`,checks:[]};}catch(e){result={ok:false,detail:e.message,checks:[]};}}
elseif(x.kind==='openprovider'){if(!(x.settings&&(x.settings.USERNAME||x.settings.OPENPROVIDER_USERNAME)))result={ok:false,detail:'설정 칸에 USERNAME=로그인 이름 을 넣으세요',checks:[]};else{try{constop=require('./openprovider');awaitop.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 공급자는 실제 목록 호출로 확인한다
elseif(x.kind==='porkbun'){try{constp=require('./dns/porkbun');constz=awaitp.listZones();result={ok:true,detail:`Porkbun 확인됨 (계정 도메인 ${z.length}개)`,checks:[]};}catch(e){result={ok:false,detail:e.message,checks:[]};}}
elseif(x.kind==='route53'){try{constp=require('./dns/route53');constz=awaitp.listZones();letdom='';try{constds=awaitrequire('./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: 등록 업체 권한도 함께 확인
elseif(x.kind==='gcloud-dns'){try{constp=require('./dns/gcloud');constz=awaitp.listZones();result={ok:true,detail:`Google Cloud DNS 확인됨 (프로젝트 ${(p.creds()||{}).project}, 관리 영역 ${z.length}개)`,checks:[]};}catch(e){result={ok:false,detail:e.message,checks:[]};}}
elseif(x.kind.startsWith('anthropic'))result={ok:!!x.secret,detail:x.secret?'저장됨 — 실제 확인은 관리자 콘솔 › AI 연결 탭의 "연결 테스트" 를 사용하세요':'키가 없습니다',checks:[]};
// v1.5.7: 연동 모듈의 비밀값은 그 모듈의 status() 로 실제 접속을 확인한다 (주소·계정은 설정 파일에 있음)