Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
b4c5b982fd fix(cli): prompt for app_url instead of guessing on remote server_url
Per GPT-Boy's review on MUL-1260: deriving app_url as
http://<server-host>:3000 breaks for the common api.example.com +
app.example.com split and for https-fronted deploys — the setup flow
would still open a broken login URL, just slightly later.

Replace the guess with an interactive prompt. If the user hits enter
(or stdin is unavailable), fail loudly with a clear usage hint instead
of proceeding with bad data.
2026-04-23 16:31:09 +08:00
Jiang Bohan
6729c8fff3 fix(cli): make browser-login work from a machine that isn't the server
The #923 callback host fix only worked when the CLI and the self-hosted
server ran on the same box. In a cross-machine setup — `multica login`
from a laptop against a self-hosted server on a NAS — the flow silently
wedged on two issues:

1. The callback host was derived from `--app-url`, so the `cli_callback`
   URL pointed at the server's IP and the browser could never reach
   the CLI's local listener on the laptop. The OAuth token never came
   back and subsequent `/api/workspaces` calls 401'd on stale state.

2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket.
   Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and
   get "connection refused" even when the URL is otherwise correct.

Changes:

- Derive the callback host from the CLI's own outbound interface by
  dialing the server (UDP, no packets sent — just asks the kernel which
  source IP it would use). Falls back to loopback for public app URLs
  and to the app IP for offline detection.
- Add `--callback-host` flag on `login` and `setup self-host` so
  reverse-proxy / FQDN users can override auto-detection — this is the
  follow-up @hassaanz asked for on #923.
- Pin the callback listener to `tcp4` so macOS never lands on an
  IPv6-only socket.
- `multica setup self-host`: when the user explicitly passes a remote
  `--server-url` but omits `--app-url`, infer app URL from the server
  host and warn instead of silently defaulting to `localhost:3000`.

Unit tests cover the binding-decision matrix (public, localhost, same-
machine LAN, cross-machine LAN, outbound-detect failure, flag override)
and the new setup helpers.

Reported by @RafeRoberts in #1494 with very clear repro details.
2026-04-23 16:02:25 +08:00
5 changed files with 275 additions and 18 deletions

View File

