Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
be8a2040f5 fix(cli): always use localhost for auth callback in browser login
When the app URL pointed to a remote private IP (e.g. 192.168.11.200),
the CLI incorrectly used that IP as the callback host. The callback
HTTP server runs on the CLI's local machine, but the browser redirect
would target the remote server's IP on a random port that isn't open
there — breaking the entire auth flow for remote self-hosted setups.

Since openBrowser() always opens the browser on the same machine as the
CLI, the callback must always target localhost.

Closes #1056
2026-04-15 14:25:28 +08:00
6 changed files with 56 additions and 25 deletions

View File

@@ -7,8 +7,10 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
PORT=8080
APP_ENV=
TASK_DOMAIN=localhost
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_SERVER_URL=http://localhost:8080
MULTICA_APP_URL=http://localhost:3000
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
@@ -55,11 +57,9 @@ ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# Only set explicitly if frontend and backend are on different domains.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
REMOTE_API_URL=http://localhost:8080

15
Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{$TASK_DOMAIN} {
@next_static path /_next/static/*
header @next_static Cache-Control "public, max-age=31536000, immutable, no-transform"
reverse_proxy /api/* backend:8080
reverse_proxy /auth/send-code backend:8080
reverse_proxy /auth/verify-code backend:8080
reverse_proxy /auth/google backend:8080
reverse_proxy /auth/logout backend:8080
reverse_proxy /ws backend:8080
reverse_proxy /ws/* backend:8080
reverse_proxy /health backend:8080
reverse_proxy /health/* backend:8080
reverse_proxy frontend:3000
}

View File

@@ -37,9 +37,11 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_WS_URL
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV STANDALONE=true

View File

@@ -315,6 +315,8 @@ api.example.com {
}
```
For a single-domain setup, route the frontend and backend through one hostname and forward `/api`, `/auth`, `/ws`, and `/health` to the backend while sending everything else to the frontend. This repository now includes a root `Caddyfile` and `docker-compose.selfhost.yml` service for that pattern.
### Nginx
```nginx

View File

@@ -5,8 +5,8 @@
# # Edit .env — change JWT_SECRET at minimum
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:3000
# Backend: http://localhost:8080 (also used by CLI/daemon)
# Frontend: https://$TASK_DOMAIN (via Caddy reverse proxy)
# Backend: internal on backend:8080 (health exposed at /health through Caddy)
name: multica
@@ -35,12 +35,13 @@ services:
postgres:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
- "127.0.0.1:${PORT:-8080}:8080"
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
APP_ENV: ${APP_ENV:-}
PORT: "8080"
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-https://${TASK_DOMAIN:-localhost}}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
@@ -60,15 +61,35 @@ services:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
REMOTE_API_URL: ${REMOTE_API_URL:-http://backend:8080}
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
caddy:
image: caddy:2-alpine
depends_on:
- frontend
- backend
ports:
- "80:80"
- "443:443"
environment:
TASK_DOMAIN: ${TASK_DOMAIN:-localhost}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
pgdata:
caddy_data:
caddy_config:

View File

@@ -98,21 +98,12 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
serverURL := resolveServerURL(cmd)
appURL := resolveAppURL(cmd)
// Determine the callback host from the configured app URL.
// For self-hosted setups where the browser is on a different machine
// (e.g. Multica running on a LAN server), use the server's private IP
// so the browser can reach the CLI's local HTTP server.
// For production (public hostnames like multica.ai), keep localhost —
// the browser and CLI are on the same machine.
// The callback always targets localhost because the browser is opened on
// the same machine as the CLI (via openBrowser). Even when the Multica
// server is on a remote LAN host, the browser-side redirect must reach
// the CLI's local HTTP server, not the remote server.
callbackHost := "localhost"
bindAddr := "127.0.0.1"
if parsed, err := url.Parse(appURL); err == nil {
h := parsed.Hostname()
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
callbackHost = h
bindAddr = "0.0.0.0"
}
}
// Start a local HTTP server on a random port to receive the callback.
listener, err := net.Listen("tcp", bindAddr+":0")