Files
multica/server/cmd/multica/cmd_setup.go
Bohan Jiang b51d1c4dc3 fix(cli): make browser-login work from a machine that isn't the server (#1556)
* 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.

* 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:41:29 +08:00

279 lines
8.7 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Configure the CLI, authenticate, and start the daemon",
Long: `Configures the CLI to connect to Multica Cloud (multica.ai), then
authenticates via browser and starts the agent daemon.
If a configuration already exists, you will be prompted before overwriting.
Use 'multica setup self-host' to connect to a self-hosted server instead.
Use --profile to create an isolated configuration for a separate environment:
multica setup self-host --profile staging --server-url https://api-staging.co`,
RunE: runSetupCloud,
}
var setupCloudCmd = &cobra.Command{
Use: "cloud",
Short: "Configure the CLI for Multica Cloud (multica.ai)",
Long: `Explicitly configures the CLI to connect to Multica Cloud (multica.ai).
This is equivalent to running 'multica setup' without a subcommand.`,
RunE: runSetupCloud,
}
var setupSelfHostCmd = &cobra.Command{
Use: "self-host",
Short: "Configure the CLI for a self-hosted Multica server",
Long: `Configures the CLI to connect to a self-hosted Multica server.
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
multica setup self-host --port 9090 --frontend-port 4000`,
RunE: runSetupSelfHost,
}
func init() {
setupSelfHostCmd.Flags().String("server-url", "", "Backend server URL (e.g. https://api.internal.co)")
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)
}
// printConfigLocation prints the config file path and profile name.
func printConfigLocation(profile string) {
path, err := cli.CLIConfigPathForProfile(profile)
if err != nil {
return
}
if profile != "" {
fmt.Fprintf(os.Stderr, " profile: %s\n", profile)
}
fmt.Fprintf(os.Stderr, " config: %s\n", path)
}
// confirmOverwrite checks for an existing config and prompts the user.
// Returns true if we should proceed, false if the user declined.
func confirmOverwrite(profile string) (bool, error) {
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return true, nil // can't load → treat as no config
}
if cfg.ServerURL == "" {
return true, nil // no server configured → fresh config
}
fmt.Fprintln(os.Stderr, "Current configuration:")
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
if cfg.WorkspaceID != "" {
fmt.Fprintf(os.Stderr, " workspace: %s\n", cfg.WorkspaceID)
}
fmt.Fprintln(os.Stderr, "")
fmt.Fprint(os.Stderr, "This will reset your configuration. Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return false, nil
}
return true, nil
}
func runSetupCloud(cmd *cobra.Command, args []string) error {
profile := resolveProfile(cmd)
ok, err := confirmOverwrite(profile)
if err != nil {
return err
}
if !ok {
return nil
}
cfg := cli.CLIConfig{
ServerURL: "https://api.multica.ai",
AppURL: "https://multica.ai",
}
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Fprintln(os.Stderr, "Configured for Multica Cloud (https://multica.ai).")
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
printConfigLocation(profile)
// Authenticate.
fmt.Fprintln(os.Stderr, "")
if err := runLogin(cmd, args); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
return nil
}
func runSetupSelfHost(cmd *cobra.Command, args []string) error {
profile := resolveProfile(cmd)
ok, err := confirmOverwrite(profile)
if err != nil {
return err
}
if !ok {
return nil
}
serverURL, _ := cmd.Flags().GetString("server-url")
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 == "" {
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{
ServerURL: serverURL,
AppURL: appURL,
}
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Fprintln(os.Stderr, "Configured for self-hosted server.")
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
printConfigLocation(profile)
// Check if the server is reachable.
if !probeServer(serverURL) {
fmt.Fprintf(os.Stderr, "\n⚠ Server at %s is not reachable.\n", serverURL)
fmt.Fprintln(os.Stderr, " Make sure the server is running, then run 'multica login'.")
return nil
}
// Authenticate.
fmt.Fprintln(os.Stderr, "")
if err := runLogin(cmd, args); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
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"
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false
}
resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}