From c563567502fc9f8e496c894e424abc825b8b389f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E5=B0=BC=E5=85=8B?= <165354365+Sycun@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:27:38 +0800 Subject: [PATCH] fix: startup banner hardcodes 127.0.0.1 regardless of server.host (#307) The Web UI startup banner always printed 127.0.0.1, even when server.host was bound to 0.0.0.0 or a specific interface. This made users believe the host setting was ignored (the actual listen binding was correct). Pass the configured host into the banner and expand wildcard binds to the machine's real addresses so LAN URLs are shown. Fixes #301 Co-authored-by: sycpro --- cmd/server/main.go | 1 + internal/termout/startup.go | 55 +++++++++++++++++-- internal/termout/startup_test.go | 94 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 194b449b..c14ad2a5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -86,6 +86,7 @@ func main() { } termout.PrintStartupWebUI(termout.StartupWebUIOptions{ Scheme: scheme, + Host: cfg.Server.Host, Port: port, SelfSigned: scheme == "https" && cfg.Server.TLSAutoSelfSign, HTTPRedirect: scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server), diff --git a/internal/termout/startup.go b/internal/termout/startup.go index 00209721..88224f23 100644 --- a/internal/termout/startup.go +++ b/internal/termout/startup.go @@ -2,13 +2,17 @@ package termout import ( "fmt" + "io" + "net" "os" + "strconv" "strings" ) // StartupWebUIOptions configures the startup Web UI banner. type StartupWebUIOptions struct { Scheme string + Host string Port int SelfSigned bool HTTPRedirect bool @@ -22,9 +26,46 @@ func PrintConfigCreated() { s.BlankLine() } +// startupHosts 返回横幅应展示的访问地址。通配地址(含空 host)展开为回环地址 +// 加本机非回环 IPv4;显式 host 原样展示。横幅此前硬编码 127.0.0.1,导致 +// 绑定 0.0.0.0 的用户误以为 server.host 配置未生效(issue #301)。 +func startupHosts(host string) []string { + host = strings.TrimSpace(host) + if host != "" && host != "0.0.0.0" && host != "::" && host != "[::]" { + return []string{host} + } + hosts := []string{"127.0.0.1"} + addrs, err := net.InterfaceAddrs() + if err != nil { + return hosts + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok || ipNet.IP.IsLoopback() || ipNet.IP.To4() == nil { + continue + } + ip := ipNet.IP.String() + dup := false + for _, h := range hosts { + if h == ip { + dup = true + break + } + } + if !dup { + hosts = append(hosts, ip) + } + } + return hosts +} + // PrintStartupWebUI prints a colored startup banner for the Web UI. func PrintStartupWebUI(opts StartupWebUIOptions) { - s := New(os.Stdout) + printStartupWebUI(os.Stdout, opts) +} + +func printStartupWebUI(out io.Writer, opts StartupWebUIOptions) { + s := New(out) scheme := opts.Scheme if scheme == "" { scheme = "http" @@ -33,17 +74,23 @@ func PrintStartupWebUI(opts StartupWebUIOptions) { if port <= 0 { port = 8080 } - url := fmt.Sprintf("%s://127.0.0.1:%d/", scheme, port) + hosts := startupHosts(opts.Host) + urlFor := func(h string) string { + return scheme + "://" + net.JoinHostPort(h, strconv.Itoa(port)) + "/" + } s.BlankLine() s.Println(s.Bold(s.Cyan("CYBERSTRIKE AI")) + s.Dim(" / secure workspace")) s.Println(s.Dim(strings.Repeat("─", 60))) - s.Println(s.Green("● ONLINE") + " " + s.Bold(s.White(url))) + s.Println(s.Green("● ONLINE") + " " + s.Bold(s.White(urlFor(hosts[0])))) + for _, h := range hosts[1:] { + s.Println(s.Dim(" Network ") + s.Bold(s.White(urlFor(h)))) + } if opts.SelfSigned { s.Println(s.Dim(" TLS ") + s.Yellow("self-signed") + s.Dim(" · accept the browser warning once")) } if opts.HTTPRedirect { - s.Println(s.Dim(" Redirect ") + fmt.Sprintf("http://127.0.0.1:%d/ → HTTPS", port)) + s.Println(s.Dim(" Redirect ") + fmt.Sprintf("http://%s/ → HTTPS", net.JoinHostPort(hosts[0], strconv.Itoa(port)))) } s.BlankLine() } diff --git a/internal/termout/startup_test.go b/internal/termout/startup_test.go index f01b610c..d4fd8977 100644 --- a/internal/termout/startup_test.go +++ b/internal/termout/startup_test.go @@ -1,10 +1,104 @@ package termout import ( + "bytes" + "net" "strings" "testing" ) +func TestStartupHostsExplicitHost(t *testing.T) { + for _, host := range []string{"192.168.1.5", "127.0.0.1", "10.0.0.8"} { + got := startupHosts(host) + if len(got) != 1 || got[0] != host { + t.Fatalf("startupHosts(%q) = %v, want [%q]", host, got, host) + } + } +} + +func TestStartupHostsWildcardExpandsToLocalAddresses(t *testing.T) { + for _, host := range []string{"", "0.0.0.0", "::", "[::]"} { + got := startupHosts(host) + if len(got) == 0 || got[0] != "127.0.0.1" { + t.Fatalf("startupHosts(%q) = %v, want first entry 127.0.0.1", host, got) + } + seen := map[string]bool{} + for _, h := range got { + if seen[h] { + t.Fatalf("startupHosts(%q) contains duplicate %q", host, h) + } + seen[h] = true + ip := net.ParseIP(h) + if ip == nil { + t.Fatalf("startupHosts(%q) returned non-IP %q", host, h) + } + if ip.IsLoopback() && h != "127.0.0.1" { + t.Fatalf("startupHosts(%q) returned unexpected loopback %q", host, h) + } + } + } +} + +func TestPrintStartupWebUIReflectsConfiguredHost(t *testing.T) { + var buf bytes.Buffer + printStartupWebUI(&buf, StartupWebUIOptions{ + Scheme: "http", + Host: "192.168.1.5", + Port: 8080, + }) + out := buf.String() + if !strings.Contains(out, "http://192.168.1.5:8080/") { + t.Fatalf("banner should show configured host, got:\n%s", out) + } + if strings.Contains(out, "127.0.0.1") { + t.Fatalf("banner should not fall back to 127.0.0.1 for explicit host, got:\n%s", out) + } +} + +func TestPrintStartupWebUIWildcardShowsLoopbackFirst(t *testing.T) { + var buf bytes.Buffer + printStartupWebUI(&buf, StartupWebUIOptions{ + Scheme: "https", + Host: "0.0.0.0", + Port: 8443, + }) + out := buf.String() + if !strings.Contains(out, "https://127.0.0.1:8443/") { + t.Fatalf("wildcard banner should include loopback URL, got:\n%s", out) + } + for _, h := range startupHosts("0.0.0.0")[1:] { + if !strings.Contains(out, "https://"+h+":8443/") { + t.Fatalf("wildcard banner should include network URL for %s, got:\n%s", h, out) + } + } +} + +func TestPrintStartupWebUIIPv6HostBracketed(t *testing.T) { + var buf bytes.Buffer + printStartupWebUI(&buf, StartupWebUIOptions{ + Scheme: "http", + Host: "::1", + Port: 8080, + }) + if !strings.Contains(buf.String(), "http://[::1]:8080/") { + t.Fatalf("IPv6 host should be bracketed in URL, got:\n%s", buf.String()) + } +} + +func TestPrintStartupWebUIRedirectUsesConfiguredHost(t *testing.T) { + var buf bytes.Buffer + printStartupWebUI(&buf, StartupWebUIOptions{ + Scheme: "https", + Host: "10.1.2.3", + Port: 8080, + HTTPRedirect: true, + }) + out := buf.String() + if !strings.Contains(out, "http://10.1.2.3:8080/") || !strings.Contains(out, "https://10.1.2.3:8080/") { + t.Fatalf("redirect line should use configured host, got:\n%s", out) + } +} + func TestDisplayWidthEmoji(t *testing.T) { if got := displayWidth("🚀"); got != 2 { t.Fatalf("displayWidth(emoji) = %d, want 2", got)