@@ -37,6 +37,11 @@ var authLogoutCmd = &cobra.Command{
RunE: runAuthLogout,
}
// callbackHostFlag lets users override the host/IP that goes into the OAuth
// cli_callback URL. Useful when the CLI sits behind a reverse proxy or the
// auto-detected LAN IP isn't the one the browser can reach.
const callbackHostFlag = "callback-host"
func init() {
authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authLogoutCmd)
@@ -94,28 +99,114 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error {
return runAuthLoginBrowser(cmd)
}
// resolveCallbackBinding picks the host that goes into the `cli_callback`
// URL and the interface the CLI should bind its local HTTP listener to.
//
// The browser running the login flow is on the *server's* machine (or
// wherever the user clicked the link), not on the CLI host. That means the
// callback URL must resolve to an address the browser can actually reach,
// which is different in each topology:
//
// - hosted / public app URL: browser and CLI are on the same machine,
// localhost works.
// - self-host, CLI on server box: same as above.
// - self-host, CLI on a different LAN box: the callback URL must point at
// the CLI's own LAN IP, not the server's.
// - reverse-proxied / FQDN setups: auto-detection can't know the right
// host — the user supplies it via --callback-host.
//
// detectOutbound is injected so tests can exercise the routing decisions
// without real network calls.
func resolveCallbackBinding(flagHost, serverURL, appURL string, detectOutbound func(string) net.IP) (callbackHost, bindAddr string) {
// Explicit flag always wins. Bind on all interfaces so the browser can
// reach us regardless of which interface the host name resolves to.
if h := strings.TrimSpace(flagHost); h != "" {
return h, "0.0.0.0"
}
appIP := urlPrivateIP(appURL)
if appIP == nil {
// Public hostname, FQDN without private-IP mapping, or parse error.
// Loopback is the only safe default — on hosted/public setups the
// browser and CLI live on the same machine.
return "localhost", "127.0.0.1"
}
// app_url is a private LAN IP. Figure out whether the CLI is on that
// same box or a different one by asking the kernel which local address
// it would use to reach the server. Same box → loopback is fine.
// Different box → use the CLI's outbound IP so the browser can reach us.
cliIP := detectOutbound(serverURL)
if cliIP == nil {
// Detection failed (offline, unreachable server, etc.). Fall back to
// the app IP — preserves the pre-existing same-machine behaviour.
return appIP.String(), "0.0.0.0"
}
if cliIP.Equal(appIP) {
return "localhost", "127.0.0.1"
}
return cliIP.String(), "0.0.0.0"
}
// urlPrivateIP returns the hostname of rawURL parsed as an RFC 1918 IP, or
// nil if the URL is unparsable or the host is not a private literal.
func urlPrivateIP(rawURL string) net.IP {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil
}
ip := net.ParseIP(parsed.Hostname())
if ip == nil || !ip.IsPrivate() {
return nil
}
return ip
}
// detectOutboundIP returns the local IPv4 address the OS would use to reach
// serverURL, or nil if detection fails. The UDP dial does not send packets —
// it just causes the kernel to pick a source IP for the destination route.
func detectOutboundIP(serverURL string) net.IP {
parsed, err := url.Parse(serverURL)
if err != nil || parsed.Hostname() == "" {
return nil
}
port := parsed.Port()
if port == "" {
if parsed.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
conn, err := net.Dial("udp4", net.JoinHostPort(parsed.Hostname(), port))
if err != nil {
return nil
}
defer conn.Close()
local, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok || local.IP == nil {
return nil
}
// Normalise to 4-byte form so Equal() comparisons match net.ParseIP
// output consistently.
if v4 := local.IP.To4(); v4 != nil {
return v4
}
return local.IP
}
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.
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"
}
}
flagHost, _ := cmd.Flags().GetString(callbackHostFlag)
callbackHost, bindAddr := resolveCallbackBinding(flagHost, serverURL, appURL, detectOutboundIP)
// Start a local HTTP server on a random port to receive the callback.
listener, err := net.Listen("tcp", bindAddr+":0")
// Pin to "tcp4" — a bare "tcp" on macOS can produce an IPv6-only socket
// that IPv4 clients (including browsers resolving localhost → 127.0.0.1)
// cannot reach. The callback URL is always an IPv4 literal or hostname,
// so an IPv4 listener is what the browser actually needs.
listener, err := net.Listen("tcp4", bindAddr+":0")
if err != nil {
return fmt.Errorf("failed to start local server: %w", err)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"net"
"testing"
"github.com/spf13/cobra"
@@ -36,6 +37,88 @@ func TestResolveAppURL(t *testing.T) {
})
}
func TestResolveCallbackBinding(t *testing.T) {
// Fake outbound detector: pretends the CLI has a fixed LAN IP regardless
// of which server it dials.
fixed := func(ip string) func(string) net.IP {
return func(string) net.IP { return net.ParseIP(ip).To4() }
}
failing := func(string) net.IP { return nil }
cases := []struct {
name string
flagHost string
serverURL string
appURL string
detect func(string) net.IP
wantCallback string
wantBind string
}{
{
name: "public app URL stays on loopback",
appURL: "https://multica.ai",
serverURL: "https://api.multica.ai",
detect: failing,
wantCallback: "localhost",
wantBind: "127.0.0.1",
},
{
name: "localhost app URL stays on loopback",
appURL: "http://localhost:3000",
serverURL: "http://localhost:8080",
detect: failing,
wantCallback: "localhost",
wantBind: "127.0.0.1",
},
{
name: "same-machine self-host uses loopback (CLI IP matches app IP)",
appURL: "http://192.168.0.28:3000",
serverURL: "http://192.168.0.28:8080",
detect: fixed("192.168.0.28"),
wantCallback: "localhost",
wantBind: "127.0.0.1",
},
{
name: "cross-machine self-host points callback at CLI's LAN IP",
appURL: "http://192.168.0.28:3000",
serverURL: "http://192.168.0.28:8080",
detect: fixed("192.168.0.47"),
wantCallback: "192.168.0.47",
wantBind: "0.0.0.0",
},
{
name: "outbound detection failure falls back to app IP",
appURL: "http://192.168.0.28:3000",
serverURL: "http://192.168.0.28:8080",
detect: failing,
wantCallback: "192.168.0.28",
wantBind: "0.0.0.0",
},
{
name: "--callback-host flag overrides everything",
flagHost: "cli.internal.example",
appURL: "https://multica.ai",
serverURL: "https://api.multica.ai",
detect: fixed("10.0.0.5"),
wantCallback: "cli.internal.example",
wantBind: "0.0.0.0",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
gotCallback, gotBind := resolveCallbackBinding(tc.flagHost, tc.serverURL, tc.appURL, tc.detect)
if gotCallback != tc.wantCallback {
t.Errorf("callback host = %q, want %q", gotCallback, tc.wantCallback)
}
if gotBind != tc.wantBind {
t.Errorf("bind addr = %q, want %q", gotBind, tc.wantBind)
}
})
}
}
func TestNormalizeAPIBaseURL(t *testing.T) {
t.Run("converts websocket base URL", func(t *testing.T) {
if got := normalizeAPIBaseURL("ws://localhost:18106/ws"); got != "http://localhost:18106" {

View File

@@ -37,6 +37,7 @@ var loginCmd = &cobra.Command{
func init() {
loginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token")
loginCmd.Flags().String(callbackHostFlag, "", "Host the OAuth callback URL points at (auto-detected from the server's route when empty). Use this for reverse-proxy / FQDN setups where auto-detection picks the wrong interface.")
}
func runLogin(cmd *cobra.Command, args []string) error {

View File

@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -46,6 +48,10 @@ var setupSelfHostCmd = &cobra.Command{
By default, connects to http://localhost:8080 (backend) and http://localhost:3000 (frontend).
Use --server-url and --app-url to specify a custom server (e.g. an on-premise deployment).
If you run this command from a different machine than the server, also pass
--callback-host <FQDN-or-IP-the-browser-can-reach-back-to-this-machine-on> so
the OAuth login flow can return the token to the CLI.
Examples:
multica setup self-host
multica setup self-host --server-url https://api.internal.co --app-url https://app.internal.co
@@ -58,6 +64,7 @@ func init() {
setupSelfHostCmd.Flags().String("app-url", "", "Frontend app URL (e.g. https://app.internal.co)")
setupSelfHostCmd.Flags().Int("port", 8080, "Backend server port (used when --server-url is not set)")
setupSelfHostCmd.Flags().Int("frontend-port", 3000, "Frontend port (used when --app-url is not set)")
setupSelfHostCmd.Flags().String(callbackHostFlag, "", "Host the OAuth callback URL points at (auto-detected when empty). Use this for reverse-proxy / FQDN setups.")
setupCmd.AddCommand(setupCloudCmd)
setupCmd.AddCommand(setupSelfHostCmd)
@@ -159,13 +166,28 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
appURL, _ := cmd.Flags().GetString("app-url")
port, _ := cmd.Flags().GetInt("port")
frontendPort, _ := cmd.Flags().GetInt("frontend-port")
userProvidedServerURL := serverURL != ""
// If custom URLs provided, use them; otherwise default to localhost with ports.
if serverURL == "" {
serverURL = fmt.Sprintf("http://localhost:%d", port)
}
if appURL == "" {
appURL = fmt.Sprintf("http://localhost:%d", frontendPort)
if userProvidedServerURL && !serverHostIsLocal(serverURL) {
// We can't guess the frontend URL for a remote server: api.x.co
// and app.x.co, or an https-fronted deployment, would silently
// produce a broken login URL. Ask the user instead.
entered, err := promptAppURL(serverURL)
if err != nil {
return err
}
if entered == "" {
return fmt.Errorf("--app-url is required when --server-url points at a remote host (e.g. --app-url https://app.internal.co)")
}
appURL = entered
} else {
appURL = fmt.Sprintf("http://localhost:%d", frontendPort)
}
}
cfg := cli.CLIConfig{
@@ -203,6 +225,39 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
return nil
}
// serverHostIsLocal reports whether serverURL points at the same machine as
// the CLI (loopback literal or "localhost"). Used to decide whether to infer
// app_url from server_url or fall back to the local-dev default.
func serverHostIsLocal(serverURL string) bool {
parsed, err := url.Parse(serverURL)
if err != nil {
return false
}
h := parsed.Hostname()
if h == "localhost" {
return true
}
if ip := net.ParseIP(h); ip != nil {
return ip.IsLoopback()
}
return false
}
// promptAppURL asks the user for the frontend URL interactively. We can't
// derive it from a remote server_url — api.example.com ≠ app.example.com in
// most production setups — so guessing would just defer the failure to the
// browser login step. Returns an empty string if the user hits enter.
func promptAppURL(serverURL string) (string, error) {
fmt.Fprintf(os.Stderr, "No --app-url provided, and --server-url (%s) is remote.\n", serverURL)
fmt.Fprint(os.Stderr, "Enter the frontend app URL (e.g. https://app.internal.co): ")
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && line == "" {
return "", nil
}
return strings.TrimRight(strings.TrimSpace(line), "/"), nil
}
// probeServer checks whether a Multica backend is reachable at the given URL.
func probeServer(baseURL string) bool {
url := strings.TrimRight(baseURL, "/") + "/health"

View File

@@ -0,0 +1,27 @@
package main
import "testing"
func TestServerHostIsLocal(t *testing.T) {
cases := []struct {
name string
server string
want bool
}{
{"localhost", "http://localhost:8080", true},
{"127.0.0.1", "http://127.0.0.1:8080", true},
{"IPv6 loopback", "http://[::1]:8080", true},
{"LAN IP", "http://192.168.0.28:8080", false},
{"public FQDN", "https://api.internal.co", false},
{"unparseable", "://bad", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := serverHostIsLocal(tc.server); got != tc.want {
t.Errorf("serverHostIsLocal(%q) = %v, want %v", tc.server, got, tc.want)
}
})
}
}