mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 12:48:56 +02:00
Compare commits
2 Commits
agent/lamb
...
fix/cli-lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c5b982fd | ||
|
|
6729c8fff3 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
server/cmd/multica/cmd_setup_test.go
Normal file
27
server/cmd/multica/cmd_setup_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user