diff --git a/.env.example b/.env.example index 92c7ee816a..5faf47d124 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,18 @@ BACKEND_PORT=8080 # API_PORT=8080 # SERVER_PORT=8080 FRONTEND_PORT=3000 +# Docker self-host binds published ports to loopback by default. This is an +# intentional safety default while secrets, signup policy, email, TLS, origins, +# and trusted proxies are still being configured. +# +# Leave BIND_HOST empty/127.0.0.1 for localhost-only installs. +# For a trusted private LAN, set a specific host IP such as 192.168.1.10 after +# rotating example secrets and setting FRONTEND_ORIGIN/CORS_ALLOWED_ORIGINS. +# Do not set BIND_HOST=0.0.0.0 for normal use; use a reverse proxy or tunnel +# for public/cross-machine access. The preflight refuses all-interface binds +# unless MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1 is set after security review. +BIND_HOST=127.0.0.1 +# MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1 # Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT. # Set explicitly only when serving frontend on a different origin/domain. FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT} @@ -58,7 +70,7 @@ MULTICA_PUBLIC_URL= # r.RemoteAddr only, which is the safe shape when the backend is # exposed directly. Set this when running behind nginx/Caddy/Cloudflare: # e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's -# announced ranges for cloud deployments. +# announced ranges for cloud deployments. Never use 0.0.0.0/0 or ::/0. MULTICA_TRUSTED_PROXIES= MULTICA_DAEMON_CONFIG= MULTICA_WORKSPACE_ID= @@ -186,6 +198,7 @@ CORS_ALLOWED_ORIGINS= # the proxy IP and the whole deployment lands in one bucket, turning # /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128" # for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF. +# Never use 0.0.0.0/0 or ::/0. # This is a separate list from MULTICA_TRUSTED_PROXIES above (which # governs the autopilot webhook limiter). # RATE_LIMIT_TRUSTED_PROXIES= @@ -250,6 +263,9 @@ ALLOW_SIGNUP=true # The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this # only requires restarting the backend / compose stack — not rebuilding web. # It is not hot-reloaded. +# +# Before exposing a self-host instance beyond localhost, bootstrap the intended +# users/workspace, then set ALLOW_SIGNUP=false and DISABLE_WORKSPACE_CREATION=true. # Optional: Only allow emails from these domains (comma-separated) ALLOWED_EMAIL_DOMAINS= diff --git a/Makefile b/Makefile index 444c6bd62d..591f07fe11 100644 --- a/Makefile +++ b/Makefile @@ -70,6 +70,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted fi; \ echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \ fi + @bash scripts/selfhost-preflight.sh .env @echo "==> Pulling official Multica images..." @if ! docker compose -f docker-compose.selfhost.yml pull; then \ echo ""; \ @@ -81,17 +82,21 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted @echo "==> Starting Multica via Docker Compose..." docker compose -f docker-compose.selfhost.yml up -d @echo "==> Waiting for backend to be ready..." - @for i in $$(seq 1 30); do \ - if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ + @health_url=$$(bash scripts/selfhost-url.sh .env health); \ + for i in $$(seq 1 30); do \ + if curl -sf "$$health_url" > /dev/null 2>&1; then \ break; \ fi; \ sleep 2; \ done - @if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ + @health_url=$$(bash scripts/selfhost-url.sh .env health); \ + if curl -sf "$$health_url" > /dev/null 2>&1; then \ + frontend_url=$$(bash scripts/selfhost-url.sh .env frontend); \ + backend_url=$$(bash scripts/selfhost-url.sh .env backend); \ echo ""; \ echo "✓ Multica is running!"; \ - echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \ - echo " Backend: http://localhost:$${PORT:-8080}"; \ + echo " Frontend: $$frontend_url"; \ + echo " Backend: $$backend_url"; \ echo ""; \ echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \ echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \ @@ -125,20 +130,25 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel fi; \ echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \ fi + @bash scripts/selfhost-preflight.sh .env @echo "==> Building Multica from the current checkout..." docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build @echo "==> Waiting for backend to be ready..." - @for i in $$(seq 1 30); do \ - if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ + @health_url=$$(bash scripts/selfhost-url.sh .env health); \ + for i in $$(seq 1 30); do \ + if curl -sf "$$health_url" > /dev/null 2>&1; then \ break; \ fi; \ sleep 2; \ done - @if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \ + @health_url=$$(bash scripts/selfhost-url.sh .env health); \ + if curl -sf "$$health_url" > /dev/null 2>&1; then \ + frontend_url=$$(bash scripts/selfhost-url.sh .env frontend); \ + backend_url=$$(bash scripts/selfhost-url.sh .env backend); \ echo ""; \ echo "✓ Multica is running!"; \ - echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \ - echo " Backend: http://localhost:$${PORT:-8080}"; \ + echo " Frontend: $$frontend_url"; \ + echo " Backend: $$backend_url"; \ echo ""; \ echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \ echo " or read the generated code from backend logs when Resend is unset."; \ diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 8e46b8c9a6..060e43a0f8 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -63,6 +63,8 @@ Once ready: - **Frontend:** http://localhost:3000 - **Backend API:** http://localhost:8080 +`docker-compose.selfhost.yml` binds published ports to `127.0.0.1` by default. That localhost-only default is intentional: first-run self-hosts may still have open signup/workspace creation, no email provider, no TLS, and example secrets if you bypass `make selfhost`. For private LAN access, set `BIND_HOST` to one specific LAN IP after hardening the instance. For public or cross-machine access, keep Docker bound to loopback and use a TLS reverse proxy or tunnel. + > **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below. ### Step 2 — Log In @@ -70,13 +72,15 @@ Once ready: Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in: - **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication). -- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:` locally). Useful for one-off testing on a single machine. Do not share verification-code log lines. - **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`. Changes to `ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads all three from `/api/config` at runtime, so no web rebuild is needed. See [Advanced Configuration → Signup Controls](SELF_HOSTING_ADVANCED.md#signup-controls-optional) for the recommended sequence to lock down workspace creation. > **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code. +Before exposing the instance beyond localhost, rotate `JWT_SECRET` and `POSTGRES_PASSWORD`, keep `APP_ENV=production`, leave `MULTICA_DEV_VERIFICATION_CODE` empty, configure Resend or SMTP, set exact origins, and lock down signup/workspace creation after bootstrap. The self-host preflight runs automatically in `make selfhost`, `make selfhost-build`, and the Unix installer. + ### Step 3 — Install CLI & Start Daemon The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work. @@ -250,7 +254,7 @@ The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication). -- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing. +- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:` locally). Useful for one-off testing. Do not share verification-code log lines. ```bash kubectl -n multica logs -f deploy/multica-backend | grep "Verification code" @@ -447,6 +451,14 @@ Edit `.env` — at minimum, change `JWT_SECRET`: JWT_SECRET=$(openssl rand -hex 32) ``` +Also change `POSTGRES_PASSWORD` and keep `DATABASE_URL` in sync if you are not using the Makefile, which does this for you. + +Before running Compose, validate the env file without printing secrets: + +```bash +bash scripts/selfhost-preflight.sh .env +``` + Then start everything: ```bash @@ -454,6 +466,33 @@ docker compose -f docker-compose.selfhost.yml pull docker compose -f docker-compose.selfhost.yml up -d ``` +### Exposure modes + +**Localhost (default):** leave `BIND_HOST=127.0.0.1`; `multica setup self-host` points the CLI at `localhost`. + +**Trusted private LAN:** set `BIND_HOST` to one specific host IP, not `0.0.0.0`, and set exact browser origins: + +```bash +BIND_HOST=192.168.1.50 +FRONTEND_ORIGIN=http://192.168.1.50:3000 +CORS_ALLOWED_ORIGINS=http://192.168.1.50:3000 +ALLOW_SIGNUP=false +DISABLE_WORKSPACE_CREATION=true +``` + +**Public or cross-machine:** keep `BIND_HOST=127.0.0.1` and put Caddy, Nginx, or Cloudflare Tunnel in front. Terminate TLS, forward `/ws` with the WebSocket `Upgrade` header, set `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` to the exact public origin, and set `MULTICA_TRUSTED_PROXIES` plus `RATE_LIMIT_TRUSTED_PROXIES` to the exact proxy/CDN CIDRs. Never use `0.0.0.0/0` or `::/0` as trusted proxy ranges. + +`BIND_HOST=0.0.0.0` publishes raw Docker ports on every interface and is refused unless `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1` is set. Treat that override as a reviewed infrastructure exception, not normal public setup. + +### Docker volumes are sensitive + +Compose creates two persistent named volumes: + +- `pgdata` stores the PostgreSQL database: users, workspaces, issues, comments, tokens, OAuth state, and other application records. +- `backend_uploads` stores local attachment/upload files when S3 is not configured. + +Back them up and restore them together for point-in-time recovery. Restrict host and Docker daemon access to the machine, and do not share raw volume archives, `pg_dump` output, or upload tarballs publicly. If support needs diagnostics, share schema/version/error snippets with secrets and user content redacted instead of database dumps. + ## Manual CLI Configuration If you prefer configuring the CLI step by step instead of `multica setup`: diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index 13c8589375..646e986156 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -14,6 +14,24 @@ All configuration is done via environment variables. Copy `.env.example` as a st | `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` | | `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` | +### Exposure Modes and Preflight Guard + +Docker Compose publishes backend and frontend ports on `127.0.0.1` by default. This is the safe first-run shape: secrets, signup/workspace policy, email, TLS, origins, and trusted proxy CIDRs can be configured before anything is reachable from another machine. + +| Mode | Settings | Notes | +|------|----------|-------| +| Localhost | `BIND_HOST=127.0.0.1` | Default. Use `multica setup self-host` on the same machine. | +| Trusted private LAN | `BIND_HOST=` plus exact `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` | Private network only. Rotate secrets, configure email, keep `APP_ENV=production`, clear fixed dev codes, and lock down signup/workspace creation first. | +| Public / cross-machine | Keep `BIND_HOST=127.0.0.1`; use Caddy, Nginx, or Cloudflare Tunnel | Recommended public path. Terminate TLS, forward `/ws` Upgrade, set exact origins, and use narrow trusted proxy CIDRs. | + +The self-host preflight runs before Docker starts in `make selfhost`, `make selfhost-build`, and the Unix installer. You can also run it manually: + +```bash +bash scripts/selfhost-preflight.sh .env +``` + +`BIND_HOST=0.0.0.0` publishes raw Docker ports on every interface and is refused unless `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1` is set. Use that override only after security review; it does not skip the secret/origin/email/signup checks. + ### Database Pool Tuning (Optional) These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default. @@ -100,6 +118,15 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront: | `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs | | `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) | +### Docker Volume Security + +The default Compose stack creates two persistent named volumes: + +- `pgdata` (`/var/lib/postgresql/data`) contains the full PostgreSQL data directory, including account records, workspace data, task/comment history, tokens, and integration state. +- `backend_uploads` (`/app/data/uploads`) contains local uploaded files when S3 is not configured. + +Treat both as sensitive data surfaces. Back them up and restore them together, restrict host and Docker daemon access, and avoid sharing raw volume archives, full `pg_dump` output, or upload tarballs outside the trusted operator group. For support cases, prefer schema/version/error snippets and redact secrets, tokens, user content, and uploaded file data. + ### Cookies | Variable | Description | @@ -114,8 +141,12 @@ The `Secure` flag on session cookies is derived automatically from the scheme of |----------|---------|-------------| | `PORT` | `8080` | Backend server port | | `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` | +| `BIND_HOST` | `127.0.0.1` | Docker Compose host interface for published backend/frontend ports. Use a specific LAN IP for private LAN only; do not use `0.0.0.0` for normal public exposure. | +| `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND` | empty | Emergency override for `BIND_HOST=0.0.0.0`; still requires the rest of the preflight hardening checks to pass. | | `FRONTEND_PORT` | `3000` | Frontend port | | `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins. Governs **both** the HTTP CORS allowlist **and** the WebSocket `Origin` check. A browser origin that isn't listed here (and isn't `localhost`) has its real-time WebSocket upgrade rejected with `403`, so live updates stop working until a manual refresh. | +| `MULTICA_TRUSTED_PROXIES` | empty | CIDRs whose forwarded headers may be trusted for autopilot webhook rate limiting. Leave empty for direct/raw backend exposure. Behind a proxy, use exact proxy/CDN CIDRs only; never `0.0.0.0/0` or `::/0`. | +| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Separate CIDRs for auth endpoint per-IP rate limiting. Required behind a reverse proxy to avoid all users sharing one proxy bucket. Use exact proxy/CDN CIDRs only. | | `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | ### CLI / Daemon @@ -272,7 +303,18 @@ REMOTE_API_URL=http://localhost:8080 pnpm start ## Reverse Proxy -In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing. +In production, keep Docker published ports on `127.0.0.1` and put a reverse proxy or tunnel in front of both the backend and frontend. The proxy must terminate TLS, forward `/ws` with WebSocket `Upgrade` headers, and be the only public listener. + +After the proxy is configured, set exact origins and trusted proxy CIDRs in `.env`: + +```bash +FRONTEND_ORIGIN=https://multica.example.com +CORS_ALLOWED_ORIGINS=https://multica.example.com +MULTICA_TRUSTED_PROXIES=127.0.0.1/32 +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32 +``` + +Replace `127.0.0.1/32` with the actual same-host proxy, load balancer, or CDN CIDRs. Do not use `0.0.0.0/0` or `::/0`; that lets untrusted clients spoof forwarded IP headers. ### Caddy (Recommended) @@ -373,6 +415,8 @@ When using separate domains for frontend and backend, set these environment vari # Backend FRONTEND_ORIGIN=https://app.example.com CORS_ALLOWED_ORIGINS=https://app.example.com +MULTICA_TRUSTED_PROXIES=127.0.0.1/32 +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32 # Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml) REMOTE_API_URL=https://api.example.com @@ -382,14 +426,17 @@ NEXT_PUBLIC_WS_URL=wss://api.example.com/ws ## LAN / Non-localhost Access -By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin: +By default, Multica publishes Docker ports on `127.0.0.1`. If you access it from another machine on a trusted private LAN (e.g. `http://192.168.1.100:3000`), bind to one specific LAN IP and tell the backend to accept that browser origin: ```bash # .env — replace with your server's LAN IP +BIND_HOST=192.168.1.100 FRONTEND_ORIGIN=http://192.168.1.100:3000 CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000 ``` +Do not use `BIND_HOST=0.0.0.0` for normal LAN/public setup. Before restarting, rotate example secrets, keep `APP_ENV=production`, leave `MULTICA_DEV_VERIFICATION_CODE` empty, configure Resend or SMTP, and lock down `ALLOW_SIGNUP` / `DISABLE_WORKSPACE_CREATION` after bootstrap. The preflight checks these before Compose starts. + Then restart the stack: ```bash diff --git a/apps/docs/content/docs/self-host-quickstart.ja.mdx b/apps/docs/content/docs/self-host-quickstart.ja.mdx index 0bdfda8ada..d4340db4d6 100644 --- a/apps/docs/content/docs/self-host-quickstart.ja.mdx +++ b/apps/docs/content/docs/self-host-quickstart.ja.mdx @@ -49,7 +49,13 @@ make selfhost - **バックエンド**: [http://localhost:8080](http://localhost:8080) -**ポートは `127.0.0.1` でのみ待ち受けます。** `docker-compose.selfhost.yml` は公開されるすべてのポートを loopback にバインドします — `ss -tlnp` には `0.0.0.0:8080` は表示されず、設計上、他のマシンからはサービスにアクセスできません。サーバーのシークレットと Postgres の認証情報は、公開インターネット上に置いては絶対にいけません。マシン間アクセスが必要な場合は、TLS を終端するリバースプロキシをスタックの前に置いてください — [ステップ5b — マシン間: リバースプロキシを前に置く](#5b-cross-machine-front-with-a-reverse-proxy)を参照してください。 +**ポートはデフォルトで loopback にバインドされます。** `docker-compose.selfhost.yml` は backend/frontend を `127.0.0.1` に公開するため、初回セットアップ中に他のマシンから raw Docker ポートへは到達できません。これは意図した安全設計です。TLS、メール、正確な origin、サインアップ/ワークスペースポリシー、シークレットが整う前に公開されないようにします。 + +サポートされる公開範囲は3つです。 + +- Localhost: `BIND_HOST=127.0.0.1` のまま、同じマシンで `multica setup self-host` を使います。 +- 信頼済みプライベート LAN: `BIND_HOST` を特定の LAN IP に設定します。`0.0.0.0` は使わず、先にシークレットをローテートし、正確なブラウザ origin を設定します。 +- 公開/マシン間: Docker は loopback のままにし、TLS リバースプロキシまたは tunnel を前段に置きます。 ## 2. 重要: プロダクションの安全設定を維持する @@ -62,6 +68,10 @@ make selfhost 公開デプロイの前には、`.env` に `APP_ENV=production` が設定され、`MULTICA_DEV_VERIFICATION_CODE` が空であることを必ず確認してください。 + +**raw Docker ポートを公開インターネットへ直接出さないでください。** `BIND_HOST=0.0.0.0` は、`MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1` を明示しない限り self-host preflight に拒否されます。この override はレビュー済みのプライベート基盤向けです。通常の公開経路は TLS リバースプロキシまたは tunnel、正確な origin、狭い trusted proxy CIDR です。 + + ## 3. メールサービスを構成する(任意ですが推奨) メールを構成しないと、ユーザーはメールで認証コードを受け取れず、サーバーは生成したコードを代わりに stdout に出力します。 @@ -134,7 +144,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com [http://localhost:3000](http://localhost:3000) を開きます。 - メールアドレスを入力します -- 構成したメールバックエンド(Resend または SMTP relay)から認証コードを受け取ります。どちらも構成していない場合は、サーバーコンテナの stdout からコピーしてください — `[DEV] Verification code` の行を探します +- 構成したメールバックエンド(Resend または SMTP relay)から認証コードを受け取ります。どちらも構成していない場合は、サーバーコンテナの stdout をローカルで確認し、`[DEV] Verification code` の行をコピーしてください。その行は共有しないでください。 - non-production の非公開インスタンスで `MULTICA_DEV_VERIFICATION_CODE=888888` を明示的に設定した場合を除き、`888888` を使わないでください - ログインして最初のワークスペースを作成します @@ -152,9 +162,42 @@ multica setup self-host これにより CLI が `http://localhost:8080`(バックエンド)と `http://localhost:3000`(フロントエンド)を指すようになり、ブラウザログインを案内し、PAT をローカルに保存して、**デーモンを自動的に起動します**。 -### 5b. マシン間: リバースプロキシを前に置く +### 5b. 信頼済みプライベート LAN: 1つのホスト IP にバインドする + +信頼済みプライベート LAN では、raw Docker ポートを1つのホストインターフェイスに公開できます。これはプライベートネットワーク専用であり、公開用の代替ではありません。 + +`.env` でサーバーの具体的な LAN IP とブラウザ origin を設定します。 + +```bash +BIND_HOST=192.168.1.50 +FRONTEND_ORIGIN=http://192.168.1.50:3000 +CORS_ALLOWED_ORIGINS=http://192.168.1.50:3000 +``` + +再起動前に `JWT_SECRET` と `POSTGRES_PASSWORD` をローテートし、`APP_ENV=production` を維持し、`MULTICA_DEV_VERIFICATION_CODE` を空にし、Resend または SMTP を設定し、bootstrap 後にサインアップ/ワークスペース作成をロックしてください。 + +```bash +ALLOW_SIGNUP=false +DISABLE_WORKSPACE_CREATION=true +``` + +その後、`docker compose -f docker-compose.selfhost.yml up -d` を実行します。preflight はこれらを確認し、シークレット値は出力しません。 + +別の LAN マシンの daemon は次のように設定できます。 + +```bash +multica setup self-host \ + --server-url http://192.168.1.50:8080 \ + --app-url http://192.168.1.50:3000 +``` + + +**Docker volume には機密データが含まれます。** Compose は PostgreSQL 用の `pgdata` とローカルアップロード用の `backend_uploads` を作成します。バックアップ/リストアは一緒に扱い、ホストと Docker daemon へのアクセスを制限し、raw volume archive、`pg_dump`、アップロード tarball を公開共有しないでください。 + -compose スタックは `127.0.0.1` でのみ待ち受けるため、別のマシンにあるデーモンは `http://:8080` に直接接続できません — そして、そうなることを望むべきでもありません。さもなければサーバーのシークレットが公開インターネットから到達可能になってしまうからです。TLS を終端し、`127.0.0.1:8080`(バックエンド)と `127.0.0.1:3000`(フロントエンド)へ転送するリバースプロキシをサーバーに置き、CLI を公開 HTTPS URL に向けてください。 +### 5c. マシン間/公開: リバースプロキシを前に置く + +公開アクセスでは、compose スタックは `127.0.0.1` のままにします。TLS を終端し、`127.0.0.1:8080`(バックエンド)と `127.0.0.1:3000`(フロントエンド)へ転送するリバースプロキシまたは tunnel をサーバーに置き、CLI を公開 HTTPS URL に向けてください。 ```bash multica setup self-host \ @@ -188,9 +231,18 @@ multica.example.com { } ``` -プロキシを立ち上げたら、サーバーの `.env` に `FRONTEND_ORIGIN=https://multica.example.com` を設定してバックエンドを再起動してください — そうしないと WebSocket の origin チェックがブラウザを拒否します([トラブルシューティング → WebSocket が接続できない](/troubleshooting#websocket-cant-connect))。 +プロキシを立ち上げたら、サーバーの `.env` に次を設定してバックエンドを再起動してください。 + +```bash +FRONTEND_ORIGIN=https://multica.example.com +CORS_ALLOWED_ORIGINS=https://multica.example.com +MULTICA_TRUSTED_PROXIES=127.0.0.1/32 +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32 +``` + +`127.0.0.1/32` は実際のプロキシ/CDN CIDR に置き換えてください。`0.0.0.0/0` や `::/0` は使わないでください。origin は HTTP CORS と WebSocket `Origin` チェックの両方に必要です。未設定だと `/ws` は `403` で拒否されます([トラブルシューティング → WebSocket が接続できない](/troubleshooting#websocket-cant-connect))。 -[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) も堅実な選択肢です — ホストにポートを一切公開せずに TLS と公開ホスト名を提供してくれます。Nginx で同等に構成する方法(`app.` / `api.` を別々のホスト名に分離、WebSocket 用の `proxy_set_header Upgrade`)も同じくらいうまく動作します。重要な要件は、TLS の終端と `/ws` での `Upgrade` ヘッダーの転送です。 +[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) も堅実な選択肢です — ホストにポートを一切公開せずに TLS と公開ホスト名を提供してくれます。Nginx で同等に構成する方法(`app.` / `api.` を別々のホスト名に分離、WebSocket 用の `proxy_set_header Upgrade`)も同じくらいうまく動作します。重要な要件は、TLS の終端、`/ws` での `Upgrade` ヘッダー転送、正確な origin allowlist、狭い trusted proxy CIDR です。 ## 6. エージェントの作成 + 最初のタスクの割り当て @@ -267,7 +319,7 @@ multica setup self-host \ ## よくある問題 - **バックエンドが起動しない**: `docker compose -f docker-compose.selfhost.yml logs backend` でコンテナのログを確認してください。たいていは `.env` の不正な `DATABASE_URL` または `JWT_SECRET` が原因です -- **認証コードが届かない**: メールバックエンドが構成されていない場合(Resend も SMTP もない)→ `docker compose logs backend` で `[DEV] Verification code` を探してください +- **認証コードが届かない**: メールバックエンドが構成されていない場合(Resend も SMTP もない)→ 共有可能な provider 選択行を確認してください: `docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"`。verification-code log はローカルでのみ確認してください。 - **WebSocket が接続できない**: 公開デプロイでは、`FRONTEND_ORIGIN` を実際のフロントエンドのドメインに必ず設定する必要があります。[トラブルシューティング → WebSocket が接続できない](/troubleshooting#websocket-wont-connect)を参照してください - **使用量 / ランタイムのダッシュボードがゼロのまま**: `rollup_task_usage_hourly()` がスケジューリングされていません — 上記の [ステップ7](#7-usage-rollup-no-operator-action-required)と[トラブルシューティング → 使用量ダッシュボードがゼロと表示される](/troubleshooting#usage-dashboard-stays-at-zero)を参照してください - **`migrate up` が `refusing to drop legacy daily rollups` で失敗する**: `v0.3.4 → v0.3.5+` のアップグレード経路ガードです。MUL-2957 以降、migrate コマンドは migration 103 を適用する前に自動でバックフィルを実行します — [ステップ7](#7-usage-rollup-no-operator-action-required)を参照してください diff --git a/apps/docs/content/docs/self-host-quickstart.ko.mdx b/apps/docs/content/docs/self-host-quickstart.ko.mdx index 1a6e05bd42..e87dece741 100644 --- a/apps/docs/content/docs/self-host-quickstart.ko.mdx +++ b/apps/docs/content/docs/self-host-quickstart.ko.mdx @@ -49,7 +49,13 @@ make selfhost - **백엔드**: [http://localhost:8080](http://localhost:8080) -**포트는 `127.0.0.1`에서만 수신합니다.** `docker-compose.selfhost.yml`은 공개된 모든 포트를 loopback에 바인딩합니다 — `ss -tlnp`에서는 `0.0.0.0:8080`이 보이지 않으며, 설계상 다른 기기에서는 서비스에 접근할 수 없습니다. 서버 시크릿과 Postgres 자격 증명이 공개 인터넷에 노출되어서는 절대 안 됩니다. 기기 간 접근이 필요하면 TLS를 종료하는 리버스 프록시를 스택 앞에 두세요 — [5b단계 — 기기 간: 리버스 프록시를 앞에 두기](#5b-cross-machine-front-with-a-reverse-proxy)를 참고하세요. +**포트는 기본적으로 loopback에 바인딩됩니다.** `docker-compose.selfhost.yml`은 backend/frontend를 `127.0.0.1`에 게시하므로, 첫 설정 중에는 다른 기기가 raw Docker 포트에 접근할 수 없습니다. 이는 의도된 안전 설계입니다. TLS, 이메일, 정확한 origin, 가입/워크스페이스 정책, 시크릿이 준비되기 전에 공개되는 일을 막습니다. + +지원되는 노출 단계는 세 가지입니다. + +- Localhost: `BIND_HOST=127.0.0.1`을 유지하고 같은 기기에서 `multica setup self-host`를 사용합니다. +- 신뢰할 수 있는 비공개 LAN: `BIND_HOST`를 특정 LAN IP로 설정합니다. `0.0.0.0`은 사용하지 말고, 먼저 시크릿을 교체하고 정확한 브라우저 origin을 설정하세요. +- 공개/기기 간: Docker는 loopback에 둔 채 TLS 리버스 프록시나 tunnel을 앞에 둡니다. ## 2. 중요: 프로덕션 안전 설정 유지하기 @@ -62,6 +68,10 @@ make selfhost 공개 배포 전에는 `.env`에 `APP_ENV=production`이 설정되어 있고 `MULTICA_DEV_VERIFICATION_CODE`가 비어 있는지 반드시 확인하세요. + +**raw Docker 포트를 공개 인터넷에 직접 노출하지 마세요.** `BIND_HOST=0.0.0.0`은 `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1`을 명시하지 않으면 self-host preflight에서 거부됩니다. 이 override는 검토된 비공개 인프라용입니다. 일반적인 공개 경로는 TLS 리버스 프록시나 tunnel, 정확한 origin, 좁은 trusted proxy CIDR입니다. + + ## 3. 이메일 서비스 구성하기(선택 사항이지만 권장) 이메일을 구성하지 않으면 사용자가 이메일로 인증 코드를 받을 수 없으며, 서버가 생성된 코드를 대신 stdout에 출력합니다. @@ -134,7 +144,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com [http://localhost:3000](http://localhost:3000)을 엽니다. - 이메일을 입력합니다 -- 구성한 이메일 백엔드(Resend 또는 SMTP relay)에서 인증 코드를 받습니다. 둘 다 구성하지 않았다면 서버 컨테이너 stdout에서 복사하세요 — `[DEV] Verification code` 줄을 찾으면 됩니다 +- 구성한 이메일 백엔드(Resend 또는 SMTP relay)에서 인증 코드를 받습니다. 둘 다 구성하지 않았다면 서버 컨테이너 stdout을 로컬에서 확인하고 `[DEV] Verification code` 줄을 복사하세요. 이 줄은 공유하지 마세요. - non-production 비공개 인스턴스에서 `MULTICA_DEV_VERIFICATION_CODE=888888`을 명시적으로 설정한 경우가 아니라면 `888888`을 사용하지 마세요 - 로그인하고 첫 워크스페이스를 생성합니다 @@ -152,9 +162,42 @@ multica setup self-host 이렇게 하면 CLI가 `http://localhost:8080`(백엔드)과 `http://localhost:3000`(프런트엔드)을 가리키고, 브라우저 로그인을 안내하며, PAT를 로컬에 저장하고, **데몬을 자동으로 시작합니다**. -### 5b. 기기 간: 리버스 프록시를 앞에 두기 +### 5b. 신뢰할 수 있는 비공개 LAN: 하나의 호스트 IP에 바인딩 + +신뢰할 수 있는 비공개 LAN에서는 raw Docker 포트를 하나의 호스트 인터페이스에 게시할 수 있습니다. 이는 비공개 네트워크 전용이며 공개용 대체 경로가 아닙니다. + +`.env`에서 서버의 구체적인 LAN IP와 브라우저 origin을 설정하세요. + +```bash +BIND_HOST=192.168.1.50 +FRONTEND_ORIGIN=http://192.168.1.50:3000 +CORS_ALLOWED_ORIGINS=http://192.168.1.50:3000 +``` + +재시작 전에 `JWT_SECRET`과 `POSTGRES_PASSWORD`를 교체하고, `APP_ENV=production`을 유지하며, `MULTICA_DEV_VERIFICATION_CODE`를 비우고, Resend 또는 SMTP를 구성하고, bootstrap 후 가입/워크스페이스 생성을 잠그세요. + +```bash +ALLOW_SIGNUP=false +DISABLE_WORKSPACE_CREATION=true +``` + +그런 다음 `docker compose -f docker-compose.selfhost.yml up -d`를 실행합니다. preflight는 이를 확인하며 시크릿 값은 출력하지 않습니다. + +다른 LAN 기기의 daemon은 다음처럼 설정할 수 있습니다. + +```bash +multica setup self-host \ + --server-url http://192.168.1.50:8080 \ + --app-url http://192.168.1.50:3000 +``` + + +**Docker volume에는 민감한 데이터가 들어 있습니다.** Compose는 PostgreSQL용 `pgdata`와 로컬 업로드용 `backend_uploads`를 만듭니다. 백업/복원은 함께 처리하고, 호스트와 Docker daemon 접근을 제한하며, raw volume archive, `pg_dump`, 업로드 tarball을 공개 공유하지 마세요. + -compose 스택은 `127.0.0.1`에서만 수신하므로, 다른 기기에 있는 데몬은 `http://:8080`에 직접 연결할 수 없습니다 — 그리고 그렇게 되기를 원해서도 안 됩니다. 그렇지 않으면 서버 시크릿이 공개 인터넷에서 접근 가능해지기 때문입니다. 서버에 TLS를 종료하고 `127.0.0.1:8080`(백엔드)과 `127.0.0.1:3000`(프런트엔드)으로 전달하는 리버스 프록시를 두고, CLI를 공개 HTTPS URL로 연결하세요. +### 5c. 기기 간/공개: 리버스 프록시를 앞에 두기 + +공개 접근에서는 compose 스택을 `127.0.0.1`에 둡니다. 서버에 TLS를 종료하고 `127.0.0.1:8080`(백엔드)과 `127.0.0.1:3000`(프런트엔드)으로 전달하는 리버스 프록시나 tunnel을 두고, CLI를 공개 HTTPS URL로 연결하세요. ```bash multica setup self-host \ @@ -188,9 +231,18 @@ multica.example.com { } ``` -프록시를 올린 후에는 서버의 `.env`에 `FRONTEND_ORIGIN=https://multica.example.com`을 설정하고 백엔드를 재시작하세요 — 그렇지 않으면 WebSocket origin 검사가 브라우저를 거부합니다([문제 해결 → WebSocket이 연결되지 않음](/troubleshooting#websocket-cant-connect)). +프록시를 올린 후에는 서버의 `.env`에 다음을 설정하고 백엔드를 재시작하세요. + +```bash +FRONTEND_ORIGIN=https://multica.example.com +CORS_ALLOWED_ORIGINS=https://multica.example.com +MULTICA_TRUSTED_PROXIES=127.0.0.1/32 +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32 +``` + +`127.0.0.1/32`는 실제 프록시/CDN CIDR로 바꾸세요. `0.0.0.0/0` 또는 `::/0`은 사용하지 마세요. origin 설정은 HTTP CORS와 WebSocket `Origin` 검사 모두에 필요합니다. 없으면 `/ws`가 `403`으로 거부됩니다([문제 해결 → WebSocket이 연결되지 않음](/troubleshooting#websocket-cant-connect)). -[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)도 견고한 선택지입니다 — 호스트에 어떤 포트도 노출하지 않고도 TLS와 공개 호스트네임을 제공합니다. Nginx로 동등하게 구성하는 방법(`app.` / `api.`을 별도 호스트네임으로 분리, WebSocket용 `proxy_set_header Upgrade`)도 똑같이 잘 동작합니다. 핵심 요구 사항은 TLS 종료와 `/ws`에서의 `Upgrade` 헤더 전달입니다. +[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)도 견고한 선택지입니다 — 호스트에 어떤 포트도 노출하지 않고도 TLS와 공개 호스트네임을 제공합니다. Nginx로 동등하게 구성하는 방법(`app.` / `api.`을 별도 호스트네임으로 분리, WebSocket용 `proxy_set_header Upgrade`)도 똑같이 잘 동작합니다. 핵심 요구 사항은 TLS 종료, `/ws`에서의 `Upgrade` 헤더 전달, 정확한 origin allowlist, 좁은 trusted proxy CIDR입니다. ## 6. 에이전트 생성 + 첫 작업 할당 @@ -267,7 +319,7 @@ multica setup self-host \ ## 자주 발생하는 문제 - **백엔드가 시작되지 않음**: `docker compose -f docker-compose.selfhost.yml logs backend`로 컨테이너 로그를 확인하세요. 보통 `.env`의 잘못된 `DATABASE_URL` 또는 `JWT_SECRET`이 원인입니다 -- **인증 코드를 받지 못함**: 이메일 백엔드가 구성되지 않은 경우(Resend도 SMTP도 없음) → `docker compose logs backend`에서 `[DEV] Verification code`를 찾으세요 +- **인증 코드를 받지 못함**: 이메일 백엔드가 구성되지 않은 경우(Resend도 SMTP도 없음) → 공유 가능한 provider 선택 줄을 확인하세요: `docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"`; verification-code log는 로컬에서만 확인하세요. - **WebSocket이 연결되지 않음**: 공개 배포에서는 반드시 `FRONTEND_ORIGIN`을 실제 프런트엔드 도메인으로 설정해야 합니다. [문제 해결 → WebSocket이 연결되지 않음](/troubleshooting#websocket-wont-connect)을 참고하세요 - **사용량 / 런타임 대시보드가 0에 머무름**: `rollup_task_usage_hourly()`가 스케줄링되지 않고 있습니다 — 위의 [7단계](#7-usage-rollup-no-operator-action-required)와 [문제 해결 → 사용량 대시보드가 0으로 표시됨](/troubleshooting#usage-dashboard-stays-at-zero)을 참고하세요 - **`migrate up`이 `refusing to drop legacy daily rollups`로 실패함**: `v0.3.4 → v0.3.5+` 업그레이드 경로 가드입니다. MUL-2957부터 migrate 명령이 migration 103을 적용하기 전에 백필을 자동으로 실행합니다 — [7단계](#7-usage-rollup-no-operator-action-required)를 참고하세요 diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx index a72a196ac1..648a2feafa 100644 --- a/apps/docs/content/docs/self-host-quickstart.mdx +++ b/apps/docs/content/docs/self-host-quickstart.mdx @@ -50,7 +50,13 @@ Once it's up: - **Backend**: [http://localhost:8080](http://localhost:8080) -**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. Secrets and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy). +**Ports bind to loopback by default.** `docker-compose.selfhost.yml` publishes backend/frontend ports on `127.0.0.1`, so other machines cannot reach raw Docker ports during first setup. This is intentional: the first-run stack may still have open signup/workspace creation, no email provider, no TLS, and example secrets if you bypass `make selfhost`. + +There are three supported exposure tiers: + +- Localhost: keep `BIND_HOST=127.0.0.1` and use `multica setup self-host` on the same machine. +- Trusted private LAN: set `BIND_HOST` to one specific LAN IP, never `0.0.0.0`, after rotating secrets and setting the exact browser origin. +- Public or cross-machine: keep Docker bound to loopback and put a TLS reverse proxy or tunnel in front. ## 2. Important: keep production safety on @@ -63,6 +69,10 @@ Only set `MULTICA_DEV_VERIFICATION_CODE` for local or private test automation. I Before any public deployment, make sure `.env` has `APP_ENV=production` and `MULTICA_DEV_VERIFICATION_CODE` is empty. + +**Do not expose raw Docker ports publicly.** `BIND_HOST=0.0.0.0` is refused by the self-host preflight unless you set `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1`. That override is for reviewed, private infrastructure only; the recommended public path is a reverse proxy or tunnel with TLS, exact origins, and narrow trusted proxy CIDRs. + + ## 3. Configure the email service (optional but recommended) Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead. @@ -135,7 +145,7 @@ For more auth configuration (OAuth, signup allowlist) and the full SMTP variable Open [http://localhost:3000](http://localhost:3000): - Enter your email -- Grab the verification code from your configured email backend (Resend or SMTP relay); if neither is configured, copy it from the server container stdout — look for the `[DEV] Verification code` line +- Grab the verification code from your configured email backend (Resend or SMTP relay); if neither is configured, inspect the server container stdout locally and copy the `[DEV] Verification code` line. Do not share that line. - Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance - Log in and create your first workspace @@ -153,9 +163,42 @@ multica setup self-host That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**. -### 5b. Cross-machine: front with a reverse proxy +### 5b. Trusted private LAN: bind one host IP + +For a trusted private LAN, you may publish raw Docker ports on a single host interface. This is for private networks only; it is not a replacement for TLS or a public reverse proxy. + +In `.env`, set the host's specific LAN IP and the browser origin that users will open: + +```bash +BIND_HOST=192.168.1.50 +FRONTEND_ORIGIN=http://192.168.1.50:3000 +CORS_ALLOWED_ORIGINS=http://192.168.1.50:3000 +``` + +Before restarting, also rotate `JWT_SECRET` and `POSTGRES_PASSWORD`, keep `APP_ENV=production`, leave `MULTICA_DEV_VERIFICATION_CODE` empty, configure Resend or SMTP, and lock down signup/workspace creation after bootstrap: + +```bash +ALLOW_SIGNUP=false +DISABLE_WORKSPACE_CREATION=true +``` + +Then run `docker compose -f docker-compose.selfhost.yml up -d`. The preflight checks these settings and fails without printing secret values. + +For daemon setup from another LAN machine: + +```bash +multica setup self-host \ + --server-url http://192.168.1.50:8080 \ + --app-url http://192.168.1.50:3000 +``` + + +**Docker volumes contain sensitive data.** Compose creates `pgdata` for PostgreSQL and `backend_uploads` for local attachment files. Back them up and restore them together, restrict host/Docker daemon access, and do not share raw volume archives, `pg_dump` output, or upload tarballs publicly. + -Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://:8080` directly — and you do not want it to, since server secrets would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL: +### 5c. Cross-machine/public: front with a reverse proxy + +For public access, keep the compose stack listening on `127.0.0.1`. Put a reverse proxy or tunnel on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the HTTPS URL: ```bash multica setup self-host \ @@ -189,9 +232,18 @@ multica.example.com { } ``` -After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)). +After bringing the proxy up, set these in the server's `.env` and restart the backend: + +```bash +FRONTEND_ORIGIN=https://multica.example.com +CORS_ALLOWED_ORIGINS=https://multica.example.com +MULTICA_TRUSTED_PROXIES=127.0.0.1/32 +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32 +``` + +Use the actual proxy/CDN CIDRs for your deployment; do not use `0.0.0.0/0` or `::/0`. The origin settings are required because the backend validates both HTTP CORS and the WebSocket `Origin` header. Without them, `/ws` upgrades are rejected with `403` ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)). -[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`. +[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination, forwarding the `Upgrade` header on `/ws`, exact origin allowlists, and narrow trusted proxy CIDRs. ## 6. Create an agent + assign your first task @@ -266,7 +318,7 @@ The full reference — three login modes, the `backend` ExternalName workaround ## Common issues - **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env` -- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend` +- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → check the safe provider-selection line with `docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"`; only inspect verification-code log lines locally - **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect) - **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-usage-rollup-no-operator-action-required) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero) - **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. As of MUL-2957 the migrate command runs the backfill automatically before applying migration 103 — see [Step 7](#7-usage-rollup-no-operator-action-required) diff --git a/apps/docs/content/docs/self-host-quickstart.zh.mdx b/apps/docs/content/docs/self-host-quickstart.zh.mdx index 35bf4e2921..f0ca378379 100644 --- a/apps/docs/content/docs/self-host-quickstart.zh.mdx +++ b/apps/docs/content/docs/self-host-quickstart.zh.mdx @@ -49,7 +49,13 @@ make selfhost - **后端**:[http://localhost:8080](http://localhost:8080) -**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免服务密钥和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。 +**端口默认绑定到 loopback。** `docker-compose.selfhost.yml` 默认把 backend/frontend 发布到 `127.0.0.1`,所以首次启动时其它机器连不到 raw Docker 端口。这是有意的安全默认值:初次配置时可能还没配 TLS、邮件、精确 origin、注册/工作区策略,或者手动绕过 `make selfhost` 后仍在用示例密钥。 + +三种暴露方式: + +- 本机:保留 `BIND_HOST=127.0.0.1`,在同一台机器上跑 `multica setup self-host`。 +- 可信私有 LAN:把 `BIND_HOST` 设成一个具体 LAN IP,不要用 `0.0.0.0`,并先轮换密钥、设置精确浏览器 origin。 +- 公网/跨机:保持 Docker 绑定 loopback,在前面放 TLS 反向代理或 tunnel。 ## 2. 重要:保持生产安全配置 @@ -62,6 +68,10 @@ make selfhost 公网部署前一定检查 `.env` 里 `APP_ENV=production`,且 `MULTICA_DEV_VERIFICATION_CODE` 为空。 + +**不要把 raw Docker 端口直接暴露到公网。** `BIND_HOST=0.0.0.0` 会被 self-host preflight 拒绝,除非显式设置 `MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1`。这个 override 只适合经过安全评审的私有基础设施;公网推荐路径是 TLS 反向代理或 tunnel,并配置精确 origin 与窄 trusted proxy CIDR。 + + ## 3. 配置邮件服务(可选但推荐) 如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。 @@ -134,7 +144,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com 打开 [http://localhost:3000](http://localhost:3000): - 输入你的邮箱 -- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行 +- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,只在本机查看 server 容器 stdout 并复制 `[DEV] Verification code` 这行,不要把这行贴出去 - 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效 - 登录后创建第一个工作区 @@ -152,9 +162,42 @@ multica setup self-host 会自动连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend),引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**。 -### 5b. 跨机访问:用反向代理把服务挡在前面 +### 5b. 可信私有 LAN:绑定一个主机 IP + +可信私有 LAN 可以把 raw Docker 端口发布到一个具体主机网卡上,但这只适合私网,不是公网替代方案。 + +在 `.env` 里设置 server 的具体 LAN IP 和用户浏览器打开的 origin: + +```bash +BIND_HOST=192.168.1.50 +FRONTEND_ORIGIN=http://192.168.1.50:3000 +CORS_ALLOWED_ORIGINS=http://192.168.1.50:3000 +``` + +重启前还要轮换 `JWT_SECRET` 和 `POSTGRES_PASSWORD`,保持 `APP_ENV=production`,清空 `MULTICA_DEV_VERIFICATION_CODE`,配置 Resend 或 SMTP,并在引导完成后锁住注册/工作区创建: + +```bash +ALLOW_SIGNUP=false +DISABLE_WORKSPACE_CREATION=true +``` + +然后运行 `docker compose -f docker-compose.selfhost.yml up -d`。preflight 会检查这些项,并且不会打印真实 secret。 + +其它 LAN 机器上的 daemon 可以这样配置: + +```bash +multica setup self-host \ + --server-url http://192.168.1.50:8080 \ + --app-url http://192.168.1.50:3000 +``` + + +**Docker volume 里有敏感数据。** Compose 创建的 `pgdata` 存 PostgreSQL 数据,`backend_uploads` 存本地上传文件。备份/恢复时一起处理,限制 host 和 Docker daemon 权限,不要公开分享原始 volume 归档、`pg_dump` 或上传文件 tarball。 + -因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://:8080` 的——这也是有意为之,否则服务密钥会直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名: +### 5c. 跨机/公网访问:用反向代理把服务挡在前面 + +公网访问时保持 compose 只监听 `127.0.0.1`。在 server 上跑一个反向代理或 tunnel(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名: ```bash multica setup self-host \ @@ -188,9 +231,18 @@ multica.example.com { } ``` -代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。 +代理起好之后,记得在 server 的 `.env` 里设置并重启后端: + +```bash +FRONTEND_ORIGIN=https://multica.example.com +CORS_ALLOWED_ORIGINS=https://multica.example.com +MULTICA_TRUSTED_PROXIES=127.0.0.1/32 +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1/32 +``` + +把 `127.0.0.1/32` 换成你的实际代理/CDN CIDR;不要用 `0.0.0.0/0` 或 `::/0`。origin 配置同时用于 HTTP CORS 和 WebSocket `Origin` 校验,否则 `/ws` 会被 `403` 拒绝(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。 -[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket),关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。 +[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket),关键就是终结 TLS、在 `/ws` 上转发 `Upgrade` 头、精确 origin allowlist,以及窄 trusted proxy CIDR。 ## 6. 创建智能体 + 分配第一个任务 @@ -267,7 +319,7 @@ multica setup self-host \ ## 常见问题 - **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题 -- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code` +- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 先看可分享的 provider 选择行:`docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"`;真实验证码日志只在本机查看,不要贴出去 - **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上) - **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-usage-rollup-no-operator-action-required) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0) - **`migrate up` 报 `refusing to drop legacy daily rollups`**:`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。从 MUL-2957 起 migrate 命令在应用 migration 103 之前会自动跑 backfill —— 见 [第 7 步](#7-usage-rollup-no-operator-action-required) diff --git a/apps/docs/content/docs/troubleshooting.ja.mdx b/apps/docs/content/docs/troubleshooting.ja.mdx index 98d95c91e4..ed903ffc61 100644 --- a/apps/docs/content/docs/troubleshooting.ja.mdx +++ b/apps/docs/content/docs/troubleshooting.ja.mdx @@ -23,15 +23,43 @@ import { Callout } from "fumadocs-ui/components/callout"; ```bash multica daemon logs --lines 100 # look for daemon-side errors -echo $MULTICA_SERVER_URL # confirm the address is set curl -i http://:8080/health # hit the server directly curl -i http://:8080/readyz # include DB + migration readiness -cat ~/.multica/config.json # verify api_token exists multica workspace list # confirm you're a member of the target workspace ``` +共有してよいものは、`/health` と `/readyz` のステータス、redact 済みの daemon エラー断片、意図した hostname/port です。`~/.multica/config.json`、PAT、daemon token、redact していない完全な daemon ログは共有しないでください。 + **解決方法**: 上記の各原因を 1 つずつ対処してください。最もよくある 2 つの解決策は、**`MULTICA_SERVER_URL` を変更してデーモンを再起動する**こと(`multica daemon restart`)と、**ログインし直す**こと(`multica logout && multica login`)です。 +## Self-host preflight が起動を止める + +**症状**: `make selfhost`、`make selfhost-build`、または Unix installer が Docker 起動前に `self-host preflight failed` で終了します。 + +**主な原因**: + +1. **raw all-interface bind** — `BIND_HOST=0.0.0.0` は backend/frontend Docker ポートを全インターフェイスに公開します +2. **example secrets** — `JWT_SECRET`、`POSTGRES_PASSWORD`、または `DATABASE_URL` がまだ例の値です +3. **LAN/公開前の hardening 不足** — `APP_ENV` が `production` ではない、固定 dev code が設定されている、メール provider がない、signup/workspace creation が開いたまま、または origin が localhost のままです +4. **unsafe proxy trust** — `MULTICA_TRUSTED_PROXIES` または `RATE_LIMIT_TRUSTED_PROXIES` が `0.0.0.0/0` のような広すぎる範囲です + +**診断**: + +```bash +bash scripts/selfhost-preflight.sh .env +grep -E '^(BIND_HOST|APP_ENV|FRONTEND_ORIGIN|CORS_ALLOWED_ORIGINS|ALLOW_SIGNUP|DISABLE_WORKSPACE_CREATION)=' .env +``` + +preflight は問題の変数名だけを出し、secret 値は出しません。上の `grep` はローカルでのみ使ってください。完全な `.env`、`docker compose config`、container env、PAT/config ファイル、完全なログを support thread に貼らないでください。 + +**修正**: + +- Localhost → `BIND_HOST=127.0.0.1` のままにします。 +- 信頼済みプライベート LAN → `BIND_HOST` を特定の LAN IP にし、正確な `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` を設定します。 +- 公開アクセス → Docker は loopback に置き、TLS リバースプロキシまたは tunnel を使います。`/ws` は WebSocket `Upgrade` header を転送してください。 +- `JWT_SECRET` と `POSTGRES_PASSWORD` をローテートし、`DATABASE_URL` を同期し、Resend または SMTP を設定し、`MULTICA_DEV_VERIFICATION_CODE` を空にし、bootstrap 後に `ALLOW_SIGNUP=false` と `DISABLE_WORKSPACE_CREATION=true` を設定します。 +- `MULTICA_TRUSTED_PROXIES` と `RATE_LIMIT_TRUSTED_PROXIES` には正確な proxy/CDN CIDR だけを使い、`0.0.0.0/0` や `::/0` は使わないでください。 + ## タスクが `queued` で止まる **症状**: エージェントにイシューを割り当てた後、イシューの状態はすぐに `in_progress` に変わりますが、長時間経ってもページにエージェント実行の兆候が見えません。`multica daemon status` はデーモンを `online` と表示しています。 @@ -99,7 +127,7 @@ multica issue show # inspect task history docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:" ``` -期待していた行が見当たらない場合は、環境変数がプロセスに届いていません。`.env` と `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'` を確認してください。この起動ログの行には認証情報は一切記録されません。 +この provider 選択行は共有して構いません。provider と relay host/port は含みますが、API key、SMTP password、verification code は含みません。期待した行がない場合は、`.env` をローカルで確認して backend を再起動してください。実際の `.env`、`docker compose config`、container env 出力は貼らないでください。 ### Resend がアクティブなプロバイダーの場合 @@ -112,7 +140,7 @@ docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService: **診断方法**: -- サーバーログで `"[DEV] Verification code for"` を grep してください — これがある場合、Resend が構成されておらず、コードが stdout に書き込まれたことを意味します +- ローカルのサーバーログで `"[DEV] Verification code for"` を grep してください — これがある場合、Resend が構成されておらず、コードが stdout に書き込まれています。コード行は共有しないでください。 - [Resend ダッシュボード](https://resend.com/) → Emails で送信履歴を確認してください - `RESEND_FROM_EMAIL` のドメインが Resend コンソールの「Verified Domains」リストに表示されるか確認してください @@ -140,8 +168,8 @@ SMTP の経路はすべての失敗を失敗した段階とともにラップす **診断方法**: - 起動時に `"EmailService: SMTP relay"` を一度 grep し、ランタイムの失敗については `"failed to send"` を grep してください -- バックエンドコンテナ内部から接続性を点検してください: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'` -- 環境変数がプロセスに届いたか確認してください: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(出力にパスワードが含まれるため、信頼できるシェルでのみ実行してください) +- バックエンドコンテナ内部から接続性を点検してください: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz "$SMTP_HOST" "$SMTP_PORT"'` +- provider 選択は上の startup line で確認してください。どうしても環境変数を見る場合は信頼済み shell でローカルに限り、出力は共有しないでください。SMTP password や API key が含まれる可能性があります。 **解決方法**: @@ -163,10 +191,11 @@ SMTP の経路はすべての失敗を失敗した段階とともにラップす **診断方法**: ```bash -cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' -docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' +grep -E '^(APP_ENV|MULTICA_DEV_VERIFICATION_CODE)=' .env ``` +これはローカル確認専用です。実際の `.env` や container env dump は共有しないでください。API key、password、token、固定 verification code が含まれる可能性があります。 + インボックス(スパムを含む)で実際の認証コードを確認してください。 **解決方法**: diff --git a/apps/docs/content/docs/troubleshooting.ko.mdx b/apps/docs/content/docs/troubleshooting.ko.mdx index e089db49c6..e9ba5bbe1c 100644 --- a/apps/docs/content/docs/troubleshooting.ko.mdx +++ b/apps/docs/content/docs/troubleshooting.ko.mdx @@ -23,15 +23,43 @@ import { Callout } from "fumadocs-ui/components/callout"; ```bash multica daemon logs --lines 100 # look for daemon-side errors -echo $MULTICA_SERVER_URL # confirm the address is set curl -i http://:8080/health # hit the server directly curl -i http://:8080/readyz # include DB + migration readiness -cat ~/.multica/config.json # verify api_token exists multica workspace list # confirm you're a member of the target workspace ``` +공유해도 되는 것은 `/health`와 `/readyz` 상태, redact된 daemon 오류 일부, 의도한 hostname/port입니다. `~/.multica/config.json`, PAT, daemon token, redact하지 않은 전체 daemon 로그는 공유하지 마세요. + **해결 방법**: 위의 각 원인을 하나씩 처리하세요. 가장 흔한 두 가지 해결책은 **`MULTICA_SERVER_URL`을 변경하고 데몬을 재시작**하는 것(`multica daemon restart`)과 **다시 로그인**하는 것(`multica logout && multica login`)입니다. +## Self-host preflight가 시작을 막음 + +**증상**: `make selfhost`, `make selfhost-build`, 또는 Unix installer가 Docker 시작 전에 `self-host preflight failed`로 종료됩니다. + +**가능한 원인**: + +1. **raw all-interface bind** — `BIND_HOST=0.0.0.0`은 backend/frontend Docker 포트를 모든 인터페이스에 게시합니다 +2. **example secrets** — `JWT_SECRET`, `POSTGRES_PASSWORD`, 또는 `DATABASE_URL`이 아직 예시 값입니다 +3. **LAN/공개 접근 전 hardening 부족** — `APP_ENV`가 `production`이 아니거나, 고정 dev code가 설정되어 있거나, 이메일 provider가 없거나, signup/workspace creation이 열려 있거나, origin이 localhost로 남아 있습니다 +4. **unsafe proxy trust** — `MULTICA_TRUSTED_PROXIES` 또는 `RATE_LIMIT_TRUSTED_PROXIES`가 `0.0.0.0/0` 같은 넓은 범위입니다 + +**진단 방법**: + +```bash +bash scripts/selfhost-preflight.sh .env +grep -E '^(BIND_HOST|APP_ENV|FRONTEND_ORIGIN|CORS_ALLOWED_ORIGINS|ALLOW_SIGNUP|DISABLE_WORKSPACE_CREATION)=' .env +``` + +preflight는 실패한 변수 이름만 출력하고 secret 값은 출력하지 않습니다. 위 `grep`은 로컬에서만 사용하세요. 전체 `.env`, `docker compose config`, container env, PAT/config 파일, 전체 로그를 support thread에 붙이지 마세요. + +**해결 방법**: + +- Localhost → `BIND_HOST=127.0.0.1`을 유지합니다. +- 신뢰할 수 있는 비공개 LAN → `BIND_HOST`를 특정 LAN IP로 설정하고 정확한 `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS`를 설정합니다. +- 공개 접근 → Docker는 loopback에 두고 TLS 리버스 프록시나 tunnel을 사용합니다. `/ws`는 WebSocket `Upgrade` header를 전달해야 합니다. +- `JWT_SECRET`과 `POSTGRES_PASSWORD`를 교체하고, `DATABASE_URL`을 동기화하고, Resend 또는 SMTP를 구성하고, `MULTICA_DEV_VERIFICATION_CODE`를 비우고, bootstrap 후 `ALLOW_SIGNUP=false`와 `DISABLE_WORKSPACE_CREATION=true`를 설정합니다. +- `MULTICA_TRUSTED_PROXIES`와 `RATE_LIMIT_TRUSTED_PROXIES`에는 정확한 proxy/CDN CIDR만 사용하고, `0.0.0.0/0` 또는 `::/0`은 사용하지 마세요. + ## 작업이 `queued`에서 멈춤 **증상**: 에이전트에게 이슈를 할당한 뒤 이슈 상태가 곧바로 `in_progress`로 바뀌지만, 오랜 시간이 지나도 페이지에 에이전트 실행의 흔적이 보이지 않습니다. `multica daemon status`는 데몬을 `online`으로 표시합니다. @@ -99,7 +127,7 @@ multica issue show # inspect task history docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:" ``` -기대했던 줄이 보이지 않는다면 환경 변수가 프로세스에 도달하지 않은 것입니다 — `.env`와 `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`를 확인하세요. 이 시작 로그 줄에는 자격 증명이 절대 기록되지 않습니다. +이 provider 선택 줄은 공유해도 됩니다. provider와 relay host/port는 포함하지만 API key, SMTP password, verification code는 포함하지 않습니다. 기대한 줄이 없다면 `.env`를 로컬에서 확인하고 backend를 재시작하세요. 실제 `.env`, `docker compose config`, container env 출력은 붙이지 마세요. ### Resend가 활성 제공자일 때 @@ -112,7 +140,7 @@ docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService: **진단 방법**: -- 서버 로그에서 `"[DEV] Verification code for"`를 grep하세요 — 있다면 Resend가 구성되지 않았고 코드가 stdout에 기록된 것입니다 +- 로컬 서버 로그에서만 `"[DEV] Verification code for"`를 grep하세요 — 있다면 Resend가 구성되지 않았고 코드가 stdout에 기록된 것입니다. 코드 줄은 공유하지 마세요. - [Resend 대시보드](https://resend.com/) → Emails에서 발송 이력을 확인하세요 - `RESEND_FROM_EMAIL`의 도메인이 Resend 콘솔의 "Verified Domains" 목록에 나타나는지 확인하세요 @@ -140,8 +168,8 @@ SMTP 경로는 모든 실패를 실패한 단계와 함께 감싸므로, 서버 **진단 방법**: - 시작 시 `"EmailService: SMTP relay"`를 한 번 grep하고, 런타임 실패에 대해서는 `"failed to send"`를 grep하세요 -- 백엔드 컨테이너 내부에서 연결성을 점검하세요: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'` -- 환경 변수가 프로세스에 도달했는지 확인하세요: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(출력에 비밀번호가 포함되므로 신뢰할 수 있는 셸에서만 실행하세요) +- 백엔드 컨테이너 내부에서 연결성을 점검하세요: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz "$SMTP_HOST" "$SMTP_PORT"'` +- provider 선택은 위 startup line으로 확인하세요. 꼭 환경 변수를 봐야 한다면 신뢰할 수 있는 셸에서 로컬로만 확인하고 출력을 공유하지 마세요. SMTP password나 API key가 포함될 수 있습니다. **해결 방법**: @@ -163,10 +191,11 @@ SMTP 경로는 모든 실패를 실패한 단계와 함께 감싸므로, 서버 **진단 방법**: ```bash -cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' -docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' +grep -E '^(APP_ENV|MULTICA_DEV_VERIFICATION_CODE)=' .env ``` +이 확인은 로컬 전용입니다. 실제 `.env`나 container env dump를 공유하지 마세요. API key, password, token, 고정 verification code가 포함될 수 있습니다. + 인박스(스팸 포함)에서 실제 인증 코드를 확인하세요. **해결 방법**: diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index d20be1d4da..e582d4920a 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -23,15 +23,43 @@ Look up issues by symptom. Each entry gives you **symptom / likely causes / how ```bash multica daemon logs --lines 100 # look for daemon-side errors -echo $MULTICA_SERVER_URL # confirm the address is set curl -i http://:8080/health # hit the server directly curl -i http://:8080/readyz # include DB + migration readiness -cat ~/.multica/config.json # verify api_token exists multica workspace list # confirm you're a member of the target workspace ``` +Safe to share: the `/health` and `/readyz` status bodies, redacted daemon error snippets, and the hostname/port you intended to use. Do not share `~/.multica/config.json`, PATs, daemon tokens, or full daemon logs without redaction. + **How to fix**: address each cause above. The two most common fixes are **changing `MULTICA_SERVER_URL` and restarting the daemon** (`multica daemon restart`) and **signing in again** (`multica logout && multica login`). +## Self-host preflight blocks startup + +**Symptom**: `make selfhost`, `make selfhost-build`, or the Unix installer exits before Docker starts with `self-host preflight failed`. + +**Likely causes**: + +1. **Raw all-interface bind** — `BIND_HOST=0.0.0.0` would publish backend/frontend Docker ports on every interface +2. **Example secrets** — `JWT_SECRET`, `POSTGRES_PASSWORD`, or `DATABASE_URL` still use example values +3. **Public/LAN access without production hardening** — `APP_ENV` is not `production`, fixed dev code is set, no email provider is configured, signup/workspace creation is still open, or origins are still localhost-only +4. **Unsafe proxy trust** — `MULTICA_TRUSTED_PROXIES` or `RATE_LIMIT_TRUSTED_PROXIES` uses a broad range such as `0.0.0.0/0` + +**How to diagnose**: + +```bash +bash scripts/selfhost-preflight.sh .env +grep -E '^(BIND_HOST|APP_ENV|FRONTEND_ORIGIN|CORS_ALLOWED_ORIGINS|ALLOW_SIGNUP|DISABLE_WORKSPACE_CREATION)=' .env +``` + +The preflight names the failing variables but does not print secret values. The `grep` command above is safe to use locally; do not paste a full `.env`, `docker compose config`, container env output, PAT/config files, or full logs into support threads. + +**How to fix**: + +- Localhost → keep `BIND_HOST=127.0.0.1`. +- Trusted private LAN → set `BIND_HOST` to one specific LAN IP and set `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` to the exact LAN browser origin. +- Public access → keep Docker bound to loopback and use a TLS reverse proxy or tunnel. Forward `/ws` with WebSocket `Upgrade` headers. +- Rotate `JWT_SECRET` and `POSTGRES_PASSWORD`, keep `DATABASE_URL` in sync, configure Resend or SMTP, leave `MULTICA_DEV_VERIFICATION_CODE` empty, and set `ALLOW_SIGNUP=false` plus `DISABLE_WORKSPACE_CREATION=true` after bootstrapping users/workspace. +- Use exact proxy/CDN CIDRs for `MULTICA_TRUSTED_PROXIES` and `RATE_LIMIT_TRUSTED_PROXIES`; never `0.0.0.0/0` or `::/0`. + ## Tasks stuck in `queued` **Symptom**: after assigning an issue to an agent, the issue status flips to `in_progress` immediately, but a long time passes with no sign of agent execution on the page; `multica daemon status` shows the daemon `online`. @@ -99,7 +127,9 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:" ``` -If the line you expected isn't there, the environment didn't reach the process — check `.env` and `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`. Credentials are never logged on this startup line. +This provider-selection line is safe to share. It names the provider and relay host/port, but it does not include API keys, SMTP passwords, or verification codes. + +If the line you expected isn't there, inspect `.env` locally and restart the backend. Do not paste the real `.env`, `docker compose config`, or container environment output into an issue or chat. ### When Resend is the active provider @@ -112,7 +142,7 @@ If the line you expected isn't there, the environment didn't reach the process **How to diagnose**: -- Grep server logs for `"[DEV] Verification code for"` — if present, Resend isn't configured and the code was written to stdout +- Grep server logs locally for `"[DEV] Verification code for"` — if present, Resend isn't configured and the code was written to stdout; do not share the code line - [Resend dashboard](https://resend.com/) → Emails for send history - Confirm `RESEND_FROM_EMAIL`'s domain appears in the Resend console's "Verified Domains" list @@ -140,8 +170,8 @@ The SMTP path wraps every failure with the stage it failed at, so the server log **How to diagnose**: - Grep `"EmailService: SMTP relay"` once at startup, then `"failed to send"` for runtime failures -- From inside the backend container, sanity-check connectivity: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'` -- Confirm the env reached the process: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_` (password will be in the output — only run on a trusted shell) +- From inside the backend container, sanity-check connectivity: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz "$SMTP_HOST" "$SMTP_PORT"'` +- Confirm the provider selection with the startup line above. If you must inspect environment values, do it only on a trusted shell and do not share the output; it may contain SMTP passwords or API keys. **How to fix**: @@ -163,10 +193,11 @@ The SMTP path wraps every failure with the stage it failed at, so the server log **How to diagnose**: ```bash -cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' -docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' +grep -E '^(APP_ENV|MULTICA_DEV_VERIFICATION_CODE)=' .env ``` +This check is for your local shell only. Do not paste a real `.env` or container environment dump; env output can include API keys, passwords, tokens, and fixed verification codes. + Check your inbox (including spam) for the real verification code. **How to fix**: diff --git a/apps/docs/content/docs/troubleshooting.zh.mdx b/apps/docs/content/docs/troubleshooting.zh.mdx index 22b929b82f..4df046be2c 100644 --- a/apps/docs/content/docs/troubleshooting.zh.mdx +++ b/apps/docs/content/docs/troubleshooting.zh.mdx @@ -23,15 +23,43 @@ import { Callout } from "fumadocs-ui/components/callout"; ```bash multica daemon logs --lines 100 # 看 daemon 侧错误 -echo $MULTICA_SERVER_URL # 确认地址配对 curl -i http://:8080/health # 直接戳 server curl -i http://:8080/readyz # 连同 DB + migration readiness 一起检查 -cat ~/.multica/config.json # 看 api_token 是否存在 multica workspace list # 确认你是目标工作区成员 ``` +可以分享:`/health`、`/readyz` 的状态、已脱敏的 daemon 错误片段、你期望使用的 hostname/port。不要分享 `~/.multica/config.json`、PAT、daemon token 或未脱敏的完整 daemon 日志。 + **怎么修**:按上面原因对症处理。最常见的两个是**改 `MULTICA_SERVER_URL` 重启 daemon**(`multica daemon restart`)和**重新登录**(`multica logout && multica login`)。 +## Self-host preflight 阻止启动 + +**症状**:`make selfhost`、`make selfhost-build` 或 Unix installer 在 Docker 启动前退出,并提示 `self-host preflight failed`。 + +**可能原因**: + +1. **raw 全接口绑定** —— `BIND_HOST=0.0.0.0` 会把 backend/frontend Docker 端口发布到所有网卡 +2. **示例密钥** —— `JWT_SECRET`、`POSTGRES_PASSWORD` 或 `DATABASE_URL` 仍是示例值 +3. **LAN/公网暴露前未加固** —— `APP_ENV` 不是 `production`、固定验证码未清空、未配置邮件 provider、注册/工作区创建仍开放,或 origin 仍是 localhost +4. **不安全的 proxy trust** —— `MULTICA_TRUSTED_PROXIES` 或 `RATE_LIMIT_TRUSTED_PROXIES` 使用了 `0.0.0.0/0` 这类宽泛网段 + +**怎么查**: + +```bash +bash scripts/selfhost-preflight.sh .env +grep -E '^(BIND_HOST|APP_ENV|FRONTEND_ORIGIN|CORS_ALLOWED_ORIGINS|ALLOW_SIGNUP|DISABLE_WORKSPACE_CREATION)=' .env +``` + +preflight 只打印变量名和问题,不打印 secret 值。上面的 `grep` 只在本机使用;不要把完整 `.env`、`docker compose config`、容器 env、PAT/config 文件或完整日志贴到支持线程。 + +**怎么修**: + +- 本机使用:保持 `BIND_HOST=127.0.0.1`。 +- 可信私有 LAN:把 `BIND_HOST` 设成一个具体 LAN IP,并设置精确的 `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS`。 +- 公网访问:保持 Docker 绑定 loopback,用 TLS 反向代理或 tunnel,并在 `/ws` 转发 WebSocket `Upgrade` 头。 +- 轮换 `JWT_SECRET` 和 `POSTGRES_PASSWORD`,同步 `DATABASE_URL`,配置 Resend 或 SMTP,清空 `MULTICA_DEV_VERIFICATION_CODE`,bootstrap 后设置 `ALLOW_SIGNUP=false` 和 `DISABLE_WORKSPACE_CREATION=true`。 +- `MULTICA_TRUSTED_PROXIES` / `RATE_LIMIT_TRUSTED_PROXIES` 只填精确 proxy/CDN CIDR,永远不要用 `0.0.0.0/0` 或 `::/0`。 + ## 任务一直卡在 queued **症状**:把 issue 分给 agent 后,issue 状态立刻变 `in_progress`,但过了很久页面没有 agent 执行的迹象;`multica daemon status` 显示 daemon `online`。 @@ -99,7 +127,7 @@ multica issue show # 看 task 历史 docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:" ``` -如果应该出现的那行没出现,说明环境变量没进到进程 —— 检查 `.env` 和 `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`。这行启动日志里**不会**打印任何密码。 +这行 provider 选择日志可以分享:它包含 provider 和 relay host/port,但不包含 API key、SMTP 密码或验证码。如果应该出现的那行没出现,请在本机检查 `.env` 并重启 backend。不要把真实 `.env`、`docker compose config` 或容器 env 输出贴出去。 ### Resend 是当前 provider @@ -112,7 +140,7 @@ docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService: **怎么查**: -- Server 日志里搜 `"[DEV] Verification code for"` —— 如果有,说明 Resend 没配,验证码被打到 stdout +- 只在本机日志里搜 `"[DEV] Verification code for"` —— 如果有,说明 Resend 没配,验证码被打到 stdout;不要分享验证码那一行 - [Resend dashboard](https://resend.com/) → Emails 看发送记录 - 确认 `RESEND_FROM_EMAIL` 的域名在 Resend console 的 "Verified Domains" 列表里 @@ -140,8 +168,8 @@ SMTP 路径把每个失败都按阶段包装好,所以 server 日志已经告 **怎么查**: - 启动时搜 `"EmailService: SMTP relay"` 一次,运行时搜 `"failed to send"` 看具体阶段 -- 在 backend 容器里测连通:`docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'` -- 确认环境变量真进到了进程:`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(这条会带出密码,仅在可信终端运行) +- 在 backend 容器里测连通:`docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz "$SMTP_HOST" "$SMTP_PORT"'` +- 用上面的 provider 选择日志确认进程选中了哪个 provider。必须看环境变量时只在可信终端本机查看,不要分享输出;里面可能有 SMTP 密码或 API key。 **怎么修**: @@ -163,10 +191,11 @@ SMTP 路径把每个失败都按阶段包装好,所以 server 日志已经告 **怎么查**: ```bash -cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' -docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' +grep -E '^(APP_ENV|MULTICA_DEV_VERIFICATION_CODE)=' .env ``` +这条只在本机使用。不要贴真实 `.env` 或容器 env dump;env 输出可能包含 API key、密码、token 和固定验证码。 + 检查邮箱(含 spam)看有没有收到真实验证码。 **怎么修**: diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index c2c516a73e..d96130d1b0 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -1,8 +1,9 @@ # Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend. # -# Services bind to 127.0.0.1 only. For cross-machine or public access, front -# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates -# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend). +# Services bind to 127.0.0.1 by default. For private LAN access, set BIND_HOST +# to a specific LAN IP. For cross-machine or public access, front them with a +# reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates TLS and +# forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend). # Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls # (UFW/iptables) by default, so the raw ports would be exposed to the internet # with the default JWT_SECRET and Postgres credentials. See: @@ -13,8 +14,8 @@ # # Edit .env — change JWT_SECRET at minimum # docker compose -f docker-compose.selfhost.yml up -d # -# Frontend: http://localhost:${FRONTEND_PORT:-3000} -# Backend: http://localhost:${BACKEND_PORT:-8080} +# Frontend: http://${BIND_HOST:-localhost}:${FRONTEND_PORT:-3000} +# Backend: http://${BIND_HOST:-localhost}:${BACKEND_PORT:-8080} name: multica @@ -44,7 +45,7 @@ services: postgres: condition: service_healthy ports: - - "127.0.0.1:${BACKEND_PORT:-8080}:8080" + - "${BIND_HOST:-127.0.0.1}:${BACKEND_PORT:-8080}:8080" volumes: - backend_uploads:/app/data/uploads environment: @@ -99,6 +100,9 @@ services: # Empty default = headers ignored, RemoteAddr used. Set e.g. # "127.0.0.1/32" when running behind a same-host reverse proxy. MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-} + # Separate trusted proxy CIDRs for auth endpoint rate limiting. + # Use exact proxy/CDN CIDRs only; never 0.0.0.0/0. + RATE_LIMIT_TRUSTED_PROXIES: ${RATE_LIMIT_TRUSTED_PROXIES:-} # Lark / Feishu bot integration. MULTICA_LARK_SECRET_KEY is the # opt-in: unset = integration disabled. Mainland 飞书 and international # Lark are auto-detected per installation and served side by side, so @@ -118,7 +122,7 @@ services: depends_on: - backend ports: - - "127.0.0.1:${FRONTEND_PORT:-3000}:3000" + - "${BIND_HOST:-127.0.0.1}:${FRONTEND_PORT:-3000}:3000" environment: HOSTNAME: "0.0.0.0" restart: unless-stopped diff --git a/scripts/install.sh b/scripts/install.sh index 905a2f3ee5..71ea0bdc8f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -41,46 +41,6 @@ fail() { printf "${BOLD}${RED}✗ %s${RESET}\n" "$*" >&2; exit 1; } command_exists() { command -v "$1" >/dev/null 2>&1; } -env_file_value() { - local file="$1" - local key="$2" - local default="$3" - local line value - line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n 1 || true)" - if [ -z "$line" ]; then - printf "%s" "$default" - return - fi - value="${line#*=}" - value="${value%$'\r'}" - value="${value%\"}" - value="${value#\"}" - value="${value%\'}" - value="${value#\'}" - if [ -z "$value" ]; then - printf "%s" "$default" - else - printf "%s" "$value" - fi -} - -selfhost_backend_port() { - local file="${1:-.env}" - local value - for key in BACKEND_PORT API_PORT SERVER_PORT PORT; do - value="$(env_file_value "$file" "$key" "")" - if [ -n "$value" ]; then - printf "%s" "$value" - return - fi - done - printf "8080" -} - -selfhost_frontend_port() { - env_file_value "${1:-.env}" "FRONTEND_PORT" "3000" -} - detect_os() { case "$(uname -s)" in Darwin) OS="darwin" ;; @@ -376,6 +336,8 @@ setup_server() { ok "Using existing .env" fi + bash scripts/selfhost-preflight.sh .env + # Start Docker Compose info "Pulling official Multica images..." pull_official_selfhost_images @@ -384,11 +346,11 @@ setup_server() { # Wait for health check info "Waiting for backend to be ready..." - local backend_port - backend_port="$(selfhost_backend_port .env)" + local health_url + health_url="$(bash scripts/selfhost-url.sh .env health)" local ready=false for i in $(seq 1 45); do - if curl -sf "http://localhost:${backend_port}/health" >/dev/null 2>&1; then + if curl -sf "$health_url" >/dev/null 2>&1; then ready=true break fi @@ -450,11 +412,11 @@ run_with_server() { printf "${BOLD}${GREEN} ✓ Multica server is running and CLI is ready!${RESET}\n" printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" printf "\n" - local frontend_port backend_port - frontend_port="$(selfhost_frontend_port "$INSTALL_DIR/.env")" - backend_port="$(selfhost_backend_port "$INSTALL_DIR/.env")" - printf " ${BOLD}Frontend:${RESET} http://localhost:%s\n" "$frontend_port" - printf " ${BOLD}Backend:${RESET} http://localhost:%s\n" "$backend_port" + local frontend_url backend_url + frontend_url="$(bash "$INSTALL_DIR/scripts/selfhost-url.sh" "$INSTALL_DIR/.env" frontend)" + backend_url="$(bash "$INSTALL_DIR/scripts/selfhost-url.sh" "$INSTALL_DIR/.env" backend)" + printf " ${BOLD}Frontend:${RESET} %s\n" "$frontend_url" + printf " ${BOLD}Backend:${RESET} %s\n" "$backend_url" printf " ${BOLD}Server at:${RESET} %s\n" "$INSTALL_DIR" printf "\n" printf " ${BOLD}Next: configure your CLI to connect${RESET}\n" diff --git a/scripts/install.test.sh b/scripts/install.test.sh index 439b036ca3..d48c22842e 100644 --- a/scripts/install.test.sh +++ b/scripts/install.test.sh @@ -53,10 +53,10 @@ _run_installer() { local tmp="$1" local out="$tmp/install.out" local err="$tmp/install.err" - if ! PATH="$tmp/stub-bin:$tmp/install-bin:/usr/bin:/bin" \ + if ! PATH="$tmp/stub-bin:$tmp/install-bin:/usr/bin:/bin:/run/current-system/sw/bin" \ MULTICA_BIN_DIR="$tmp/install-bin" \ MULTICA_TEST_ARCHIVE="$tmp/multica.tar.gz" \ - bash "$ROOT_DIR/scripts/install.sh" >"$out" 2>"$err"; then + "${BASH:-bash}" "$ROOT_DIR/scripts/install.sh" >"$out" 2>"$err"; then echo "install.sh exited non-zero" >&2 cat "$out" >&2 || true cat "$err" >&2 || true diff --git a/scripts/selfhost-config.test.sh b/scripts/selfhost-config.test.sh index 77323a0b97..870ad0669d 100755 --- a/scripts/selfhost-config.test.sh +++ b/scripts/selfhost-config.test.sh @@ -42,10 +42,25 @@ config="$( require_config "$config" 'published: "3100"' require_config "$config" 'published: "9100"' +require_config "$config" 'host_ip: 127.0.0.1' require_config "$config" 'FRONTEND_ORIGIN: http://localhost:3100' require_config "$config" 'GOOGLE_REDIRECT_URI: http://localhost:3100/auth/callback' require_config "$config" 'MULTICA_APP_URL: http://localhost:3100' +lan_tmp_env="$(mktemp)" +trap 'rm -f "$tmp_env" "$lan_tmp_env"' EXIT +sed 's/^BIND_HOST=.*/BIND_HOST=192.168.1.50/' "$tmp_env" >"$lan_tmp_env" + +lan_config="$( + docker compose \ + --env-file "$lan_tmp_env" \ + -f docker-compose.selfhost.yml \ + config +)" + +require_config "$lan_config" 'host_ip: 192.168.1.50' +require_config "$lan_config" 'RATE_LIMIT_TRUSTED_PROXIES: ""' + for script in scripts/dev.sh scripts/check.sh; do if ! grep -Fq '. scripts/local-env.sh' "$script"; then echo "$script must source scripts/local-env.sh for shared local env derivation." diff --git a/scripts/selfhost-preflight.sh b/scripts/selfhost-preflight.sh new file mode 100755 index 0000000000..61d0a7ee29 --- /dev/null +++ b/scripts/selfhost-preflight.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" + +warn() { + printf 'WARN: %s\n' "$*" >&2 +} + +fail_msg() { + printf 'ERROR: %s\n' "$*" >&2 +} + +env_value() { + local key=$1 + local line value + + line="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 || true)" + if [ -z "$line" ]; then + return 0 + fi + value="${line#*=}" + value="${value%%#*}" + value="${value%$'\r'}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "$value" +} + +is_loopback_host() { + local host=$1 + case "$host" in + ""|"localhost"|"127.0.0.1"|"::1"|"[::1]") + return 0 + ;; + 127.*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_all_interface_host() { + local host=$1 + case "$host" in + "0.0.0.0"|"::"|"[::]"|"*") + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_local_origin_value() { + local value=$1 + case "$value" in + ""|*localhost*|*127.0.0.1*|*"::1"*|*'${FRONTEND_PORT}'*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +has_nonlocal_origin_value() { + local value=$1 + local item + + IFS=',' read -ra items <<<"$value" + for item in "${items[@]}"; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + if [ -n "$item" ] && ! is_local_origin_value "$item"; then + return 0 + fi + done + + return 1 +} + +has_wildcard_origin_value() { + local value=$1 + local item + + IFS=',' read -ra items <<<"$value" + for item in "${items[@]}"; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + if [[ -n "$item" && "$item" == *"*"* ]]; then + return 0 + fi + done + + return 1 +} + +has_broad_cidr() { + local value=$1 + value="${value//[[:space:]]/}" + case ",$value," in + *,0.0.0.0/0,*|*,::/0,*|*,0/0,*|*,\*,*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +if [ ! -f "$ENV_FILE" ]; then + fail_msg "missing env file: $ENV_FILE" + exit 1 +fi + +bind_host="$(env_value BIND_HOST)" +bind_host="${bind_host:-127.0.0.1}" +public_bind_override="$(env_value MULTICA_SELFHOST_ALLOW_PUBLIC_BIND)" +public_url="$(env_value MULTICA_PUBLIC_URL)" +frontend_origin="$(env_value FRONTEND_ORIGIN)" +cors_allowed_origins="$(env_value CORS_ALLOWED_ORIGINS)" + +errors=0 +exposure_requested=false + +if is_all_interface_host "$bind_host"; then + exposure_requested=true + if [ "$public_bind_override" != "1" ]; then + fail_msg "BIND_HOST=0.0.0.0 is refused because it publishes raw Docker ports on every interface. Use a reverse proxy/tunnel, bind a specific LAN IP, or set MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1 after a security review." + errors=$((errors + 1)) + else + warn "explicit public bind override enabled; raw Docker ports may be reachable outside this host." + fi +elif ! is_loopback_host "$bind_host"; then + exposure_requested=true + warn "non-loopback bind requested; preflight will require self-host hardening settings before Docker starts." +fi + +if [ -n "$public_url" ] && has_nonlocal_origin_value "$public_url"; then + exposure_requested=true +fi +if [ -n "$frontend_origin" ] && has_nonlocal_origin_value "$frontend_origin"; then + exposure_requested=true +fi +if [ -n "$cors_allowed_origins" ] && has_nonlocal_origin_value "$cors_allowed_origins"; then + exposure_requested=true +fi + +if [ "$exposure_requested" = true ]; then + postgres_password="$(env_value POSTGRES_PASSWORD)" + database_url="$(env_value DATABASE_URL)" + jwt_secret="$(env_value JWT_SECRET)" + app_env="$(env_value APP_ENV)" + dev_code="$(env_value MULTICA_DEV_VERIFICATION_CODE)" + resend_api_key="$(env_value RESEND_API_KEY)" + smtp_host="$(env_value SMTP_HOST)" + smtp_tls_insecure="$(env_value SMTP_TLS_INSECURE)" + trusted_proxies="$(env_value MULTICA_TRUSTED_PROXIES)" + rate_limit_trusted_proxies="$(env_value RATE_LIMIT_TRUSTED_PROXIES)" + allow_signup="$(env_value ALLOW_SIGNUP)" + disable_workspace_creation="$(env_value DISABLE_WORKSPACE_CREATION)" + + if [ -z "$postgres_password" ] || [ "$postgres_password" = "multica" ]; then + fail_msg "POSTGRES_PASSWORD still uses the example value; rotate it before non-loopback or public access." + errors=$((errors + 1)) + fi + if [[ "$database_url" == *":multica@"* ]]; then + fail_msg "DATABASE_URL still embeds the example Postgres password; keep it in sync with the rotated POSTGRES_PASSWORD." + errors=$((errors + 1)) + fi + if [ -z "$jwt_secret" ] || [ "$jwt_secret" = "change-me-in-production" ]; then + fail_msg "JWT_SECRET still uses the example value; generate a high-entropy secret before exposure." + errors=$((errors + 1)) + fi + if [ "$app_env" != "production" ]; then + fail_msg "APP_ENV should be production before non-loopback or public access." + errors=$((errors + 1)) + fi + if [ -n "$dev_code" ]; then + fail_msg "MULTICA_DEV_VERIFICATION_CODE must be empty before non-loopback or public access." + errors=$((errors + 1)) + fi + if [ -z "$resend_api_key" ] && [ -z "$smtp_host" ]; then + fail_msg "no email provider is configured; generated login codes would be printed to backend logs." + errors=$((errors + 1)) + fi + if [ "$smtp_tls_insecure" = "true" ]; then + fail_msg "SMTP_TLS_INSECURE=true disables certificate verification; do not use it for public or shared-network access." + errors=$((errors + 1)) + fi + if has_broad_cidr "$trusted_proxies"; then + fail_msg "MULTICA_TRUSTED_PROXIES contains a broad CIDR; use only exact reverse proxy/CDN CIDRs." + errors=$((errors + 1)) + fi + if has_broad_cidr "$rate_limit_trusted_proxies"; then + fail_msg "RATE_LIMIT_TRUSTED_PROXIES contains a broad CIDR; use only exact reverse proxy/CDN CIDRs." + errors=$((errors + 1)) + fi + if ! has_nonlocal_origin_value "$frontend_origin" && ! has_nonlocal_origin_value "$cors_allowed_origins"; then + fail_msg "FRONTEND_ORIGIN or CORS_ALLOWED_ORIGINS must be set to the exact LAN/public browser origin." + errors=$((errors + 1)) + fi + if has_wildcard_origin_value "$frontend_origin"; then + fail_msg "FRONTEND_ORIGIN must use exact origins; wildcard values or patterns are not allowed before non-loopback or public access." + errors=$((errors + 1)) + fi + if has_wildcard_origin_value "$cors_allowed_origins"; then + fail_msg "CORS_ALLOWED_ORIGINS must use exact origins; wildcard values or patterns are not allowed before non-loopback or public access." + errors=$((errors + 1)) + fi + if [ "$allow_signup" != "false" ]; then + fail_msg "ALLOW_SIGNUP defaults to true unless explicitly set to false; set ALLOW_SIGNUP=false after bootstrapping users." + errors=$((errors + 1)) + fi + if [ "$disable_workspace_creation" != "true" ]; then + fail_msg "DISABLE_WORKSPACE_CREATION is not true; set it after bootstrapping the shared workspace." + errors=$((errors + 1)) + fi +fi + +if [ "$errors" -gt 0 ]; then + printf 'self-host preflight failed with %s issue(s). Values were not printed; inspect %s locally.\n' "$errors" "$ENV_FILE" >&2 + exit 1 +fi + +echo "self-host preflight ok" diff --git a/scripts/selfhost-preflight.test.sh b/scripts/selfhost-preflight.test.sh new file mode 100755 index 0000000000..d28ff4a65f --- /dev/null +++ b/scripts/selfhost-preflight.test.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +require_contains() { + local output=$1 + local expected=$2 + + if ! grep -Fq "$expected" <<<"$output"; then + echo "Missing expected preflight output:" + echo " $expected" + echo "Observed:" + echo "$output" + exit 1 + fi +} + +require_not_contains() { + local output=$1 + local forbidden=$2 + + if grep -Fq "$forbidden" <<<"$output"; then + echo "Preflight output included a sensitive value:" + echo " $forbidden" + echo "Observed:" + echo "$output" + exit 1 + fi +} + +run_preflight_expect_fail() { + local env_file=$1 + local output + + if output="$(bash scripts/selfhost-preflight.sh "$env_file" 2>&1)"; then + echo "Expected selfhost preflight to fail for $env_file" >&2 + echo "$output" >&2 + exit 1 + fi + + printf "%s" "$output" +} + +run_preflight_expect_pass() { + local env_file=$1 + local output + + if ! output="$(bash scripts/selfhost-preflight.sh "$env_file" 2>&1)"; then + echo "Expected selfhost preflight to pass for $env_file" >&2 + echo "$output" >&2 + exit 1 + fi + + printf "%s" "$output" +} + +make_env() { + local env_file=$1 + shift + + cat >"$env_file" <<'ENV' +POSTGRES_PASSWORD=fixture-postgres-password +JWT_SECRET=fixture-jwt-secret +APP_ENV=production +BIND_HOST=127.0.0.1 +FRONTEND_ORIGIN=http://localhost:3000 +CORS_ALLOWED_ORIGINS= +RESEND_API_KEY=fixture-resend-key +SMTP_HOST= +SMTP_TLS_INSECURE=false +MULTICA_DEV_VERIFICATION_CODE= +MULTICA_TRUSTED_PROXIES= +RATE_LIMIT_TRUSTED_PROXIES= +ALLOW_SIGNUP=false +DISABLE_WORKSPACE_CREATION=true +ENV + + for line in "$@"; do + local key="${line%%=*}" + if grep -q "^${key}=" "$env_file"; then + sed -i "s#^${key}=.*#${line}#" "$env_file" + else + printf '%s\n' "$line" >>"$env_file" + fi + done +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +safe_env="$tmp_dir/safe.env" +make_env "$safe_env" +safe_output="$(run_preflight_expect_pass "$safe_env")" +require_contains "$safe_output" "self-host preflight ok" + +lan_safe_env="$tmp_dir/lan-safe.env" +make_env "$lan_safe_env" \ + "BIND_HOST=192.168.1.50" \ + "FRONTEND_ORIGIN=http://192.168.1.50:3000" +lan_safe_output="$(run_preflight_expect_pass "$lan_safe_env")" +require_contains "$lan_safe_output" "non-loopback bind requested" + +lan_mixed_origin_env="$tmp_dir/lan-mixed-origin.env" +make_env "$lan_mixed_origin_env" \ + "BIND_HOST=192.168.1.50" \ + "FRONTEND_ORIGIN=http://localhost:3000" \ + "CORS_ALLOWED_ORIGINS=http://localhost:3000,http://192.168.1.50:3000" +lan_mixed_origin_output="$(run_preflight_expect_pass "$lan_mixed_origin_env")" +require_contains "$lan_mixed_origin_output" "non-loopback bind requested" + +lan_missing_signup_env="$tmp_dir/lan-missing-signup.env" +make_env "$lan_missing_signup_env" \ + "BIND_HOST=192.168.1.50" \ + "FRONTEND_ORIGIN=http://192.168.1.50:3000" +sed -i '/^ALLOW_SIGNUP=/d' "$lan_missing_signup_env" +lan_missing_signup_output="$(run_preflight_expect_fail "$lan_missing_signup_env")" +require_contains "$lan_missing_signup_output" "ALLOW_SIGNUP defaults to true" + +lan_wildcard_cors_env="$tmp_dir/lan-wildcard-cors.env" +make_env "$lan_wildcard_cors_env" \ + "BIND_HOST=192.168.1.50" \ + "FRONTEND_ORIGIN=" \ + "CORS_ALLOWED_ORIGINS=*" +lan_wildcard_cors_output="$(run_preflight_expect_fail "$lan_wildcard_cors_env")" +require_contains "$lan_wildcard_cors_output" "CORS_ALLOWED_ORIGINS must use exact origins" + +lan_wildcard_frontend_env="$tmp_dir/lan-wildcard-frontend.env" +make_env "$lan_wildcard_frontend_env" \ + "BIND_HOST=192.168.1.50" \ + "FRONTEND_ORIGIN=https://*.example.com" \ + "CORS_ALLOWED_ORIGINS=" +lan_wildcard_frontend_output="$(run_preflight_expect_fail "$lan_wildcard_frontend_env")" +require_contains "$lan_wildcard_frontend_output" "FRONTEND_ORIGIN must use exact origins" + +all_interface_env="$tmp_dir/all-interface.env" +make_env "$all_interface_env" "BIND_HOST=0.0.0.0" +all_interface_output="$(run_preflight_expect_fail "$all_interface_env")" +require_contains "$all_interface_output" "BIND_HOST=0.0.0.0 is refused" +require_contains "$all_interface_output" "MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1" + +lan_unsafe_env="$tmp_dir/lan-unsafe.env" +make_env "$lan_unsafe_env" \ + "BIND_HOST=192.168.1.50" \ + "POSTGRES_PASSWORD=multica" \ + "JWT_SECRET=change-me-in-production" \ + "APP_ENV=development" \ + "MULTICA_DEV_VERIFICATION_CODE=888888" \ + "RESEND_API_KEY=" \ + "SMTP_HOST=" \ + "SMTP_TLS_INSECURE=true" \ + "MULTICA_TRUSTED_PROXIES=0.0.0.0/0" \ + "RATE_LIMIT_TRUSTED_PROXIES=0.0.0.0/0" \ + "FRONTEND_ORIGIN=" \ + "CORS_ALLOWED_ORIGINS=" \ + "ALLOW_SIGNUP=true" \ + "DISABLE_WORKSPACE_CREATION=" +lan_unsafe_output="$(run_preflight_expect_fail "$lan_unsafe_env")" +require_contains "$lan_unsafe_output" "POSTGRES_PASSWORD still uses the example value" +require_contains "$lan_unsafe_output" "JWT_SECRET still uses the example value" +require_contains "$lan_unsafe_output" "APP_ENV should be production" +require_contains "$lan_unsafe_output" "MULTICA_DEV_VERIFICATION_CODE must be empty" +require_contains "$lan_unsafe_output" "no email provider is configured" +require_contains "$lan_unsafe_output" "SMTP_TLS_INSECURE=true" +require_contains "$lan_unsafe_output" "MULTICA_TRUSTED_PROXIES contains a broad CIDR" +require_contains "$lan_unsafe_output" "RATE_LIMIT_TRUSTED_PROXIES contains a broad CIDR" +require_contains "$lan_unsafe_output" "FRONTEND_ORIGIN or CORS_ALLOWED_ORIGINS must be set" +require_contains "$lan_unsafe_output" "ALLOW_SIGNUP defaults to true" +require_contains "$lan_unsafe_output" "DISABLE_WORKSPACE_CREATION is not true" +require_not_contains "$lan_unsafe_output" "fixture-postgres-password" +require_not_contains "$lan_unsafe_output" "fixture-jwt-secret" + +override_env="$tmp_dir/override.env" +make_env "$override_env" \ + "BIND_HOST=0.0.0.0" \ + "MULTICA_SELFHOST_ALLOW_PUBLIC_BIND=1" \ + "FRONTEND_ORIGIN=https://multica.example.com" +override_output="$(run_preflight_expect_pass "$override_env")" +require_contains "$override_output" "explicit public bind override enabled" + +echo "self-host preflight tests passed" diff --git a/scripts/selfhost-url.sh b/scripts/selfhost-url.sh new file mode 100755 index 0000000000..1297bc061e --- /dev/null +++ b/scripts/selfhost-url.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" +KIND="${2:-frontend}" + +env_value() { + local key=$1 + local default=${2:-} + local line value + + line="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 || true)" + if [ -z "$line" ]; then + printf '%s' "$default" + return + fi + + value="${line#*=}" + value="${value%%#*}" + value="${value%$'\r'}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + + if [ -z "$value" ]; then + printf '%s' "$default" + else + printf '%s' "$value" + fi +} + +url_host() { + local host=$1 + + case "$host" in + ""|"127.0.0.1"|"0.0.0.0"|"::"|"[::]"|"*") + printf 'localhost' + ;; + "localhost"|"[::1]") + printf '%s' "$host" + ;; + "::1") + printf '[::1]' + ;; + *:*) + printf '[%s]' "$host" + ;; + *) + printf '%s' "$host" + ;; + esac +} + +bind_host="$(env_value BIND_HOST "127.0.0.1")" +frontend_port="$(env_value FRONTEND_PORT "3000")" +backend_port="$(env_value BACKEND_PORT "$(env_value API_PORT "$(env_value SERVER_PORT "$(env_value PORT "8080")")")")" +host="$(url_host "$bind_host")" + +case "$KIND" in + frontend) + printf 'http://%s:%s\n' "$host" "$frontend_port" + ;; + backend) + printf 'http://%s:%s\n' "$host" "$backend_port" + ;; + health) + printf 'http://%s:%s/health\n' "$host" "$backend_port" + ;; + *) + echo "usage: $0 [env-file] frontend|backend|health" >&2 + exit 2 + ;; +esac diff --git a/scripts/selfhost-url.test.sh b/scripts/selfhost-url.test.sh new file mode 100755 index 0000000000..cf978261da --- /dev/null +++ b/scripts/selfhost-url.test.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +require_output() { + local actual=$1 + local expected=$2 + + if [ "$actual" != "$expected" ]; then + echo "Unexpected self-host URL helper output:" + echo " expected: $expected" + echo " actual: $actual" + exit 1 + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +default_env="$tmp_dir/default.env" +cat >"$default_env" <<'ENV' +BIND_HOST=127.0.0.1 +FRONTEND_PORT=3100 +BACKEND_PORT=9100 +ENV + +require_output "$(bash scripts/selfhost-url.sh "$default_env" frontend)" "http://localhost:3100" +require_output "$(bash scripts/selfhost-url.sh "$default_env" backend)" "http://localhost:9100" +require_output "$(bash scripts/selfhost-url.sh "$default_env" health)" "http://localhost:9100/health" + +lan_env="$tmp_dir/lan.env" +cat >"$lan_env" <<'ENV' +BIND_HOST=192.168.1.50 +FRONTEND_PORT=3100 +BACKEND_PORT=9100 +ENV + +require_output "$(bash scripts/selfhost-url.sh "$lan_env" frontend)" "http://192.168.1.50:3100" +require_output "$(bash scripts/selfhost-url.sh "$lan_env" backend)" "http://192.168.1.50:9100" +require_output "$(bash scripts/selfhost-url.sh "$lan_env" health)" "http://192.168.1.50:9100/health" + +all_interface_env="$tmp_dir/all-interface.env" +cat >"$all_interface_env" <<'ENV' +BIND_HOST=0.0.0.0 +FRONTEND_PORT=3100 +BACKEND_PORT=9100 +ENV + +require_output "$(bash scripts/selfhost-url.sh "$all_interface_env" health)" "http://localhost:9100/health" + +ipv6_env="$tmp_dir/ipv6.env" +cat >"$ipv6_env" <<'ENV' +BIND_HOST=::1 +FRONTEND_PORT=3100 +BACKEND_PORT=9100 +ENV + +require_output "$(bash scripts/selfhost-url.sh "$ipv6_env" health)" "http://[::1]:9100/health" + +echo "self-host URL helper tests passed"