diff --git a/apps/backend/internal/launcher/network.go b/apps/backend/internal/launcher/network.go new file mode 100644 index 0000000000..36c0b97829 --- /dev/null +++ b/apps/backend/internal/launcher/network.go @@ -0,0 +1,106 @@ +package launcher + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +var ( + networkInterfacesFn = net.Interfaces + networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) { return iface.Addrs() } +) + +func listHostNetworkAddresses() []string { + interfaces, err := networkInterfacesFn() + if err != nil { + return nil + } + + var addresses []net.Addr + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := networkInterfaceAddrsFn(iface) + if err != nil { + continue + } + addresses = append(addresses, addrs...) + } + return networkAddressesFromAddrs(addresses) +} + +func networkAddressesFromAddrs(addresses []net.Addr) []string { + var ipv4, ipv6 []string + seen := make(map[string]struct{}, len(addresses)) + for _, address := range addresses { + ip := networkAddrIP(address) + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + host := ip.String() + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + if ip.To4() != nil { + ipv4 = append(ipv4, host) + } else { + ipv6 = append(ipv6, host) + } + } + return append(ipv4, ipv6...) +} + +func networkAddrIP(address net.Addr) net.IP { + switch value := address.(type) { + case *net.IPNet: + return value.IP + case *net.IPAddr: + return value.IP + default: + return nil + } +} + +func networkURLsForPort(port int, hosts []string) []string { + urls := make([]string, 0, len(hosts)) + for _, host := range hosts { + urls = append(urls, fmt.Sprintf("http://%s", net.JoinHostPort(host, strconv.Itoa(port)))) + } + return urls +} + +func networkAddressesForBindHost(addresses []string, bindHost string) []string { + if strings.TrimSpace(bindHost) == "" { + return addresses + } + + allowed := make(map[string]struct{}) + for _, rawHost := range strings.Split(bindHost, ",") { + host := strings.TrimSpace(rawHost) + if host == "" { + continue + } + ip := net.ParseIP(host) + if ip == nil { + continue + } + if ip.IsUnspecified() { + return addresses + } + if !ip.IsLoopback() && !ip.IsLinkLocalUnicast() { + allowed[ip.String()] = struct{}{} + } + } + + filtered := make([]string, 0, len(allowed)) + for _, address := range addresses { + if _, ok := allowed[address]; ok { + filtered = append(filtered, address) + } + } + return filtered +} diff --git a/apps/backend/internal/launcher/network_test.go b/apps/backend/internal/launcher/network_test.go new file mode 100644 index 0000000000..8d81b88686 --- /dev/null +++ b/apps/backend/internal/launcher/network_test.go @@ -0,0 +1,223 @@ +package launcher + +import ( + "bytes" + "errors" + "io" + "net" + "os" + "strings" + "testing" +) + +func TestLogStartupPrintsNetworkAddress(t *testing.T) { + t.Setenv("KANDEV_SERVER_HOST", "") + oldNetworkInterfaces := networkInterfacesFn + oldNetworkInterfaceAddrs := networkInterfaceAddrsFn + t.Cleanup(func() { + networkInterfacesFn = oldNetworkInterfaces + networkInterfaceAddrsFn = oldNetworkInterfaceAddrs + }) + networkInterfacesFn = func() ([]net.Interface, error) { + return []net.Interface{{Name: "eth0", Flags: net.FlagUp}, {Name: "tailscale0", Flags: net.FlagUp}}, nil + } + networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) { + switch iface.Name { + case "eth0": + return []net.Addr{testNetworkAddr(t, "192.168.1.34/22")}, nil + case "tailscale0": + return []net.Addr{testNetworkAddr(t, "100.94.173.104/32")}, nil + default: + return nil, nil + } + } + + output := captureLauncherStdout(t, func() { + logStartup("test", portConfig{ + BackendPort: 38429, + BackendURL: "http://localhost:38429", + }, "", "") + }) + + for _, want := range []string{ + "[kandev] network: http://192.168.1.34:38429", + "[kandev] network: http://100.94.173.104:38429", + } { + if !strings.Contains(output, want) { + t.Fatalf("startup output = %q, want %q", output, want) + } + } +} + +func TestListHostNetworkAddressesFiltersAndOrders(t *testing.T) { + addresses := []net.Addr{ + testNetworkAddr(t, "127.0.0.1/8"), + testNetworkAddr(t, "169.254.10.5/16"), + testNetworkAddr(t, "2001:db8::1/64"), + testNetworkAddr(t, "192.168.1.34/22"), + testNetworkAddr(t, "100.94.173.104/32"), + testNetworkAddr(t, "192.168.1.34/22"), + testNetworkAddr(t, "fe80::1/64"), + testNetworkAddr(t, "::1/128"), + } + + got := networkAddressesFromAddrs(addresses) + want := []string{"192.168.1.34", "100.94.173.104", "2001:db8::1"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("networkAddressesFromAddrs() = %v, want %v", got, want) + } +} + +func TestListHostNetworkAddressesSkipsDownInterfaces(t *testing.T) { + oldNetworkInterfaces := networkInterfacesFn + oldNetworkInterfaceAddrs := networkInterfaceAddrsFn + t.Cleanup(func() { + networkInterfacesFn = oldNetworkInterfaces + networkInterfaceAddrsFn = oldNetworkInterfaceAddrs + }) + networkInterfacesFn = func() ([]net.Interface, error) { + return []net.Interface{ + {Name: "eth0", Flags: net.FlagUp}, + {Name: "down0"}, + }, nil + } + networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) { + if iface.Name == "down0" { + return []net.Addr{testNetworkAddr(t, "10.0.0.99/24")}, nil + } + return []net.Addr{testNetworkAddr(t, "192.168.1.34/22")}, nil + } + + got := listHostNetworkAddresses() + want := []string{"192.168.1.34"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("listHostNetworkAddresses() = %v, want %v", got, want) + } +} + +func TestNetworkURLsForPortFormatsIPv6(t *testing.T) { + hosts := []string{"192.168.1.34", "100.94.173.104", "2001:db8::1"} + got := networkURLsForPort(38429, hosts) + want := []string{ + "http://192.168.1.34:38429", + "http://100.94.173.104:38429", + "http://[2001:db8::1]:38429", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("networkURLsForPort() = %v, want %v", got, want) + } +} + +func TestNetworkAddressesForBindHost(t *testing.T) { + addresses := []string{"192.168.1.34", "100.94.173.104", "2001:db8::1"} + tests := []struct { + name string + bindHost string + want []string + }{ + {name: "wildcard default", bindHost: "", want: addresses}, + {name: "ipv4 wildcard", bindHost: "0.0.0.0", want: addresses}, + {name: "ipv6 wildcard", bindHost: "::", want: addresses}, + {name: "loopback", bindHost: "127.0.0.1"}, + {name: "specific address", bindHost: "192.168.1.34", want: []string{"192.168.1.34"}}, + {name: "multiple addresses", bindHost: "127.0.0.1,100.94.173.104", want: []string{"100.94.173.104"}}, + {name: "hostname loopback", bindHost: "localhost"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := networkAddressesForBindHost(addresses, test.bindHost) + if strings.Join(got, ",") != strings.Join(test.want, ",") { + t.Fatalf("networkAddressesForBindHost(%q) = %v, want %v", test.bindHost, got, test.want) + } + }) + } +} + +func TestLogStartupKeepsLocalhostWhenNetworkDiscoveryFails(t *testing.T) { + t.Setenv("KANDEV_SERVER_HOST", "") + oldNetworkInterfaces := networkInterfacesFn + oldNetworkInterfaceAddrs := networkInterfaceAddrsFn + t.Cleanup(func() { + networkInterfacesFn = oldNetworkInterfaces + networkInterfaceAddrsFn = oldNetworkInterfaceAddrs + }) + networkInterfacesFn = func() ([]net.Interface, error) { + return nil, errors.New("test interface enumeration failure") + } + + output := captureLauncherStdout(t, func() { + logStartup("test", portConfig{ + BackendPort: 38429, + BackendURL: "http://localhost:38429", + }, "", "") + }) + if !strings.Contains(output, "[kandev] url: http://localhost:38429") { + t.Fatalf("startup output = %q, missing localhost URL", output) + } + if strings.Contains(output, "network:") { + t.Fatalf("startup output = %q, want no network lines", output) + } +} + +func TestLogStartupSuppressesNetworkAddressesForLoopbackBind(t *testing.T) { + oldNetworkInterfaces := networkInterfacesFn + oldNetworkInterfaceAddrs := networkInterfaceAddrsFn + t.Cleanup(func() { + networkInterfacesFn = oldNetworkInterfaces + networkInterfaceAddrsFn = oldNetworkInterfaceAddrs + }) + t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1") + networkInterfacesFn = func() ([]net.Interface, error) { + return []net.Interface{{Name: "eth0", Flags: net.FlagUp}}, nil + } + networkInterfaceAddrsFn = func(net.Interface) ([]net.Addr, error) { + return []net.Addr{testNetworkAddr(t, "192.168.1.34/22")}, nil + } + + output := captureLauncherStdout(t, func() { + logStartup("test", portConfig{ + BackendPort: 38429, + BackendURL: "http://localhost:38429", + }, "", "") + }) + if strings.Contains(output, "network:") { + t.Fatalf("startup output = %q, want no network lines for loopback bind", output) + } +} + +func testNetworkAddr(t *testing.T, cidr string) net.Addr { + t.Helper() + ip, network, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("net.ParseCIDR(%q) = %v", cidr, err) + } + return &net.IPNet{IP: ip, Mask: network.Mask} +} + +func captureLauncherStdout(t *testing.T, fn func()) string { + t.Helper() + read, write, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() = %v", err) + } + t.Cleanup(func() { + _ = read.Close() + _ = write.Close() + }) + oldStdout := os.Stdout + os.Stdout = write + defer func() { os.Stdout = oldStdout }() + + fn() + if err := write.Close(); err != nil { + t.Fatalf("close stdout writer: %v", err) + } + var output bytes.Buffer + if _, err := io.Copy(&output, read); err != nil { + t.Fatalf("read captured stdout: %v", err) + } + if err := read.Close(); err != nil { + t.Fatalf("close stdout reader: %v", err) + } + return output.String() +} diff --git a/apps/backend/internal/launcher/start.go b/apps/backend/internal/launcher/start.go index 96e6829362..b57f1017e0 100644 --- a/apps/backend/internal/launcher/start.go +++ b/apps/backend/internal/launcher/start.go @@ -134,6 +134,10 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { func logStartup(header string, ports portConfig, dbPath, logLevel string) { fmt.Println("[kandev] " + header) fmt.Println("[kandev] url:", ports.BackendURL) + hosts := networkAddressesForBindHost(listHostNetworkAddresses(), os.Getenv("KANDEV_SERVER_HOST")) + for _, url := range networkURLsForPort(ports.BackendPort, hosts) { + fmt.Println("[kandev] network:", url) + } fmt.Println("[kandev] mcp:", ports.BackendURL+"/mcp") if dbPath != "" { fmt.Println("[kandev] db:", dbPath) diff --git a/docs/plans/dev-network-address-logging/plan.md b/docs/plans/dev-network-address-logging/plan.md new file mode 100644 index 0000000000..fe50ccb743 --- /dev/null +++ b/docs/plans/dev-network-address-logging/plan.md @@ -0,0 +1,89 @@ +--- +spec: "../../specs/go-dev-launcher/spec.md" +created: 2026-08-13 +status: implemented +--- + +# Implementation Plan: Restore dev network address logging + +## Overview + +Restore the startup-banner behavior that the Node launcher provided before the +`make dev` migration. Add a small Go network-address helper, feed its filtered +addresses into the existing shared `logStartup` path, and cover formatting, +filtering, and enumeration failures with focused launcher tests. This keeps the +behavior shared by `dev`, `start`, and `run` without exposing the internal Vite or +agentctl ports. + +--- + +## Backend + +### Host address discovery and startup banner + +- Add `apps/backend/internal/launcher/network.go` with best-effort enumeration of + active, non-loopback host addresses, deduplication, link-local filtering, + IPv4-first ordering, IPv6 URL formatting, and filtering against an explicit + bind host. +- Update `apps/backend/internal/launcher/start.go` so `logStartup` prints the + filtered network URLs for `portConfig.BackendPort` between the localhost URL and + the existing MCP/DB/log-level lines. +- Keep interface-enumeration errors non-fatal and leave the existing localhost + startup path unchanged. + +--- + +## Tests + +- **What:** LAN and Tailscale-style IPv4 addresses are listed, duplicates and + loopback/link-local addresses are omitted, IPv6 URLs are bracketed, and IPv4 is + listed before IPv6. + **File:** `apps/backend/internal/launcher/network_test.go`. + **How:** Deterministic table-driven unit tests over representative interface + address fixtures. +- **What:** The shared startup banner emits the network URLs and still emits the + localhost URL when interface enumeration fails. + **File:** `apps/backend/internal/launcher/network_test.go`. + **How:** Assert the generated startup output with injected address-discovery + results and an enumeration-error case. +- **What:** Down interfaces are not advertised, and explicit bind hosts only + advertise reachable interface addresses. + **File:** `apps/backend/internal/launcher/network_test.go`. + **How:** Use active/down interface fixtures and loopback, wildcard, and + specific-bind cases. + +## Documentation + +- Update `docs/public/cli.md` so the public CLI reference describes the optional + `network:` lines and keeps the network-exposure warning clear. This page is a + reference document. + +## Verification Results + +- `cd apps/backend && go test -run TestLogStartupPrintsNetworkAddress ./internal/launcher` — + failed before the fix with the expected missing `network:` assertion. +- `cd apps/backend && go test -run 'Test(ListHostNetworkAddresses|NetworkURLsForPort|LogStartup)' ./internal/launcher` — + passed, 4 tests. +- `cd apps/backend && go test ./internal/launcher` — passed, 196 tests. +- `cd apps/backend && go test -run TestLogStartupSuppressesNetworkAddressesForLoopbackBind ./internal/launcher` — + failed before the fix, then passed after bind-host filtering was added. +- `cd apps/backend && go test -run 'Test(NetworkAddressesForBindHost|LogStartupSuppressesNetworkAddressesForLoopbackBind)' ./internal/launcher` — passed. +- `cd apps/backend && go test -run TestListHostNetworkAddressesSkipsDownInterfaces ./internal/launcher` — passed. +- `rtk git diff --check` — passed. +- `node --test scripts/validate-public-docs.test.mjs` — passed, 61 tests. +- `node scripts/validate-public-docs.mjs` — passed, 41 published docs pages. +- Platform risk: native interface enumeration is covered by injected fixtures on this + Linux runner; non-Linux native enumeration remains unverified. + +## Implementation Waves And Parallel Candidates + +Wave 1, sequential: + +- [x] [task-01-restore-network-logging](task-01-restore-network-logging.md) + +The implementation and its tests share the launcher output contract, so the task +is intentionally sequential and is not a delegation authorization. + +## Open Questions + +None. diff --git a/docs/plans/dev-network-address-logging/task-01-restore-network-logging.md b/docs/plans/dev-network-address-logging/task-01-restore-network-logging.md new file mode 100644 index 0000000000..9c60b59802 --- /dev/null +++ b/docs/plans/dev-network-address-logging/task-01-restore-network-logging.md @@ -0,0 +1,77 @@ +--- +id: "01-restore-network-logging" +title: "Restore network startup logging" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 01: Restore network startup logging + +Implement the startup-banner contract added to the Go dev-launcher spec. + +## Acceptance + +- `dev`, `start`, and `run` print a `network:` URL for each unique non-loopback, + non-link-local host address on the backend port. +- IPv4 addresses appear before IPv6 addresses, and IPv6 URLs use brackets. +- Interface enumeration errors do not prevent startup or remove the localhost URL. +- Explicit loopback or specific bind hosts do not advertise unrelated interfaces. +- Internal Vite and agentctl ports remain absent from the network-address lines. + +## Verification + +Run from the repository root: + +```bash +cd apps/backend && go test -run 'Test(ListHostNetworkAddresses|NetworkURLsForPort|LogStartup)' ./internal/launcher +``` + +## Files likely touched + +- `apps/backend/internal/launcher/network.go` +- `apps/backend/internal/launcher/network_test.go` +- `apps/backend/internal/launcher/start.go` +- `docs/public/cli.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. The helper, shared logger, and regression tests define one output +contract. + +## Inputs + +- `docs/specs/go-dev-launcher/spec.md`, startup output section and scenarios. +- The previous Node behavior in `apps/cli/src/shared.ts` at the pre-migration + revision, especially `listHostNetworkAddresses`, `networkUrlsForPort`, and + `logStartupInfo`. +- The current shared Go logger in `apps/backend/internal/launcher/start.go`. + +## Output contract + +Summarize the changed files, exact test command and result, any remaining +platform-specific risk, and synchronize this task and `plan.md` status. + +## Results + +- `cd apps/backend && go test -run TestLogStartupPrintsNetworkAddress ./internal/launcher` — + failed before the fix with the expected missing `network:` assertion. +- `cd apps/backend && go test -run 'Test(ListHostNetworkAddresses|NetworkURLsForPort|LogStartup)' ./internal/launcher` — + passed, 4 tests. +- `cd apps/backend && go test ./internal/launcher` — passed, 196 tests. +- `cd apps/backend && go test -run TestLogStartupSuppressesNetworkAddressesForLoopbackBind ./internal/launcher` — + failed before the bind-host fix, then passed after the fix. +- `cd apps/backend && go test -run 'Test(NetworkAddressesForBindHost|LogStartupSuppressesNetworkAddressesForLoopbackBind)' ./internal/launcher` — passed. +- `rtk git diff --check` — passed. +- `gofmt -w apps/backend/internal/launcher/network.go apps/backend/internal/launcher/network_test.go apps/backend/internal/launcher/start.go` — completed successfully. +- `node --test scripts/validate-public-docs.test.mjs` — passed, 61 tests. +- `node scripts/validate-public-docs.mjs` — passed, 41 published docs pages. +- Platform risk: native interface enumeration is covered by injected fixtures on this + Linux runner; non-Linux native enumeration remains unverified. +- Cleanup: no temporary files, processes, or external runtime state created. diff --git a/docs/public/cli.md b/docs/public/cli.md index 0b3e5321ed..cbd2fef6a7 100644 --- a/docs/public/cli.md +++ b/docs/public/cli.md @@ -225,7 +225,9 @@ Supported actions are `install`, `uninstall`, `start`, `stop`, `restart`, `statu There is no separate web-server port in an installed release: the backend serves embedded assets. For the `run`, `start`, and development launcher flows, if `--port`, `KANDEV_BACKEND_PORT`, or `KANDEV_PORT` specifies a port, the launcher checks it before declaring the backend ready, does not substitute another one, and fails startup if the configured listen address cannot bind it. `kandev service install --port` remains the separate installer behavior described above. -The launcher prints `http://localhost:`, but the backend's default `server.host` is `0.0.0.0`. That can expose Kandev to other machines on the network, and the current local product path is not an authenticated multi-user boundary. Bind it to loopback unless remote access is deliberately protected: +The launcher prints `http://localhost:`. When it can enumerate non-loopback interfaces, it also prints `network:` URLs for each unique non-loopback, non-link-local address on the same port, including local-network and Tailscale addresses. IPv6 addresses are shown in brackets. If `KANDEV_SERVER_HOST` restricts the listener, the launcher only prints matching addresses. These lines are informational and are omitted if interface discovery is unavailable. + +The backend's default `server.host` is `0.0.0.0`. That can expose Kandev to other machines on the network, and the current local product path is not an authenticated multi-user boundary. Bind it to loopback unless remote access is deliberately protected: ```bash KANDEV_SERVER_HOST=127.0.0.1 kandev diff --git a/docs/specs/go-dev-launcher/spec.md b/docs/specs/go-dev-launcher/spec.md index c81409aca6..450c15ea9c 100644 --- a/docs/specs/go-dev-launcher/spec.md +++ b/docs/specs/go-dev-launcher/spec.md @@ -104,6 +104,19 @@ require the launcher health token — Vite is not a Kandev process and does not If either child exits, the launcher shuts the whole tree down and exits with the child's code, matching current behavior. +### Startup output advertises network addresses + +The startup banner for `dev`, `start`, and `run` prints the localhost backend URL and +then one `network:` URL for each unique non-loopback, non-link-local host address on the machine. This +includes addresses from local-network and Tailscale interfaces so that a user running +Kandev on a remote VM can identify an address to open from another machine. + +The banner lists IPv4 addresses before IPv6 addresses, omits link-local addresses, and +wraps IPv6 hosts in brackets. Network-address discovery is best effort: if the host +cannot enumerate its interfaces, the launcher still starts and prints the localhost +URL. When `KANDEV_SERVER_HOST` restricts the backend to loopback or specific addresses, +the banner omits unreachable interface addresses and only advertises matching binds. + ### Backend restart from the UI still rebuilds The restart supervisor (ADR 0019) writes a launch manifest and listens on a control @@ -187,6 +200,7 @@ them: building and serving the web app (Vite), the published npm shim, and repo | Either child exits | Shut down the tree; exit with that child's code, or 0 if it was signalled. | | `pnpm` missing | Surface the spawn error rather than hanging on the readiness probe. | | Ctrl-C | Whole tree terminates, including on Windows, without winjob. | +| Network interface enumeration fails | Continue startup and omit `network:` lines; keep the localhost URL. | ## Persistence guarantees @@ -196,3 +210,18 @@ them: building and serving the web app (Vite), the published npm shim, and repo never prune other backup families. - The supervisor manifest and control socket live under `/supervisor/` at mode `0600`, unchanged from `start`. + +## Scenarios + +- **GIVEN** a host with non-loopback LAN and Tailscale IPv4 addresses, **WHEN** the + `dev`, `start`, or `run` launcher prints its startup banner, **THEN** it prints one + `network:` URL for each address using the backend port. +- **GIVEN** a host with duplicate addresses, loopback addresses, or link-local + addresses, **WHEN** the startup banner is printed, **THEN** duplicate, loopback, and + link-local addresses are omitted. +- **GIVEN** a host with an IPv6 address, **WHEN** the startup banner is printed, **THEN** + the address is rendered as `http://[
]:`. +- **GIVEN** interface enumeration returns an error, **WHEN** the launcher starts, + **THEN** it continues startup and prints the localhost URL without a `network:` line. +- **GIVEN** `KANDEV_SERVER_HOST` is a loopback or specific bind address, **WHEN** the + startup banner is printed, **THEN** it omits unrelated network addresses.