Compare commits

...

2 Commits

Author SHA1 Message Date
I Luk Kim 6747c6d77b feat: front with reverse proxy, make ngrok opt-in
ngrok off by default (DEFAULT_CONFIG.tunnel=false); access fronted by a reverse proxy (Synology RP -> :9870). New publicUrl / CMUX_PUBLIC_URL / --public-url sets the banner+QR URL only (not routing). --no-tunnel replaced by --tunnel to re-enable ngrok. Adds docs/synology-dynamic-subdomain.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
I Luk Kim bae87b5023 perf: cache static assets in service worker
Versioned cache-first for /js, /css, /fonts, manifest so the ~22 no-store assets (+~5MB fonts) aren't re-fetched through the tunnel on every load. Safe via the existing ?v= cache-busting URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago

@ -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 <port>` | `9870` | Local server port |
| `-P, --password <pass>` | 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 <domain>` | none | Fixed ngrok domain (or `NGROK_DOMAIN` env) |
| `--public-url <url>` | `https://cmux-remote.yirugi.synology.me` | External URL for banner/QR (or `CMUX_PUBLIC_URL` env) |
| `--poll-rate <ms>` | `200` | Screen poll interval |
| `--theme <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

@ -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='<JSON>'
```
- **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 → <host>:<port> 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/

@ -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"

@ -1,40 +1,93 @@
// Pass-through service worker — does NOT cache responses (response caching is what
// made us disable the previous SW; assets must always come fresh from the network).
// Service worker with TWO jobs:
//
// Its only job: add the `ngrok-skip-browser-warning` header to top-level navigation
// requests, so ngrok's free-tier browser interstitial ("You are about to visit …")
// doesn't appear on repeat visits / PWA launches. ngrok skips the warning whenever
// that header is present (verified). The very first visit on a fresh browser still
// shows it once — the SW can't exist before that initial load — but ngrok's own
// cookie also suppresses it after a single "Visit Site" click.
// 1. ngrok interstitial bypass (navigations) — add the `ngrok-skip-browser-warning`
// header to top-level navigation requests so ngrok's free-tier browser
// interstitial ("You are about to visit …") doesn't appear on repeat visits /
// PWA launches. ngrok skips the warning whenever that header is present. The very
// first visit on a fresh browser still shows it once (the SW can't exist before
// that initial load); ngrok's own cookie also suppresses it after one click.
//
// 2. Versioned asset cache (subresources) — serve /js, /css, /fonts and the manifest
// from a Cache Storage entry instead of re-fetching them through the tunnel on
// every load. The server sends `Cache-Control: no-store` on everything (see
// src/server/app.ts), so the browser HTTP cache is disabled and every page open
// otherwise re-downloads ~20 assets + ~5MB of fonts through ngrok. ngrok's free
// tier allows only 20k HTTP requests/month (ERR_NGROK_727 when exceeded), so this
// is the difference between staying under the cap and blowing it in days.
//
// Safe against stale assets: every asset URL carries a ?v=N that index.html bumps
// whenever that file changes (the project's existing cache-busting convention). A
// changed asset is therefore a NEW url → guaranteed cache miss → fresh fetch. We
// never serve old bytes for a current url. Bump CACHE_VERSION to wipe everything
// (e.g. to clear the few KB of dead old-?v= entries, or to refresh the unversioned
// fonts/manifest if they ever change).
const CACHE_VERSION = 'cmux-assets-v1';
// Same-origin GET requests under these paths are cache-first. Everything else
// (/api, navigations, POST, WebSocket upgrades) is left completely untouched.
function isCacheableAsset(pathname) {
return (
pathname.startsWith('/js/') ||
pathname.startsWith('/css/') ||
pathname.startsWith('/fonts/') ||
pathname === '/manifest.json'
);
}
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
// Drop any caches left behind by the old caching SW so nothing serves stale.
// Drop every cache that isn't the current version (old caching SWs included).
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
await Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
async function cacheFirst(request) {
const cache = await caches.open(CACHE_VERSION);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
// Only store complete, same-origin 200s. The server's `no-store` header is
// intentionally ignored — the Cache API stores what we put regardless, and the
// ?v= URL versioning (not HTTP caching) is what guards against staleness here.
if (response && response.status === 200 && response.type === 'basic') {
cache.put(request, response.clone());
}
return response;
}
self.addEventListener('fetch', (event) => {
const req = event.request;
// Only top-level navigations trigger ngrok's interstitial. Leave everything else
// (assets, /api, POST, WebSocket upgrades) completely untouched — no rewrite and
// no caching — so behaviour is otherwise identical to having no SW at all.
if (req.mode !== 'navigate') return;
const headers = new Headers(req.headers);
headers.set('ngrok-skip-browser-warning', 'true');
event.respondWith(
fetch(
new Request(req.url, {
method: 'GET',
headers,
credentials: req.credentials,
redirect: 'manual',
})
).catch(() => fetch(req))
);
// Top-level navigations: network-first, with the ngrok interstitial-bypass header.
// Never cached — HTML must stay fresh so new ?v= asset versions are picked up.
if (req.mode === 'navigate') {
const headers = new Headers(req.headers);
headers.set('ngrok-skip-browser-warning', 'true');
event.respondWith(
fetch(
new Request(req.url, {
method: 'GET',
headers,
credentials: req.credentials,
redirect: 'manual',
})
).catch(() => fetch(req))
);
return;
}
// Versioned static assets: cache-first, keeping them off the tunnel entirely.
const url = new URL(req.url);
if (req.method === 'GET' && url.origin === self.location.origin && isCacheableAsset(url.pathname)) {
event.respondWith(cacheFirst(req));
return;
}
// Everything else: untouched — straight to network, no rewrite, no caching.
});

@ -37,8 +37,9 @@ program
.version('0.1.0')
.option('-p, --port <port>', 'Local port', String(DEFAULT_CONFIG.port))
.option('-P, --password <pass>', '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 <domain>', 'Fixed ngrok domain (or NGROK_DOMAIN env)')
.option('--public-url <url>', 'External URL for access/QR display (or CMUX_PUBLIC_URL env)')
.option('--poll-rate <ms>', 'Poll interval in ms', String(DEFAULT_CONFIG.pollRate))
.option('--theme <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 {

@ -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,

Loading…
Cancel
Save