fix(launcher): restore network address startup logging (#2619)

This commit is contained in:
Carlos Florêncio
2026-08-13 12:11:48 +01:00
committed by GitHub
parent 3e7cb9097d
commit 5dff29d61e
7 changed files with 531 additions and 1 deletions
+106
View File
@@ -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
}
@@ -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()
}
+4
View File
@@ -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)
@@ -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.
@@ -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.
+3 -1
View File
@@ -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:<port>`, 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:<port>`. 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
+29
View File
@@ -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
`<KANDEV_HOME_DIR>/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://[<address>]:<port>`.
- **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.