diff --git a/README.md b/README.md index 5e045de..d9147b5 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,17 @@ npm run build ## Usage ```bash -# Start with defaults (port 9870, ngrok tunnel enabled, no auth) +# Start with defaults (port 9870, no tunnel — front it with a reverse proxy, no auth) cmux-remote # Set a password cmux-remote -P mypassword -# Local only (no tunnel) -cmux-remote --no-tunnel +# Opt back into the ngrok tunnel +cmux-remote --tunnel + +# Override the external URL shown in the banner / QR code +cmux-remote --public-url https://cmux-remote.example.com # Custom port with verbose logging cmux-remote -p 3000 -v @@ -64,8 +67,9 @@ cmux-remote --help |--------|---------|-------------| | `-p, --port ` | `9870` | Local server port | | `-P, --password ` | none | Access password (or `CMUX_REMOTE_PASSWORD` env) | -| `--no-tunnel` | tunnel on | Disable ngrok tunnel | +| `--tunnel` | off | Enable ngrok tunnel (off by default — use a reverse proxy instead) | | `--tunnel-domain ` | none | Fixed ngrok domain (or `NGROK_DOMAIN` env) | +| `--public-url ` | `https://cmux-remote.yirugi.synology.me` | External URL for banner/QR (or `CMUX_PUBLIC_URL` env) | | `--poll-rate ` | `200` | Screen poll interval | | `--theme ` | `dark` | Default theme (`dark` or `light`) | | `-v, --verbose` | off | Verbose server logging | @@ -76,6 +80,8 @@ Create `~/.cmux-remote/config` for persistent settings: ```ini CMUX_REMOTE_PASSWORD=mypassword +CMUX_PUBLIC_URL=https://cmux-remote.yirugi.synology.me +# ngrok is off by default; only needed if you start with --tunnel NGROK_AUTHTOKEN=your_token NGROK_DOMAIN=my-domain.ngrok-free.app CMUX_PATH=/usr/local/bin/cmux diff --git a/docs/synology-dynamic-subdomain.md b/docs/synology-dynamic-subdomain.md new file mode 100644 index 0000000..6794c1f --- /dev/null +++ b/docs/synology-dynamic-subdomain.md @@ -0,0 +1,154 @@ +# Synology에서 동적 서브도메인(ngrok 대체) 조사 + +목표: ngrok처럼 **새 앱마다 쉽게 서브도메인을 부여**해서 외부에서 `[id].yirugi.synology.me`로 +접속하게 하기. ngrok 무료 HTTP 요청 월 20k 한도(ERR_NGROK_727)에서 벗어나는 게 동기. + +조사일: 2026-06-25 / 대상 환경: Synology NAS(DSM 7 가정), `yirugi.synology.me` DDNS, +Portainer, cmux-remote는 Mac에서 프로세스로 구동. + +--- + +## TL;DR + +1. **DNS 와일드카드**: 이미 작동. `*.yirugi.synology.me`가 (중첩 포함) 전부 집 공인 IP로 풀림. → 문제 아님. +2. **Synology RP 와일드카드 네이티브 지원**: DSM 7은 **사실상 불가**. 파일편집 우회는 DSM 6 전용 + 취약. +3. **Synology RP를 API/CLI로 자동 설정**: **가능**(코드로 확인). `SYNO.Core.AppPortal.ReverseProxy`에 list/create/update/delete 전부 존재. +4. **결론**: 와일드카드가 막혀도, **API로 RP 규칙을 동적 생성/삭제**하면 "ngrok처럼 자동 등록"을 + Synology RP만으로 구현 가능. 기존 인프라(443 정문, 기존 규칙) 변경 0. + +--- + +## 1. DNS 상태 — 와일드카드 resolve는 이미 됨 (검증됨) + +`dig` 테스트 결과, 다음이 전부 동일한 공인 IP(`72.210.59.209`)로 풀림: + +| 질의 | 결과 | +|---|---| +| `yirugi.synology.me` | ✅ | +| `portainer.yirugi.synology.me` (작동중) | ✅ | +| `randomtest98765.yirugi.synology.me` (1-level 와일드카드) | ✅ | +| `foo123.ddns.yirugi.synology.me` (중첩 와일드카드) | ✅ | + +→ Synology DDNS가 깊이 상관없이 와일드카드 DNS를 제공. **임의의 `[id].yirugi.synology.me`는 DNS상 이미 집으로 도달함.** + +**중요한 구분**: "DNS 와일드카드(resolve)"와 "리버스 프록시 와일드카드(routing)"는 다른 문제다. +DNS는 해결됨. 진짜 병목은 **들어온 트래픽을 Host 헤더 보고 `[id]`별 백엔드로 라우팅**하는 부분 — +그게 Synology RP가 와일드카드를 못 해서 막히는 지점. + +--- + +## 2. Synology RP 와일드카드 네이티브 지원 — DSM 7은 불가 ❌ + +- 알려진 유일한 우회: SSH로 `/usr/syno/etc/rc.sysv/nginx-conf-generator.sh`(약 29번째 줄)에 + 아래 sed를 넣어, UI에 `wildcard.도메인`으로 입력하면 내부적으로 `*.도메인`으로 변환: + ```sh + sed -i 's/\("fqdn" \+: \+\)"wildcard\.\([^"]*\)"/\1"*.\2"/g' "$SZF_RP_DATASTORE" + ``` +- **이 우회는 DSM 6 전용. DSM 7+는 우회법 없음.** +- 시스템 파일 편집이라 **DSM 업데이트/리부트 시 소실 위험** → 권장하지 않음. +- 커뮤니티 결론: 진짜 와일드카드가 필요하면 별도 프록시(NGINX Proxy Manager / Traefik 컨테이너) 사용. + +--- + +## 3. API/CLI로 RP 자동 설정 — 가능 ✅ (소스로 확인) + +`SYNO.Core.AppPortal.ReverseProxy` API (`path: entry.cgi`, `maxVersion: 1`)에 CRUD가 전부 존재. +N4S4/synology-api 파이썬 래퍼(`synology_api/core_service_apps.py`) 소스에서 직접 확인: + +| 래퍼 메서드 | DSM API method | +|---|---| +| `app_portal_reverse_proxy_list()` | `list` | +| `app_portal_reverse_proxy_create(entry)` | `create` | +| `app_portal_reverse_proxy_update(entry)` | `update` | +| `app_portal_reverse_proxy_delete(uuids)` | `delete` (uuid 단위) | + +호출 경로 2가지: + +- **SSH (synowebapi):** + ```sh + sudo synowebapi --exec api=SYNO.Core.AppPortal.ReverseProxy method=create version=1 entry='' + ``` +- **HTTP (entry.cgi):** 로그인으로 `sid` 받고 `/webapi/entry.cgi`에 POST. + 파이썬 래퍼 사용 시: + ```python + from synology_api.core_service_apps import CoreServiceApps + api = CoreServiceApps(ip, port, user, pw, secure=True, ...) + api.app_portal_reverse_proxy_create(entry) # entry = dict + ``` + +--- + +## 4. 권장 해법 — 와일드카드 없이 "동적 서브도메인" + +API로 규칙을 동적 생성/삭제하면 ngrok 같은 자동 등록을 Synology RP만으로 구현 가능: + +``` +앱 시작 → 스크립트가 API로 [id].yirugi.synology.me → : RP 규칙 생성 +앱 종료 → 그 규칙의 uuid로 삭제 +``` + +이 방식의 장점: + +- ✅ Synology RP 그대로 유지 — 기존 규칙·443 정문 안 건드림 +- ✅ Traefik/NPM로 443 정문을 교체할 필요 없음 (DSM nginx와의 443 공존 문제 회피) +- ✅ 동적/자동 = ngrok 느낌 +- ✅ TLS 자동 커버 (아래 5번) + +--- + +## 5. TLS — 와일드카드 인증서 + +- DSM 7은 임의 도메인의 자동 DNS-01 와일드카드 발급을 기본 지원하지 않음. +- **단, `synology.me` DDNS 도메인은 예외** — Synology가 `*.yirugi.synology.me` 와일드카드 인증서를 + 제공/자동갱신함(자사 DNS라 가능). 따라서 새로 만든 `[id].yirugi.synology.me` 서브도메인도 + **인증서가 자동으로 커버**됨. +- 확인: DSM → Control Panel → Security → Certificate 에서 현재 인증서가 `*.yirugi.synology.me` + 와일드카드인지 점검. +- (직접 도메인으로 와일드카드가 필요하면 `acme.sh` + DNS-01, 예: `./acme.sh -d "*.도메인" --deploy --deploy-hook synology_dsm`) + +--- + +## 6. 구현 시 실전 팁 + +1. **entry JSON 구조는 추측하지 말고 덤프**: UI에서 RP 규칙 하나 손으로 만든 뒤 + `app_portal_reverse_proxy_list()`로 그 entry 구조(frontend/backend/customize_headers 등)를 + 그대로 뽑아 복제. 내부 포맷이라 덤프가 가장 정확. +2. **WebSocket**: entry의 custom header에 `Upgrade`/`Connection`을 넣어야 cmux류가 동작 + (UI의 "Custom Header → WebSocket" 버튼이 하는 것). +3. **nginx reload**: create 후 적용을 위한 reload는 보통 API가 처리하지만, 첫 구현 시 실제 반영 검증 필요. + +--- + +## 7. 리스크 + +- `SYNO.Core.AppPortal.ReverseProxy`는 **비공식·미문서화 내부 API**. DSM 메이저 업데이트에서 + 깨질 수 있음(파이썬 래퍼가 완충은 하나 보장은 아님). +- 와일드카드 파일편집 우회는 DSM 7 미지원 + 업데이트 시 소실 → 사용하지 말 것. + +--- + +## 8. 대안 비교 + +| 방법 | 와일드카드 | 443 정문 | 비고 | +|---|---|---|---| +| **Synology RP + API 자동생성** ⭐ | 불필요(규칙 자동 생성) | Synology RP 그대로 | 기존 인프라 0 변경, 동적. 비공식 API 리스크 | +| NGINX Proxy Manager (컨테이너) | ✅ 네이티브 | 정문 교체/공존 필요 | UI 좋음. DSM과 80/443 공존 처리 필요 | +| Traefik (컨테이너) | ✅ 라벨 자동 | 정문 교체 필요 | 가장 강력. DSM 443 비우고 기존 규칙 이전 | +| 외부 터널 (sish/frp/cloudflared) | N/A | 별도 | 앱이 집 밖(off-LAN)에서 돌 때 필요. 여기선 불필요 | + +→ **"Synology RP 유지 + 동적 서브도메인"을 둘 다 원하면 API 자동생성이 유일하게 둘 다 만족.** + +--- + +## 참고 자료 + +- BforBenny — Wildcard domains in Synology ReverseProxy (DSM 6 only): + https://www.bforbenny.com/add-support-for-wildcard-domains-in-reverseproxy/ +- N4S4/synology-api — Supported APIs (소스: `synology_api/core_service_apps.py`): + https://n4s4.github.io/synology-api/docs/apis +- Marius Hosting — Synology Wildcard Certificate: + https://mariushosting.com/synology-how-to-add-wildcard-certificate/ +- Synology DSM 7 with Let's Encrypt & DNS Challenge: + https://dr-b.io/post/Synology-DSM-7-with-Lets-Encrypt-and-DNS-Challenge +- SynoForum — Synology Reverse Proxy under the hood: + https://www.synoforum.com/resources/synology-reverse-proxy-under-the-hood.135/ diff --git a/package.json b/package.json index 752355a..1e187e6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build": "tsc", "prepublishOnly": "npm run build", "dev": "tsx src/index.ts", - "dev:local": "tsx src/index.ts --no-tunnel", + "dev:local": "tsx src/index.ts --public-url \"\"", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest" diff --git a/src/index.ts b/src/index.ts index 45c0081..d60b154 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,8 +37,9 @@ program .version('0.1.0') .option('-p, --port ', 'Local port', String(DEFAULT_CONFIG.port)) .option('-P, --password ', 'Access password (or CMUX_REMOTE_PASSWORD env)') - .option('--no-tunnel', 'Disable ngrok tunnel') + .option('--tunnel', 'Enable ngrok tunnel (default: off)', DEFAULT_CONFIG.tunnel) .option('--tunnel-domain ', 'Fixed ngrok domain (or NGROK_DOMAIN env)') + .option('--public-url ', 'External URL for access/QR display (or CMUX_PUBLIC_URL env)') .option('--poll-rate ', 'Poll interval in ms', String(DEFAULT_CONFIG.pollRate)) .option('--theme ', 'Default theme (dark|light)', DEFAULT_CONFIG.theme) .option('-v, --verbose', 'Verbose logging', false) @@ -46,8 +47,12 @@ program const config: Config = { port: parseInt(opts.port, 10), password: opts.password || process.env.CMUX_REMOTE_PASSWORD || null, - tunnel: opts.tunnel !== false, + tunnel: opts.tunnel === true, tunnelDomain: opts.tunnelDomain || process.env.NGROK_DOMAIN || null, + publicUrl: + opts.publicUrl !== undefined + ? opts.publicUrl || null + : process.env.CMUX_PUBLIC_URL || DEFAULT_CONFIG.publicUrl, pollRate: parseInt(opts.pollRate, 10), theme: opts.theme as 'dark' | 'light', verbose: opts.verbose, @@ -88,6 +93,9 @@ program console.log(' =================='); console.log(''); console.log(` Local: http://localhost:${config.port}`); + if (config.publicUrl) { + console.log(` Public: ${config.publicUrl}`); + } if (config.password) { const p = config.password; const masked = p.length <= 4 ? '****' : p[0] + '*'.repeat(p.length - 2) + p[p.length - 1]; @@ -100,7 +108,7 @@ program // Start tunnel let tunnel: NgrokTunnel | null = null; - let accessUrl = `http://localhost:${config.port}`; + let accessUrl = config.publicUrl || `http://localhost:${config.port}`; if (config.tunnel) { tunnel = new NgrokTunnel(); try { diff --git a/src/utils/config.ts b/src/utils/config.ts index 92ff88d..0feeb9a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -3,6 +3,7 @@ export interface Config { password: string | null; tunnel: boolean; tunnelDomain: string | null; + publicUrl: string | null; pollRate: number; theme: 'dark' | 'light'; verbose: boolean; @@ -12,8 +13,12 @@ export interface Config { export const DEFAULT_CONFIG: Config = { port: 9870, password: null, - tunnel: true, + // ngrok off by default — the server is now fronted by a reverse proxy + // (Synology RP) that forwards publicUrl -> this port. Pass --tunnel to opt back in. + tunnel: false, tunnelDomain: null, + // External address the proxy serves on; used only for the printed URL + QR code. + publicUrl: 'https://cmux-remote.yirugi.synology.me', pollRate: 200, theme: 'dark', verbose: false,