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)