Files

309 lines
36 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ============================================================================
// 설정 파일 (v1.5.7, 요구 1·2·3)
//
// 웹메이커가 동작하는 데 필요한 "환경 정보" 를 코드·systemd 유닛에서 떼어 한 파일(기본 data/config.json, 권한 0600)에 둔다.
//
// 왜 분리하나
// - 1.5.7 이전에는 공개 주소(PUBLIC_BASE_URL), 소스 저장소 주소(SITES_GIT_REMOTE), AI 실행 파일 경로 같은 값이
// systemd 유닛의 Environment= 줄에 박혀 있어, 처음 설치하는 서버에서는 값을 알기 전에 유닛을 고쳐 써야 했고
// (install-gitea.sh 가 유닛을 sed 로 바꾸는 식), 운영자가 바꾸려면 서버에 들어가 유닛을 편집해야 했다.
// - 이제 "초기 배포 때 모르는 값" 과 "배포에 쓰지 않는 값" 은 모두 이 파일에 두고, 웹 초기 설정 페이지(setup-server.js,
// /setup)나 명령줄(scripts/config.js)로 넣는다. 유닛에는 배포 때 정해지는 값(PORT·HOST·경로)만 남는다.
//
// 우선순위 (apply)
// 1) 설정 파일 값 2) 환경 변수(systemd 유닛) 3) 기본값
// 단, "배포 때 정해지는 값"(SCHEMA 의 deployTime: PORT·HOST·SITES_ROOT·DATA_ROOT 등)은 환경 변수가 우선이고 파일에는
// 참고용으로만 비친다(파일을 고쳐도 포트가 바뀌지 않게 — 포트는 웹서버 프록시 설정과 함께 바꿔야 한다).
//
// 기존 운영 유지 (요구 3)
// 설정 파일이 없는 서버에서 처음 기동하면 migrateFromEnv() 가 지금 환경 변수에 있는 값을 그대로 파일에 옮겨 적는다.
// 그러므로 1.5.7 로 올려도 운영 중인 서비스의 값은 바뀌지 않고, 이후부터는 파일이 기준이 된다.
// 코드의 나머지 부분은 여전히 process.env.X 를 읽는다 — apply() 가 파일 값을 process.env 에 넣어 주기 때문에
// 기존 모듈을 고치지 않아도 된다(모듈이 기동 때 읽는 값은 재시작 뒤 적용, SCHEMA 의 restart 표시).
//
// 비밀값
// 토큰·비밀번호(SCHEMA 의 secret)는 이 파일에 쓰지 않고 암호화 보관소(lib/vault.js)에 넣는다. 파일에는 "있음" 표시조차
// 두지 않으며 화면에는 보관소의 힌트(앞뒤 몇 글자)만 보인다.
//
// 파일 형식
// { "version": 1, "updatedAt": ISO, "updatedBy": "setup|cli|migrate:env", "values": { "<group>.<key>": 값, … } }
// ============================================================================
const fs = require('fs');
const path = require('path');
const os = require('os');
const ROOT = path.join(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
/** 설정 파일 경로: WEBMAKER_CONFIG 환경 변수 > <DATA_ROOT>/config.json */
const FILE = process.env.WEBMAKER_CONFIG || path.join(DATA_ROOT, 'config.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/**
* 설정 항목표. 그룹마다 화면 카드가 하나씩 그려진다.
* key : 파일에 저장되는 이름 (그룹.항목)
* env : 같은 뜻의 환경 변수 — apply() 가 이 이름으로 process.env 에 넣고, migrateFromEnv() 가 이 이름에서 읽어 온다
* type : text | url | int | bool | enum | list(쉼표 목록) | path | secret
* default : 비어 있을 때의 동작 값(화면에 "기본값" 으로 보임). 코드 쪽 기본값과 같게 둔다
* deployTime: 배포 때 정해지는 값 — 환경 변수가 우선, 화면에는 읽기 전용으로 보인다
* restart : 바꾼 뒤 서비스를 재시작해야 적용되는 값 (모듈이 기동 때 한 번 읽음)
* secret : 값을 암호화 보관소에 두는 항목 (vaultKind 가 보관소 종류)
*/
const SCHEMA = [
{ group: 'service', label: '서비스', desc: '이 서비스 자체의 주소와 관리 설정입니다. 설치 직후 공개 주소를 먼저 확인하세요.', keys: [
{ key: 'service.publicBaseUrl', env: 'PUBLIC_BASE_URL', type: 'url', label: '서비스 공개 주소', desc: '사용자가 접속하는 주소(https://webmaker.example.com). 생성 사이트의 위젯·통계 스크립트·웹메일 안내 파일이 이 주소를 부른다. 틀리면 사이트의 문의폼·게시판이 동작하지 않는다.', default: 'http://192.168.20.66', restart: true, required: true },
{ key: 'service.formBaseUrl', env: 'FORM_BASE_URL', type: 'url', label: '폼 접수 서버 주소', desc: '생성 사이트의 폼이 호출하는 서버. 비우면 공개 주소와 같다(관리자 콘솔 › 설정 › 메일 발송의 값이 있으면 그것이 우선).', default: '', restart: true },
{ key: 'service.adminUsers', env: 'ADMIN_USERS', type: 'list', label: '관리자로 취급할 아이디', desc: '회원 role 과 무관하게 관리자로 보는 아이디(쉼표 구분). 보통 비워 두고 관리자 콘솔(또는 scripts/admin.js grant)로 권한을 준다.', default: '', restart: true },
{ key: 'service.systemdUnit', env: 'WEBMAKER_SERVICE', type: 'text', label: 'systemd 서비스 이름', desc: '초기 설정 페이지의 "서비스 재시작" 이 재시작할 유닛 이름(webmaker / webhosting-agent). 설치 스크립트가 넣는다.', default: '' },
// v1.5.11 (요구 2): 기본 포트 3081 → 8795, 바인드 lan → 0.0.0.0. FQDN 을 모르는 서버에서도 http://<서버 IP>:8795/ 가 "기본 접속 위치" 가 되어 모든 설정을 웹에서 하도록.
// 포트가 겹치면 setup-server.js 가 다음 포트(8796, 8797 …)를 잡고 이 값에 적어 둔다. 접속은 토큰(data/setup.token) 또는 관리자 로그인이 필요하다.
{ key: 'service.setupPort', env: 'WEBMAKER_SETUP_PORT', type: 'int', min: 1024, max: 65535, label: '초기 설정 페이지 포트', desc: '설정 전용 웹페이지(setup-server.js)가 여는 포트. 기본 8795 — 겹치면 다음 포트(8796 …)를 자동으로 쓰고 여기에 기록한다. 바꾸면 webmaker-setup 서비스를 재시작한다. 같은 화면이 서비스 안 /setup 에도 있다.', default: 8795 },
{ key: 'service.setupBind', env: 'WEBMAKER_SETUP_BIND', type: 'text', label: '초기 설정 페이지 바인드 주소', desc: '0.0.0.0(기본: 모든 인터페이스 — 공인 IP 만 있는 서버에서도 http://<IP>:8795/ 로 접속), lan(이 서버의 첫 사설 IPv4 만), 또는 특정 IP. 접속에는 토큰(data/setup.token) 또는 관리자 로그인이 필요하고 로그인 시도는 15분 10회로 제한된다.', default: '0.0.0.0' },
{ key: 'service.setupRedirect', env: 'WEBMAKER_SETUP_REDIRECT', type: 'enum', options: [['auto', '자동 (설치 점검표가 끝나기 전에는 첫 화면을 설정 페이지로)'], ['off', '끔 (항상 로그인 화면)']], label: '첫 화면을 설정 페이지로 보내기', desc: '관리자 비밀번호가 기본값이거나 AI 엔진 인증이 없어 운영할 수 없는 동안에는 서비스 첫 화면(/, /login)을 /setup 으로 보낸다. 점검표가 모두 끝나면 보내지 않는다. 점검 전에 로그인 화면을 열려면 /login?direct=1', default: 'auto' },
// v1.6.31 (요구 5·7): 신뢰 프록시 · 내부 주소 허용 목록 · 생성 사이트 전용 주소
{ key: 'service.trustedProxies', env: 'WM_TRUSTED_PROXIES', type: 'text', label: '신뢰 프록시 IP(쉼표)', desc: '이 주소에서 온 연결의 X-Forwarded-For 만 클라이언트 IP 로 믿는다(속도 제한·기록용). 기본은 같은 서버의 nginx(127.0.0.1, ::1). 앞단 프록시가 다른 서버면 그 IP 를 적는다', default: '' },
{ key: 'service.internalAllow', env: 'WM_INTERNAL_ALLOW', type: 'text', label: '내부 주소 허용 목록 (host:port, 쉼표)', desc: '사용자가 넣는 주소(참고·복사·가져오기, 제공자 URL)는 사설·내부 주소로 연결하지 않는다. 관리자가 허용한 내부 서비스(host:port)만 예외. 설정 파일의 Gitea·Arimail 주소는 자동 허용', default: '' },
{ key: 'service.siteOrigin', env: 'WM_SITE_ORIGIN', type: 'text', label: '생성 사이트 전용 주소 (선택)', desc: 'v1.6.31: 생성 사이트(/<사이트ID>/)를 관리 화면과 다른 origin(예: https://sites.example.com)에서 열어 관리 세션 쿠키가 생성 사이트 페이지에 전달되지 않게 한다. 비우면 같은 주소에서 열고, 서버가 요청 출처(Referer·CSRF 토큰)로 세션 사용을 막는다. 그 주소의 nginx·인증서는 운영자가 준비한다', default: '' },
{ key: 'service.allowSignup', env: 'WEBMAKER_ALLOW_SIGNUP', type: 'bool', label: '회원가입 허용', desc: '끄면 로그인 화면에 회원가입 탭이 사라지고 POST /api/auth/signup 이 403 "회원가입이 닫혀 있습니다" 로 응답한다. 관리자는 콘솔 › 사용자에서 계정을 만든다. (웹서버 설정으로 /api/auth/signup 을 막으면 안내 없는 403 이 되므로 이 값을 쓴다)', default: true, envTrue: '1', envFalse: '0' },
] },
{ group: 'runtime', label: '실행 환경 (배포 때 정해지는 값 — 읽기 전용)', desc: '설치 스크립트가 systemd 유닛에 정한 값입니다. 포트·경로는 웹서버(nginx/Caddy) 프록시 설정과 함께 바뀌어야 하므로 여기서는 보기만 하고, 바꾸려면 유닛과 웹서버 설정을 함께 고친 뒤 재시작합니다.', keys: [
{ key: 'runtime.port', env: 'PORT', type: 'int', label: '앱 포트', desc: '웹서버가 프록시하는 로컬 포트', default: 3080, deployTime: true },
{ key: 'runtime.host', env: 'HOST', type: 'text', label: '앱 바인드 주소', desc: '보통 127.0.0.1 (웹서버 뒤)', default: '127.0.0.1', deployTime: true },
{ key: 'runtime.sitesRoot', env: 'SITES_ROOT', type: 'path', label: '사이트 파일 경로', desc: '생성 사이트가 저장되는 폴더(웹서버가 /<사이트ID>/ 로 정적 서비스)', default: '/var/www/sites', deployTime: true },
{ key: 'runtime.dataRoot', env: 'DATA_ROOT', type: 'path', label: '데이터 경로', desc: '회원·사이트·설정·보관소 파일이 있는 폴더(기본 앱 폴더의 data/). 이 설정 파일도 여기에 있다', default: path.join(ROOT, 'data'), deployTime: true },
{ key: 'runtime.statsLog', env: 'STATS_LOG', type: 'path', label: '접속 통계 로그', desc: 'nginx 가 쓰는 사이트 접속 로그(wmstats 형식)', default: '/var/log/nginx/webmaker-sites.log', deployTime: true },
] },
{ group: 'ai', label: 'AI 엔진', desc: 'Claude Code·OpenAI Codex CLI 실행 파일과 실행 한도입니다. 인증(API 키·OAuth 토큰·서버 로그인)은 관리자 콘솔 › AI 연결 탭(v1.5.16, 이전 설정 › 모델 연결)에서 합니다.', keys: [
{ key: 'ai.claudeBin', env: 'CLAUDE_BIN', type: 'path', label: 'claude 실행 파일', desc: '비우면 PATH 와 알려진 설치 위치(~/.local/bin, ~/.npm-global/bin)에서 자동 탐색', default: '', restart: true },
{ key: 'ai.codexBin', env: 'CODEX_BIN', type: 'path', label: 'codex 실행 파일', desc: '비우면 자동 탐색', default: '', restart: true },
{ key: 'ai.claudeModel', env: 'CLAUDE_MODEL', type: 'text', label: 'Claude 기본 모델(설치값)', desc: '관리자 콘솔 › 설정 › 내부 호출 모델을 정하기 전까지 쓰는 Claude 모델(연결 테스트에도 사용). 콘솔에서 정하면 그 값이 우선', default: 'claude-opus-5-5', restart: true },
{ key: 'ai.claudeMaxTurns', env: 'CLAUDE_MAX_TURNS', type: 'int', min: 1, max: 500, label: 'Claude 최대 턴', desc: '한 작업에서 Claude 가 도구를 쓰는 횟수 상한', default: 80, restart: true },
{ key: 'ai.openaiMaxTurns', env: 'OPENAI_ENGINE_MAX_TURNS', type: 'int', min: 1, max: 500, label: 'OpenAI 호환 엔진 최대 턴', desc: 'API 키로 쓰는 OpenAI 호환 모델의 도구 호출 상한', default: 60, restart: true },
// v1.6.31 (검토 S8): 기본값 끔 — 켜면 claude 자식 프로세스 전체의 TLS 검증이 꺼진다(중간자 위험). 사설 CA 는 NODE_EXTRA_CA_CERTS 로 등록하는 것이 바른 방법
{ key: 'ai.allowInsecureTls', env: 'CLAUDE_ALLOW_INSECURE_TLS', type: 'bool', label: 'Claude 실행 때 TLS 검증 완화 (권장하지 않음)', desc: '켜면 claude 자식 프로세스의 모든 HTTPS 검증이 꺼진다. 사내 프록시(자체 서명 인증서)에서만, 그것도 NODE_EXTRA_CA_CERTS 로 CA 를 등록할 수 없을 때만 켠다', default: false, envTrue: '1', envFalse: '0', restart: true },
{ key: 'ai.codexUnsafe', env: 'CODEX_UNSAFE', type: 'bool', label: 'codex 샌드박스 없는 실행 허용 (위험)', desc: 'v1.6.31: 샌드박스를 만들 수 없을 때 기본은 작업 거부(fail-closed)다. 켜면 샌드박스 없이 실행한다 — 다른 사이트·서버 설정·인증 정보에 접근할 수 있으므로 진단 목적 외에는 켜지 않는다', default: false, envTrue: '1', envFalse: '0', restart: true },
{ key: 'ai.maxJobMinutes', env: 'WM_MAX_JOB_MINUTES', type: 'int', min: 5, max: 240, label: 'AI 작업 최대 실행 시간(분)', desc: 'v1.6.31: 한 작업(생성·수정·기획 단계)이 이 시간을 넘기면 중단한다. 15분 동안 출력이 없어도 중단 — 멈춘 작업이 서버·다른 사용자에게 영향을 주지 않게', default: 45 },
{ key: 'ai.maxConcurrentJobs', env: 'WM_MAX_CONCURRENT_JOBS', type: 'int', min: 1, max: 32, label: '동시 AI 작업 수(전체)', desc: 'v1.6.31: 서버 전체에서 동시에 실행할 AI 작업 수. 넘으면 "잠시 후 다시" 로 거절한다', default: 4 },
{ key: 'ai.codexSandbox', env: 'CODEX_SANDBOX', type: 'enum', options: [['auto', '자동 (커널이 허용하면 샌드박스, 아니면 없이)'], ['workspace-write', '항상 샌드박스 (작업 폴더만 쓰기)'], ['none', '항상 샌드박스 없이']], label: 'codex 샌드박스', desc: 'codex 의 Linux 샌드박스는 비특권 user namespace 가 필요하다. Ubuntu 24.04 기본(kernel.apparmor_restrict_unprivileged_userns=1)에서는 샌드박스 안의 쓰기가 모두 실패하므로 "자동" 은 그때 샌드박스 없이 실행한다. 샌드박스를 쓰려면 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (영구: /etc/sysctl.d/60-codex-sandbox.conf) 뒤 재시작', default: 'auto' },
] },
{ group: 'source', label: '소스 보관 연동 (Gitea)', desc: '사이트의 생성·수정 버전을 보관하는 git 서비스입니다. 연동 모듈 lib/connectors/gitea.js 가 이 값으로 접속한다. deploy/install-gitea.sh 가 서비스 서버 안에 전용 Gitea 를 만들고 이 값을 채운다.', keys: [
{ key: 'source.provider', env: 'SOURCE_PROVIDER', type: 'enum', options: [['gitea', 'Gitea'], ['none', '연동 안 함 (로컬 버전 기록만)']], label: '연동 모듈', desc: '다른 git 서비스를 쓰려면 lib/connectors/ 에 모듈을 추가하고 여기서 고른다', default: 'gitea' },
{ key: 'source.remote', env: 'SITES_GIT_REMOTE', type: 'text', label: '푸시 주소 (git 원격)', desc: '예: git@webmaker-gitea:webmaker/sites.git — 서비스 서버의 SSH 키가 그 계정에 등록되어 있어야 한다. 비우면 원격에 보관하지 않는다', default: '' },
{ key: 'source.web', env: 'SITES_GIT_WEB', type: 'url', label: '웹 주소', desc: '운영자가 브라우저로 브랜치를 볼 주소(예: http://192.168.20.23:3030/webmaker/sites). 관리자 콘솔의 버전 링크에 쓴다', default: '' },
{ key: 'source.prefix', env: 'SITES_GIT_PREFIX', type: 'text', label: '브랜치 접두어', desc: '사이트 브랜치 이름 <접두어>/<사이트ID>/main', default: 'sites' },
{ key: 'source.apiUrl', env: 'GITEA_API_URL', type: 'url', label: 'Gitea API 주소', desc: '예: http://192.168.20.23:3030 — 상태 확인·저장소 만들기·SSH 키 등록을 API 로 하려면 넣는다(선택). 토큰은 아래 비밀값', default: '' },
{ key: 'source.user', env: 'GITEA_USER', type: 'text', label: 'Gitea 계정', desc: '저장소 소유 계정(기본 webmaker)', default: 'webmaker' },
{ key: 'source.repo', env: 'GITEA_REPO', type: 'text', label: '저장소', desc: '소유자/이름 (기본 webmaker/sites)', default: 'webmaker/sites' },
{ key: 'source.token', type: 'secret', vaultKind: 'gitea', label: 'Gitea API 토큰', desc: '위 계정의 접근 토큰(repo·user 권한). install-gitea.sh 가 만들어 넣는다. 없으면 API 기능(저장소 만들기·키 등록)만 꺼지고 푸시는 SSH 키로 그대로 된다' },
] },
// [Ariweb Suite 변경] Ariweb Suite 포털 연동: 포털 주소(비밀 아님)와 공유 비밀(보관소 'ariweb'). lib/ariweb.js 참고.
{ group: 'ariweb', label: 'Ariweb Suite 연동', desc: 'Ariweb Suite 고객 포털과의 연동(별도 로그인 없는 제작 화면 진입, 프로젝트 ↔ 운영 사이트 연결). 포털의 보관소 sso:webmaker 와 같은 공유 비밀을 넣습니다.', keys: [
{ key: 'ariweb.portalUrl', env: 'ARIWEB_PORTAL_URL', type: 'url', label: '포털 공개 주소', desc: '예: https://my.ariweb.net — 발행 안내·폐기 영향 조회에 쓴다', default: '' },
{ key: 'ariweb.secret', type: 'secret', vaultKind: 'ariweb', label: '공유 비밀', desc: 'SSO 티켓·서비스 호출 서명에 쓰는 공유 비밀(암호화 보관). 포털이 발급한 값을 그대로 넣는다' },
] },
{ group: 'mail', label: '메일 연동 (Arimail)', desc: 'Arimail(구 mail-gateway)은 받는 메일·웹메일·IMAP/POP/SMTP 를 맡는 메일 서버입니다. 연동 모듈 lib/connectors/arimail.js 가 관리자 API 로 사이트 관리자 계정의 메일함을 만들고 지운다. 보내기(Resend 키)는 관리자 콘솔 › 설정 › 메일 발송 그대로.', keys: [
{ key: 'mail.provider', env: 'MAIL_PROVIDER', type: 'enum', options: [['none', '연동 안 함 (보내기만)'], ['arimail', 'Arimail']], label: '연동 모듈', desc: '다른 메일 서버를 쓰려면 lib/connectors/ 에 모듈을 추가하고 여기서 고른다', default: 'none' },
{ key: 'mail.url', env: 'ARIMAIL_URL', type: 'url', label: 'Arimail 주소 (공개)', desc: '사용자에게 안내하는 웹메일 주소(예: https://mail.example.com). deploy/install-arimail.sh 가 설치하면 자동으로 채운다', default: '' },
{ key: 'mail.apiUrl', env: 'ARIMAIL_API_URL', type: 'url', label: 'Arimail API 주소 (내부)', desc: '웹메이커 서버가 계정을 만들고 지울 때 부르는 주소. 같은 서버에 설치했으면 http://127.0.0.1:8790 처럼 로컬 주소를 넣어 인증서·DNS 와 무관하게 동작시킨다. 비우면 공개 주소를 쓴다', default: '' },
{ key: 'mail.domain', env: 'ARIMAIL_DOMAIN', type: 'text', label: '메일 도메인', desc: 'Arimail 이 다루는 도메인(계정은 아이디@이 도메인). 사이트의 메일 도메인이 이 값과 같을 때 받는 메일·웹메일이 연결된다', default: '' },
{ key: 'mail.adminLogin', env: 'ARIMAIL_ADMIN_LOGIN', type: 'text', label: '관리자 로그인 ID', desc: 'Arimail 관리자 페이지(/admin) 계정. 계정을 만들고 지우는 데 쓴다', default: 'admin' },
{ key: 'mail.adminPassword', type: 'secret', vaultKind: 'arimail', label: '관리자 비밀번호', desc: '위 계정의 비밀번호(암호화 보관). install-arimail.sh 가 설치할 때 무작위로 정해 넣는다' },
// v1.6.38 (요구 2): 메일 서버를 여러 대 등록할 수 있다(관리자 콘솔 › 메일 시스템 › 메일 서버 연동, lib/mailservers.js). 여기 mail.* 는 그중 "기본 서버" 이며 닉네임·IP 는 고를 때 구분용
{ key: 'mail.nickname', env: 'ARIMAIL_NICKNAME', type: 'text', label: '기본 메일 서버 닉네임', desc: '관리자 콘솔에서 메일 서버를 고를 때 보이는 이름(예: ibit 메일). 비우면 호스트 이름', default: '' },
{ key: 'mail.ip', env: 'ARIMAIL_IP', type: 'text', label: '기본 메일 서버 IP', desc: '표시·구분용 서버 IP(예: 115.68.194.192). 다른 서버에 설치한 Arimail 을 구분하기 위한 값이며 접속에는 주소(mail.url / mail.apiUrl)를 쓴다', default: '' },
] },
{ group: 'ssl', label: 'SSL (와일드카드 인증서)', desc: '플랫폼 도메인 와일드카드 인증서(관리자 콘솔 › 설정 › SSL)를 받을 때 쓰는 값입니다.', keys: [
{ key: 'ssl.acmeDir', env: 'WM_ACME_DIR', type: 'path', label: 'ACME 작업 폴더', desc: 'DNS TXT 값을 주고받는 임시 폴더', default: '/var/tmp/webmaker-acme', restart: true },
{ key: 'ssl.acmeWaitMin', env: 'WM_ACME_WAIT_MIN', type: 'int', min: 5, max: 600, label: '수동 DNS 대기(분)', desc: '수동 DNS 방식에서 운영자가 TXT 를 넣을 때까지 기다리는 최대 시간', default: 40, restart: true },
] },
{ group: 'limits', label: '가져오기·복사 한도', desc: '기존 사이트 가져오기·복사의 상한입니다.', keys: [
{ key: 'limits.copyMaxPages', env: 'COPY_MAX_PAGES', type: 'int', min: 1, max: 100000, label: '최대 페이지 수', desc: 'HTML 페이지 상한(무한 링크 방지)', default: 300, restart: true },
{ key: 'limits.copyMaxAssets', env: 'COPY_MAX_ASSETS', type: 'int', min: 1, max: 1000000, label: '최대 자원 파일 수', desc: '이미지·CSS·JS 등 자원 파일 상한', default: 5000, restart: true },
] },
{ group: 'endpoints', label: '외부 API 주소 (고급)', desc: '보통 바꾸지 않습니다. 시험용 대체 서버를 쓸 때만 넣습니다. 접속 키는 관리자 콘솔 › 설정 › 외부 연동 접속 정보에 있습니다.', advanced: true, keys: [
{ key: 'endpoints.resendApiBase', env: 'RESEND_API_BASE', type: 'url', label: 'Resend API', desc: '메일 발송', default: 'https://api.resend.com' },
{ key: 'endpoints.openproviderApiBase', env: 'OPENPROVIDER_API_BASE', type: 'url', label: 'Openprovider API', desc: '도메인 등록·DNS', default: 'https://api.openprovider.eu/v1beta' },
{ key: 'endpoints.googleApiBase', env: 'GOOGLE_API_BASE', type: 'url', label: 'Google API', desc: '검색 등록 자동화', default: 'https://www.googleapis.com' },
{ key: 'endpoints.googleTokenUrl', env: 'GOOGLE_TOKEN_URL', type: 'url', label: 'Google 토큰 URL', desc: '서비스 계정 인증', default: 'https://oauth2.googleapis.com/token' },
{ key: 'endpoints.indexnowEndpoint', env: 'INDEXNOW_ENDPOINT', type: 'url', label: 'IndexNow', desc: '검색 엔진 색인 요청', default: 'https://api.indexnow.org/indexnow' },
] },
];
/** key → 항목 정의 */
const KEYS = Object.fromEntries(SCHEMA.flatMap((g) => g.keys.map((k) => [k.key, { ...k, group: g.group }])));
// ---------- 파일 ----------
let cache = null; let cacheMtime = -1;
function emptyFile() { return { version: 1, updatedAt: null, updatedBy: null, values: {} }; }
function mtimeOf() { try { return fs.statSync(FILE).mtimeMs; } catch (_) { return 0; } }
/**
* 파일 읽기 (없거나 깨졌으면 빈 설정). exists 로 파일 존재 여부를 함께 돌려준다.
* 파일의 수정 시각이 바뀌면 다시 읽는다 — 설치 스크립트(scripts/config.js)나 다른 프로세스(setup-server.js)가 파일을 고쳐도
* 실행 중인 서비스가 다음 호출부터 새 값을 보게 (재시작이 필요한 항목은 SCHEMA 의 restart 표시대로 재시작 뒤 적용).
*/
function load() {
const mt = mtimeOf(); if (cache && mt === cacheMtime) return cache;
// v1.6.39 (요구 2): 파일이 깨졌으면 lib/jsonstore 가 config.json.bak(직전 정상본)을 읽어 준다. 그래도 못 읽으면 broken 으로 표시
try { const raw = fs.readFileSync(FILE, 'utf8'); let d; try { d = JSON.parse(raw); } catch (e) { d = require('./jsonstore').readJson(FILE, null); if (!d) throw e; } cache = { ...emptyFile(), ...d, values: d.values || {}, exists: true }; }
catch (e) { cache = { ...emptyFile(), exists: e.code !== 'ENOENT' ? true : false, broken: e.code !== 'ENOENT' ? e.message : null }; }
cacheMtime = mt; return cache;
}
function save(d, by) {
const out = { version: 1, updatedAt: new Date().toISOString(), updatedBy: by || 'setup', values: d.values };
// v1.6.39 (요구 2): 직전 정상본을 config.json.bak 으로 남긴다 (잘못 저장한 값을 운영자가 되돌릴 수 있게)
require('./jsonstore').writeJsonAtomic(FILE, out, { mode: 0o600, keepBackup: true, indent: 2 });
try { fs.chmodSync(FILE + '.bak', 0o600); } catch (_) {}
cache = { ...out, exists: true }; cacheMtime = mtimeOf(); return cache;
}
function exists() { return load().exists; }
// ---------- 값 해석 ----------
/** 문자열(환경 변수·입력) → 항목 type 에 맞는 값. 틀리면 예외 */
function parse(def, raw) {
if (raw === undefined || raw === null) return undefined;
const s = typeof raw === 'string' ? raw.trim() : raw;
if (s === '' || s === null) return '';
switch (def.type) {
case 'int': { const n = Number(s); if (!Number.isInteger(n)) throw httpError(`${def.label}은(는) 정수로 입력하세요`); if (def.min !== undefined && n < def.min) throw httpError(`${def.label}은(는) ${def.min} 이상이어야 합니다`); if (def.max !== undefined && n > def.max) throw httpError(`${def.label}은(는) ${def.max} 이하여야 합니다`); return n; }
case 'bool': { if (typeof s === 'boolean') return s; const v = String(s).toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(v)) return true; if (['0', 'false', 'no', 'off'].includes(v)) return false; throw httpError(`${def.label}은(는) 켬/끔 값이어야 합니다`); }
case 'url': { const v = String(s); if (!/^https?:\/\/[^\s/]+/i.test(v)) throw httpError(`${def.label}은(는) http:// 또는 https:// 로 시작하는 주소여야 합니다`); return v.replace(/\/+$/, ''); }
case 'path': { const v = String(s); if (!v.startsWith('/')) throw httpError(`${def.label}은(는) / 로 시작하는 절대 경로여야 합니다`); return v; }
case 'enum': { const v = String(s); if (!def.options.some((o) => o[0] === v)) throw httpError(`${def.label}: 고를 수 없는 값입니다 (${def.options.map((o) => o[0]).join(', ')})`); return v; }
case 'list': { const arr = Array.isArray(s) ? s : String(s).split(','); return arr.map((x) => String(x).trim()).filter(Boolean).join(','); }
default: return String(s);
}
}
/** 값 → 환경 변수 문자열 */
function toEnv(def, v) {
if (v === undefined || v === null || v === '') return '';
if (def.type === 'bool') return v ? (def.envTrue || '1') : (def.envFalse || '0');
return String(v);
}
/** 환경 변수 문자열 → 저장값 (migrate 용, 틀린 값은 건너뜀) */
function fromEnv(def, raw) { try { const v = parse(def, raw); return v === undefined ? undefined : v; } catch (_) { return undefined; } }
/**
* 항목의 "지금 동작하는 값" 과 출처.
* - deployTime : 환경 변수 > 파일 > 기본값
* - 그 밖 : 파일 > 환경 변수 > 기본값
* @returns {{value, source:'file'|'env'|'default'}}
*/
function effective(key) {
const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`);
if (def.type === 'secret') { let hint = ''; let has = false; try { const v = require('./vault').get(def.vaultKind); has = !!(v && v.hasSecret); hint = v ? v.secretHint : ''; } catch (_) {} return { value: has ? '••••' : '', source: has ? 'vault' : 'default', hasSecret: has, secretHint: hint }; }
const fileV = load().values[key]; const envRaw = def.env ? process.env[def.env] : undefined;
const envV = envRaw !== undefined && envRaw !== '' ? fromEnv(def, envRaw) : undefined;
if (def.deployTime) { if (envV !== undefined) return { value: envV, source: 'env' }; if (fileV !== undefined && fileV !== '') return { value: fileV, source: 'file' }; return { value: def.default, source: 'default' }; }
if (fileV !== undefined && fileV !== '') return { value: fileV, source: 'file' };
// 파일에 빈 값('')을 명시적으로 저장한 경우 = "비움" — 환경 변수가 있어도 쓰지 않는다 (예: 원격 보관 끄기)
if (fileV === '' && load().values.hasOwnProperty(key)) return { value: '', source: 'file' };
if (envV !== undefined) return { value: envV, source: 'env' };
return { value: def.default, source: 'default' };
}
/** 동작 값만 (secret 은 보관소에서 복호화한 원문) */
function get(key) {
const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`);
if (def.type === 'secret') { try { return require('./vault').secretOf(def.vaultKind); } catch (_) { return ''; } }
const v = effective(key).value; return v === undefined || v === null ? '' : v;
}
/**
* 파일 값을 process.env 에 반영한다 (서비스 기동 맨 처음, 다른 모듈을 require 하기 전에 부른다).
* 파일이 없으면 환경 변수의 값으로 파일을 만든다(migrateFromEnv) — 1.5.7 로 올린 기존 서버의 값이 그대로 보존된다.
* @returns {{created:boolean, file:string, applied:string[]}}
*/
function apply() {
let created = false;
if (!exists()) { migrateFromEnv('migrate:env'); created = true; }
const applied = [];
for (const def of Object.values(KEYS)) {
if (!def.env || def.type === 'secret') continue;
const e = effective(def.key);
if (def.deployTime) { if (e.source === 'file') { process.env[def.env] = toEnv(def, e.value); applied.push(def.env); } continue; } // 환경 변수가 없을 때만 파일 값으로
if (e.source === 'file') { const s = toEnv(def, e.value); if (s === '') delete process.env[def.env]; else process.env[def.env] = s; applied.push(def.env); }
}
return { created, file: FILE, applied };
}
/**
* 환경 변수(systemd 유닛)에 있는 값을 파일로 옮겨 적는다. 이미 파일에 있는 항목은 건드리지 않는다.
* @param {string} by 기록 출처
* @param {object} [envObj] 읽을 환경(기본 process.env). 설치 스크립트가 유닛 파일을 읽어 넘길 때 쓴다
* @returns {string[]} 옮긴 항목 key
*/
function migrateFromEnv(by = 'migrate:env', envObj = process.env) {
const d = load(); const moved = []; const values = { ...d.values };
for (const def of Object.values(KEYS)) {
if (!def.env || def.type === 'secret' || values.hasOwnProperty(def.key)) continue;
const raw = envObj[def.env]; if (raw === undefined || raw === '') continue;
const v = fromEnv(def, raw); if (v === undefined) continue;
values[def.key] = v; moved.push(def.key);
}
if (moved.length || !d.exists) save({ values }, by);
return moved;
}
/**
* 값 저장 (웹 설정 페이지·관리자 콘솔·CLI).
* @param {object} patch { '<group>.<key>': 값 } — secret 은 원문을 주면 보관소에 저장, null 이면 보관소에서 지움, '••••' 또는 undefined 는 유지
* @param {string} by
* @returns {{changed:string[], restart:boolean}} 바뀐 항목과 "재시작해야 적용되는 항목이 있었는지"
*/
function set(patch, by = 'setup') {
if (!patch || typeof patch !== 'object') throw httpError('저장할 값이 없습니다');
const d = load(); const values = { ...d.values }; const changed = []; let restart = false; const after = [];
for (const [key, raw] of Object.entries(patch)) {
const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`);
if (def.deployTime) throw httpError(`${def.label}은(는) 배포 때 정해지는 값이라 여기서 바꿀 수 없습니다 (systemd 유닛과 웹서버 설정을 함께 고치세요)`);
if (def.type === 'secret') {
if (raw === undefined || raw === '••••') continue;
const vault = require('./vault');
if (raw === null || raw === '') { after.push(() => { try { vault.remove(def.vaultKind); } catch (_) {} }); changed.push(key); continue; }
const sec = String(raw); after.push(() => vault.set({ kind: def.vaultKind, secret: sec }, `config:${by}`)); changed.push(key); continue;
}
const v = parse(def, raw); if (v === undefined) continue;
if (def.required && v === '') throw httpError(`${def.label}은(는) 비워 둘 수 없습니다`);
if (values[key] === v) continue;
values[key] = v; changed.push(key); if (def.restart) restart = true;
}
if (!changed.length) return { changed, restart: false };
for (const fn of after) fn(); // 검증을 모두 통과한 뒤 보관소에 쓴다
save({ values }, by);
// 재시작 없이 바뀌어도 되는 값은 지금 바로 process.env 에 반영 (모듈이 호출 시점에 env 를 읽는 경우 즉시 적용)
for (const key of changed) { const def = KEYS[key]; if (def.env && def.type !== 'secret') { const s = toEnv(def, values[key]); if (s === '') delete process.env[def.env]; else process.env[def.env] = s; } }
return { changed, restart };
}
function unset(key, by = 'cli') { const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`); const d = load(); const values = { ...d.values }; delete values[key]; save({ values }, by); if (def.env && def.type !== 'secret') delete process.env[def.env]; return true; }
/** 화면용: 그룹·항목·동작 값·출처 (비밀값 원문 없음) */
function publicView() {
const d = load();
return {
file: FILE, exists: d.exists, broken: d.broken || null, updatedAt: d.updatedAt, updatedBy: d.updatedBy,
groups: SCHEMA.map((g) => ({ group: g.group, label: g.label, desc: g.desc, advanced: !!g.advanced, keys: g.keys.map((k) => { const e = effective(k.key); return { key: k.key, label: k.label, desc: k.desc, type: k.type, options: k.options || null, default: k.default === undefined ? '' : k.default, env: k.env || null, deployTime: !!k.deployTime, restart: !!k.restart, required: !!k.required, min: k.min, max: k.max, value: e.value === undefined ? '' : e.value, source: e.source, hasSecret: !!e.hasSecret, secretHint: e.secretHint || '', fileValue: d.values.hasOwnProperty(k.key) ? d.values[k.key] : undefined }; }) })),
};
}
/** 설정 요약 (로그·CLI list) */
function summary() { const out = {}; for (const def of Object.values(KEYS)) { const e = effective(def.key); out[def.key] = def.type === 'secret' ? (e.hasSecret ? `(보관소 ${e.secretHint})` : '(없음)') : e.value; } return out; }
/**
* 이 서버의 사설 IPv4 목록 (설정 페이지 주소 안내·바인드용). 도커 브리지·가상 인터페이스(docker0, br-*, veth*, virbr*, tun/wg …)는 뺀다 —
* 운영 서버에 컨테이너가 많아 172.x 주소가 열 개씩 나오던 안내를 정리(1.5.7 배포 때 확인). 사설 대역(10/8, 172.16/12, 192.168/16)을 앞에 둔다.
*/
function lanIps() {
const out = []; const skip = /^(docker|br-|veth|virbr|lxc|lxd|tun|tap|wg|zt|tailscale|cni|flannel|cali)/i;
for (const [name, list] of Object.entries(os.networkInterfaces())) { if (skip.test(name)) continue; for (const i of list || []) if (i.family === 'IPv4' && !i.internal) out.push(i.address); }
const priv = (a) => /^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(a);
return [...out.filter(priv), ...out.filter((a) => !priv(a))];
}
/** 설정 페이지가 실제로 붙을 주소: 'lan' → 첫 사설 IP(없으면 127.0.0.1), 그 밖(기본 0.0.0.0)은 입력값 그대로 */
function setupBindAddress() { const v = String(get('service.setupBind') || '0.0.0.0').trim(); if (v !== 'lan') return v; const ips = lanIps(); return ips.find((a) => /^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(a)) || ips[0] || '127.0.0.1'; }
/**
* v1.5.11: 초기 설정 페이지 접속 주소 목록 (설치 스크립트·기동 로그·점검표 안내용).
* 바인드가 특정 IP 면 그 주소 하나, 아니면 이 서버의 IP 들(사설 → 공인 순). FQDN 을 모르는 서버에서는 http://<IP>:8795/ 가 기본 접속 위치다.
*/
function setupUrls(token) {
const port = parseInt(String(get('service.setupPort') || 8795), 10) || 8795; const bind = setupBindAddress();
const ips = bind === '0.0.0.0' || bind === '::' ? lanIps() : [bind];
return (ips.length ? ips : ['127.0.0.1']).map((ip) => `http://${ip}:${port}/${token ? `?token=${token}` : ''}`);
}
module.exports = { SCHEMA, KEYS, FILE, load, exists, apply, migrateFromEnv, set, unset, get, effective, publicView, summary, lanIps, setupBindAddress, setupUrls, parse };