diff --git a/src/presentation/runners/common/runtime_address_info.go b/src/presentation/runners/common/runtime_address_info.go index c9b0905c..98e12ebb 100644 --- a/src/presentation/runners/common/runtime_address_info.go +++ b/src/presentation/runners/common/runtime_address_info.go @@ -4,13 +4,26 @@ import ( "net/netip" clientConfiguration "tungo/infrastructure/PAL/configuration/client" serverConfiguration "tungo/infrastructure/PAL/configuration/server" + "tungo/infrastructure/settings" ) +type RuntimeAddressPair struct { + IPv4 netip.Addr + IPv6 netip.Addr +} + +func (p RuntimeAddressPair) IsValid() bool { + return p.IPv4.IsValid() || p.IPv6.IsValid() +} + +type RuntimeProtocolAddress struct { + Protocol settings.Protocol + ServerAddress RuntimeAddressPair + TunnelAddress RuntimeAddressPair +} + type RuntimeAddressInfo struct { - ServerIPv4 netip.Addr - ServerIPv6 netip.Addr - TunnelIPv4 netip.Addr - TunnelIPv6 netip.Addr + ProtocolAddresses []RuntimeProtocolAddress } func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configuration) RuntimeAddressInfo { @@ -19,43 +32,86 @@ func RuntimeAddressInfoFromClientConfiguration(conf clientConfiguration.Configur if err != nil { return info } - if serverIPv4, ok := activeSettings.Server.IPv4(); ok { - info.ServerIPv4 = serverIPv4 - } - if serverIPv6, ok := activeSettings.Server.IPv6(); ok { - info.ServerIPv6 = serverIPv6 - } - if activeSettings.IPv4.IsValid() { - info.TunnelIPv4 = activeSettings.IPv4 - } - if activeSettings.IPv6.IsValid() { - info.TunnelIPv6 = activeSettings.IPv6 + if protocolAddress, ok := newRuntimeProtocolAddress( + protocolOrFallback(activeSettings.Protocol, conf.Protocol), + runtimeAddressPairFromHost(activeSettings.Server), + runtimeAddressPairFromAddrs(activeSettings.IPv4, activeSettings.IPv6), + ); ok { + info.ProtocolAddresses = append(info.ProtocolAddresses, protocolAddress) } return info } func RuntimeAddressInfoFromServerConfiguration(conf serverConfiguration.Configuration) RuntimeAddressInfo { info := RuntimeAddressInfo{} - for _, s := range conf.EnabledSettings() { - if !info.ServerIPv4.IsValid() { - if serverIPv4, ok := s.Server.IPv4(); ok { - info.ServerIPv4 = serverIPv4 - } - } - if !info.ServerIPv6.IsValid() { - if serverIPv6, ok := s.Server.IPv6(); ok { - info.ServerIPv6 = serverIPv6 - } - } - if !info.TunnelIPv4.IsValid() && s.IPv4.IsValid() { - info.TunnelIPv4 = s.IPv4 - } - if !info.TunnelIPv6.IsValid() && s.IPv6.IsValid() { - info.TunnelIPv6 = s.IPv6 - } - if info.ServerIPv4.IsValid() && info.ServerIPv6.IsValid() && info.TunnelIPv4.IsValid() && info.TunnelIPv6.IsValid() { - break + for _, enabledSetting := range enabledProtocolSettings(conf) { + s := enabledSetting.settings + if protocolAddress, ok := newRuntimeProtocolAddress( + protocolOrFallback(s.Protocol, enabledSetting.protocol), + runtimeAddressPairFromHost(s.Server), + runtimeAddressPairFromAddrs(s.IPv4, s.IPv6), + ); ok { + info.ProtocolAddresses = append(info.ProtocolAddresses, protocolAddress) } } return info } + +type protocolSettings struct { + protocol settings.Protocol + settings settings.Settings +} + +func enabledProtocolSettings(conf serverConfiguration.Configuration) []protocolSettings { + result := make([]protocolSettings, 0, 3) + if conf.EnableTCP { + result = append(result, protocolSettings{protocol: settings.TCP, settings: conf.TCPSettings}) + } + if conf.EnableUDP { + result = append(result, protocolSettings{protocol: settings.UDP, settings: conf.UDPSettings}) + } + if conf.EnableWS { + result = append(result, protocolSettings{protocol: settings.WS, settings: conf.WSSettings}) + } + return result +} + +func runtimeAddressPairFromHost(host settings.Host) RuntimeAddressPair { + var pair RuntimeAddressPair + if ipv4, ok := host.IPv4(); ok { + pair.IPv4 = ipv4 + } + if ipv6, ok := host.IPv6(); ok { + pair.IPv6 = ipv6 + } + return pair +} + +func runtimeAddressPairFromAddrs(ipv4, ipv6 netip.Addr) RuntimeAddressPair { + return RuntimeAddressPair{ + IPv4: ipv4, + IPv6: ipv6, + } +} + +func newRuntimeProtocolAddress( + protocol settings.Protocol, + serverAddress RuntimeAddressPair, + tunnelAddress RuntimeAddressPair, +) (RuntimeProtocolAddress, bool) { + if !serverAddress.IsValid() && !tunnelAddress.IsValid() { + return RuntimeProtocolAddress{}, false + } + return RuntimeProtocolAddress{ + Protocol: protocol, + ServerAddress: serverAddress, + TunnelAddress: tunnelAddress, + }, true +} + +func protocolOrFallback(protocol, fallback settings.Protocol) settings.Protocol { + if protocol == settings.UNKNOWN { + return fallback + } + return protocol +} diff --git a/src/presentation/runners/common/runtime_address_info_test.go b/src/presentation/runners/common/runtime_address_info_test.go index 41f2ba63..35074a38 100644 --- a/src/presentation/runners/common/runtime_address_info_test.go +++ b/src/presentation/runners/common/runtime_address_info_test.go @@ -23,17 +23,23 @@ func TestRuntimeAddressInfoFromClientConfiguration(t *testing.T) { } got := RuntimeAddressInfoFromClientConfiguration(cfg) - if got.ServerIPv4 != netip.MustParseAddr("198.51.100.10") { - t.Fatalf("ServerIPv4: got %v", got.ServerIPv4) + if len(got.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry, got %d", len(got.ProtocolAddresses)) } - if got.ServerIPv6 != netip.MustParseAddr("2001:db8::10") { - t.Fatalf("ServerIPv6: got %v", got.ServerIPv6) + if got.ProtocolAddresses[0].Protocol != settings.TCP { + t.Fatalf("ProtocolAddresses[0].Protocol: got %v", got.ProtocolAddresses[0].Protocol) } - if got.TunnelIPv4 != netip.MustParseAddr("10.0.0.2") { - t.Fatalf("TunnelIPv4: got %v", got.TunnelIPv4) + if got.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv4: got %v", got.ProtocolAddresses[0].ServerAddress.IPv4) } - if got.TunnelIPv6 != netip.MustParseAddr("fd00::2") { - t.Fatalf("TunnelIPv6: got %v", got.TunnelIPv6) + if got.ProtocolAddresses[0].ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::10") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv6: got %v", got.ProtocolAddresses[0].ServerAddress.IPv6) + } + if got.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.0.2") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv4: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv4) + } + if got.ProtocolAddresses[0].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::2") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv6: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv6) } } @@ -52,17 +58,66 @@ func TestRuntimeAddressInfoFromServerConfiguration(t *testing.T) { } got := RuntimeAddressInfoFromServerConfiguration(cfg) - if got.ServerIPv4 != netip.MustParseAddr("203.0.113.10") { - t.Fatalf("ServerIPv4: got %v", got.ServerIPv4) + if len(got.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry, got %d", len(got.ProtocolAddresses)) + } + if got.ProtocolAddresses[0].Protocol != settings.UDP { + t.Fatalf("ProtocolAddresses[0].Protocol: got %v", got.ProtocolAddresses[0].Protocol) + } + if got.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("203.0.113.10") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv4: got %v", got.ProtocolAddresses[0].ServerAddress.IPv4) } - if got.ServerIPv6 != netip.MustParseAddr("2001:db8::20") { - t.Fatalf("ServerIPv6: got %v", got.ServerIPv6) + if got.ProtocolAddresses[0].ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::20") { + t.Fatalf("ProtocolAddresses[0].ServerAddress.IPv6: got %v", got.ProtocolAddresses[0].ServerAddress.IPv6) } - if got.TunnelIPv4 != netip.MustParseAddr("10.1.0.1") { - t.Fatalf("TunnelIPv4: got %v", got.TunnelIPv4) + if got.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.1.0.1") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv4: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv4) } - if got.TunnelIPv6 != netip.MustParseAddr("fd01::1") { - t.Fatalf("TunnelIPv6: got %v", got.TunnelIPv6) + if got.ProtocolAddresses[0].TunnelAddress.IPv6 != netip.MustParseAddr("fd01::1") { + t.Fatalf("ProtocolAddresses[0].TunnelAddress.IPv6: got %v", got.ProtocolAddresses[0].TunnelAddress.IPv6) + } +} + +func TestRuntimeAddressInfoFromServerConfiguration_CollectsAllEnabledProtocolAddresses(t *testing.T) { + cfg := serverConfiguration.Configuration{ + EnableTCP: true, + EnableUDP: true, + EnableWS: true, + TCPSettings: settings.Settings{ + Protocol: settings.TCP, + Addressing: settings.Addressing{ + IPv4: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + }, + UDPSettings: settings.Settings{ + Protocol: settings.UDP, + Addressing: settings.Addressing{ + IPv4: netip.MustParseAddr("10.0.1.1"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + }, + WSSettings: settings.Settings{ + Protocol: settings.WS, + Addressing: settings.Addressing{ + IPv4: netip.MustParseAddr("10.0.2.1"), + IPv6: netip.MustParseAddr("fd00::3"), + }, + }, + } + + got := RuntimeAddressInfoFromServerConfiguration(cfg) + if len(got.ProtocolAddresses) != 3 { + t.Fatalf("expected three protocol address entries, got %d", len(got.ProtocolAddresses)) + } + if got.ProtocolAddresses[0].Protocol != settings.TCP || got.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.0.1") || got.ProtocolAddresses[0].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::1") { + t.Fatalf("unexpected TCP protocol address entry: %+v", got.ProtocolAddresses[0]) + } + if got.ProtocolAddresses[1].Protocol != settings.UDP || got.ProtocolAddresses[1].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.1.1") || got.ProtocolAddresses[1].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::2") { + t.Fatalf("unexpected UDP protocol address entry: %+v", got.ProtocolAddresses[1]) + } + if got.ProtocolAddresses[2].Protocol != settings.WS || got.ProtocolAddresses[2].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.2.1") || got.ProtocolAddresses[2].TunnelAddress.IPv6 != netip.MustParseAddr("fd00::3") { + t.Fatalf("unexpected WS protocol address entry: %+v", got.ProtocolAddresses[2]) } } @@ -72,7 +127,49 @@ func TestRuntimeAddressInfoFromClientConfiguration_ActiveSettingsErrorReturnsZer } got := RuntimeAddressInfoFromClientConfiguration(cfg) - if got.ServerIPv4.IsValid() || got.ServerIPv6.IsValid() || got.TunnelIPv4.IsValid() || got.TunnelIPv6.IsValid() { - t.Fatalf("expected zero info when active settings resolution fails, got %+v", got) + if len(got.ProtocolAddresses) != 0 { + t.Fatalf("expected no protocol address entries when active settings resolution fails, got %d", len(got.ProtocolAddresses)) + } +} + +func TestRuntimeAddressInfoFromServerConfiguration_PreservesPerProtocolServerAddresses(t *testing.T) { + cfg := serverConfiguration.Configuration{ + EnableTCP: true, + EnableUDP: true, + TCPSettings: settings.Settings{ + Protocol: settings.TCP, + Addressing: settings.Addressing{ + Server: settings.Host{}.WithIPv4(netip.MustParseAddr("198.51.100.10")), + IPv4: netip.MustParseAddr("10.0.0.1"), + }, + }, + UDPSettings: settings.Settings{ + Protocol: settings.UDP, + Addressing: settings.Addressing{ + Server: settings.Host{}.WithIPv6(netip.MustParseAddr("2001:db8::20")), + IPv4: netip.MustParseAddr("10.0.1.1"), + }, + }, + } + + got := RuntimeAddressInfoFromServerConfiguration(cfg) + if len(got.ProtocolAddresses) != 2 { + t.Fatalf("expected two protocol address entries, got %d", len(got.ProtocolAddresses)) + } + if got.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") || got.ProtocolAddresses[0].ServerAddress.IPv6.IsValid() { + t.Fatalf("unexpected TCP server address: %+v", got.ProtocolAddresses[0].ServerAddress) + } + if got.ProtocolAddresses[1].ServerAddress.IPv6 != netip.MustParseAddr("2001:db8::20") || got.ProtocolAddresses[1].ServerAddress.IPv4.IsValid() { + t.Fatalf("unexpected UDP server address: %+v", got.ProtocolAddresses[1].ServerAddress) + } +} + +func TestNewRuntimeProtocolAddress_InvalidAddressesReturnFalse(t *testing.T) { + got, ok := newRuntimeProtocolAddress(settings.TCP, RuntimeAddressPair{}, RuntimeAddressPair{}) + if ok { + t.Fatal("expected invalid address pairs to be rejected") + } + if got != (RuntimeProtocolAddress{}) { + t.Fatalf("expected zero protocol address on invalid input, got %+v", got) } } diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go index 1716b83c..3eeb5c88 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard.go @@ -3,11 +3,11 @@ package bubble_tea import ( "context" "errors" - "net/netip" "strings" "time" "tungo/infrastructure/settings" "tungo/infrastructure/telemetry/trafficstats" + runnerCommon "tungo/presentation/runners/common" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -21,15 +21,12 @@ const ( ) type RuntimeDashboardOptions struct { - Mode RuntimeDashboardMode - LogFeed RuntimeLogFeed - ServerSupported bool - ReadyCh <-chan struct{} - Protocol settings.Protocol - ServerIPv4 netip.Addr - ServerIPv6 netip.Addr - TunnelIPv4 netip.Addr - TunnelIPv6 netip.Addr + Mode RuntimeDashboardMode + LogFeed RuntimeLogFeed + ServerSupported bool + ReadyCh <-chan struct{} + Protocol settings.Protocol + ProtocolAddresses []runnerCommon.RuntimeProtocolAddress } type runtimeTickMsg struct { @@ -92,10 +89,7 @@ type RuntimeDashboard struct { readyCh <-chan struct{} connected bool protocol settings.Protocol - serverIPv4 netip.Addr - serverIPv6 netip.Addr - tunnelIPv4 netip.Addr - tunnelIPv6 netip.Addr + protocolAddresses []runnerCommon.RuntimeProtocolAddress } type runtimeDashboardProgram interface { @@ -129,23 +123,20 @@ func NewRuntimeDashboard(ctx context.Context, options RuntimeDashboardOptions, s } } model := RuntimeDashboard{ - settings: settings, - ctx: ctx, - mode: mode, - serverSupported: options.ServerSupported, - keys: defaultSelectorKeyMap(), - screen: runtimeScreenDataplane, - preferences: settings.Preferences(), - logFeed: options.LogFeed, - logs: newLogViewport(), - tickSeq: 1, - readyCh: readyCh, - connected: connected, - protocol: options.Protocol, - serverIPv4: options.ServerIPv4, - serverIPv6: options.ServerIPv6, - tunnelIPv4: options.TunnelIPv4, - tunnelIPv6: options.TunnelIPv6, + settings: settings, + ctx: ctx, + mode: mode, + serverSupported: options.ServerSupported, + keys: defaultSelectorKeyMap(), + screen: runtimeScreenDataplane, + preferences: settings.Preferences(), + logFeed: options.LogFeed, + logs: newLogViewport(), + tickSeq: 1, + readyCh: readyCh, + connected: connected, + protocol: options.Protocol, + protocolAddresses: options.ProtocolAddresses, } if model.preferences.ShowDataplaneGraph { model.recordTrafficSample(trafficstats.SnapshotGlobal()) @@ -395,11 +386,11 @@ func (m RuntimeDashboard) mainView() string { if protocol := m.protocolLine(); protocol != "" { body = append(body, protocol) } - if connectedTo := m.connectedToLine(); connectedTo != "" { - body = append(body, connectedTo) + if serverLines := m.serverAddressLines(); len(serverLines) > 0 { + body = append(body, serverLines...) } - if tunnelIP := m.tunnelIPLine(); tunnelIP != "" { - body = append(body, tunnelIP) + if tunnelLines := m.tunnelIPLines(); len(tunnelLines) > 0 { + body = append(body, tunnelLines...) } if m.preferences.ShowDataplaneStats || m.preferences.ShowDataplaneGraph { body = append(body, "") @@ -443,32 +434,50 @@ func (m RuntimeDashboard) mainView() string { ) } -func (m RuntimeDashboard) connectedToLine() string { - if !m.serverIPv4.IsValid() && !m.serverIPv6.IsValid() { - return "" +func (m RuntimeDashboard) serverAddressLines() []string { + if len(m.protocolAddresses) == 0 { + return nil } - parts := make([]string, 0, 2) - if m.serverIPv4.IsValid() { - parts = append(parts, "IPv4 "+m.serverIPv4.String()) + if m.mode == RuntimeDashboardServer && len(m.protocolAddresses) > 1 { + if sharedAddress, ok := sharedServerAddress(m.protocolAddresses); ok { + return []string{formatRuntimeAddressLine("Server IP", sharedAddress)} + } + lines := []string{"Server IPs:"} + for _, protocolAddress := range m.protocolAddresses { + if line := formatRuntimeProtocolAddress(protocolAddress.Protocol, protocolAddress.ServerAddress); line != "" { + lines = append(lines, " "+line) + } + } + if len(lines) > 1 { + return lines + } + return nil } - if m.serverIPv6.IsValid() { - parts = append(parts, "IPv6 "+m.serverIPv6.String()) + if line := formatRuntimeAddressLine("Server IP", m.protocolAddresses[0].ServerAddress); line != "" { + return []string{line} } - return "Server IP: " + strings.Join(parts, " | ") + return nil } -func (m RuntimeDashboard) tunnelIPLine() string { - if !m.tunnelIPv4.IsValid() && !m.tunnelIPv6.IsValid() { - return "" +func (m RuntimeDashboard) tunnelIPLines() []string { + if len(m.protocolAddresses) == 0 { + return nil } - parts := make([]string, 0, 2) - if m.tunnelIPv4.IsValid() { - parts = append(parts, "IPv4 "+m.tunnelIPv4.String()) + if m.mode == RuntimeDashboardServer && len(m.protocolAddresses) > 1 { + lines := []string{"Tunnel IPs:"} + for _, protocolAddress := range m.protocolAddresses { + if line := formatRuntimeProtocolAddress(protocolAddress.Protocol, protocolAddress.TunnelAddress); line != "" { + lines = append(lines, " "+line) + } + } + if len(lines) > 1 { + return lines + } } - if m.tunnelIPv6.IsValid() { - parts = append(parts, "IPv6 "+m.tunnelIPv6.String()) + if tunnelIP := formatRuntimeAddressLine("Tunnel IP", m.protocolAddresses[0].TunnelAddress); tunnelIP != "" { + return []string{tunnelIP} } - return "Tunnel IP: " + strings.Join(parts, " | ") + return nil } func (m RuntimeDashboard) protocolLine() string { @@ -478,6 +487,52 @@ func (m RuntimeDashboard) protocolLine() string { return "Protocol: " + m.protocol.String() } +func formatRuntimeAddressLine(label string, address runnerCommon.RuntimeAddressPair) string { + parts := formatRuntimeAddressParts(address) + if parts == "" { + return "" + } + return label + ": " + parts +} + +func formatRuntimeAddressParts(address runnerCommon.RuntimeAddressPair) string { + parts := make([]string, 0, 2) + if address.IPv4.IsValid() { + parts = append(parts, "IPv4 "+address.IPv4.String()) + } + if address.IPv6.IsValid() { + parts = append(parts, "IPv6 "+address.IPv6.String()) + } + return strings.Join(parts, " | ") +} + +func formatRuntimeProtocolAddress(protocol settings.Protocol, address runnerCommon.RuntimeAddressPair) string { + parts := formatRuntimeAddressParts(address) + if parts == "" { + return "" + } + if protocol == settings.UNKNOWN { + return parts + } + return protocol.String() + ": " + parts +} + +func sharedServerAddress(protocolAddresses []runnerCommon.RuntimeProtocolAddress) (runnerCommon.RuntimeAddressPair, bool) { + if len(protocolAddresses) == 0 { + return runnerCommon.RuntimeAddressPair{}, false + } + sharedAddress := protocolAddresses[0].ServerAddress + if !sharedAddress.IsValid() { + return runnerCommon.RuntimeAddressPair{}, false + } + for _, protocolAddress := range protocolAddresses[1:] { + if protocolAddress.ServerAddress != sharedAddress { + return runnerCommon.RuntimeAddressPair{}, false + } + } + return sharedAddress, true +} + func (m RuntimeDashboard) stopActionLabel() string { if m.mode == RuntimeDashboardClient && m.preferences.AutoConnect { return "Stop (AutoConnect will be disabled)" diff --git a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go index 8a2d5c83..382a22e4 100644 --- a/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go +++ b/src/presentation/ui/tui/internal/bubble_tea/runtime_dashboard_test.go @@ -10,6 +10,7 @@ import ( "time" "tungo/infrastructure/settings" "tungo/infrastructure/telemetry/trafficstats" + runnerCommon "tungo/presentation/runners/common" "unicode/utf8" tea "charm.land/bubbletea/v2" @@ -623,11 +624,18 @@ func TestRuntimeDashboard_MainView_ServerAndFooterOff(t *testing.T) { func TestRuntimeDashboard_MainView_ShowsServerAndNetworkAddresses(t *testing.T) { m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ - Protocol: settings.UDP, - ServerIPv4: netip.MustParseAddr("198.51.100.10"), - ServerIPv6: netip.MustParseAddr("2001:db8::10"), - TunnelIPv4: netip.MustParseAddr("10.0.0.2"), - TunnelIPv6: netip.MustParseAddr("fd00::2"), + Protocol: settings.UDP, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + IPv6: netip.MustParseAddr("2001:db8::10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + }}, }, testSettings()) view := m.View().Content if !strings.Contains(view, "Protocol: UDP") { @@ -641,6 +649,101 @@ func TestRuntimeDashboard_MainView_ShowsServerAndNetworkAddresses(t *testing.T) } } +func TestRuntimeDashboard_MainView_ServerShowsTunnelAddressesPerProtocol(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + Mode: RuntimeDashboardServer, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.1.1"), + IPv6: netip.MustParseAddr("fd00::2"), + }, + }, + { + Protocol: settings.WS, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.2.1"), + IPv6: netip.MustParseAddr("fd00::3"), + }, + }, + }, + }, testSettings()) + + view := m.View().Content + if !strings.Contains(view, "Tunnel IPs:") { + t.Fatalf("expected tunnel addresses section in server main view, got %q", view) + } + if !strings.Contains(view, "Server IP: IPv4 198.51.100.10") { + t.Fatalf("expected shared server IP line in server main view, got %q", view) + } + if !strings.Contains(view, "TCP: IPv4 10.0.0.1 | IPv6 fd00::1") { + t.Fatalf("expected TCP tunnel line in server main view, got %q", view) + } + if !strings.Contains(view, "UDP: IPv4 10.0.1.1 | IPv6 fd00::2") { + t.Fatalf("expected UDP tunnel line in server main view, got %q", view) + } + if !strings.Contains(view, "WS: IPv4 10.0.2.1 | IPv6 fd00::3") { + t.Fatalf("expected WS tunnel line in server main view, got %q", view) + } +} + +func TestRuntimeDashboard_MainView_ServerShowsServerAddressesPerProtocolWhenDifferent(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + Mode: RuntimeDashboardServer, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.1"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv6: netip.MustParseAddr("2001:db8::20"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.1.1"), + }, + }, + }, + }, testSettings()) + + view := m.View().Content + if !strings.Contains(view, "Server IPs:") { + t.Fatalf("expected per-protocol server addresses section, got %q", view) + } + if !strings.Contains(view, "TCP: IPv4 198.51.100.10") { + t.Fatalf("expected TCP server line, got %q", view) + } + if !strings.Contains(view, "UDP: IPv6 2001:db8::20") { + t.Fatalf("expected UDP server line, got %q", view) + } + if strings.Contains(view, "Server IP: IPv4 198.51.100.10 | IPv6 2001:db8::20") { + t.Fatalf("unexpected merged server IP line, got %q", view) + } +} + func TestRuntimeDashboard_MainView_CanHideStatsAndGraph(t *testing.T) { s := testSettings() p := s.Preferences() @@ -1771,3 +1874,105 @@ func TestWaitForReadyCh_ContextCanceled_ReturnsContextDoneMsg(t *testing.T) { t.Fatalf("expected seq=7, got %d", done.seq) } } + +func TestRuntimeDashboard_TunnelIPLines_InvalidSingleAddressReturnsNil(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.TCP, + TunnelAddress: runnerCommon.RuntimeAddressPair{}, + }}, + }, testSettings()) + + if got := m.tunnelIPLines(); got != nil { + t.Fatalf("expected nil lines for invalid single tunnel address, got %#v", got) + } +} + +func TestRuntimeDashboard_ServerAddressLines_InvalidSharedAddressReturnsNil(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + Mode: RuntimeDashboardServer, + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{ + {Protocol: settings.TCP}, + {Protocol: settings.UDP}, + }, + }, testSettings()) + + if got := m.serverAddressLines(); got != nil { + t.Fatalf("expected nil lines for invalid shared server addresses, got %#v", got) + } +} + +func TestRuntimeDashboard_ServerAddressLines_InvalidSingleAddressReturnsNil(t *testing.T) { + m := NewRuntimeDashboard(context.Background(), RuntimeDashboardOptions{ + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.TCP, + }}, + }, testSettings()) + + if got := m.serverAddressLines(); got != nil { + t.Fatalf("expected nil lines for invalid single server address, got %#v", got) + } +} + +func TestFormatRuntimeProtocolAddress_EmptyAddressReturnsEmpty(t *testing.T) { + if got := formatRuntimeProtocolAddress(settings.TCP, runnerCommon.RuntimeAddressPair{}); got != "" { + t.Fatalf("expected empty line for invalid runtime address, got %q", got) + } +} + +func TestFormatRuntimeProtocolAddress_UnknownProtocolOmitsProtocolLabel(t *testing.T) { + got := formatRuntimeProtocolAddress(settings.UNKNOWN, runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + }) + if got != "IPv4 10.0.0.2 | IPv6 fd00::2" { + t.Fatalf("unexpected unknown-protocol line: %q", got) + } +} + +func TestSharedServerAddress_RequiresExactMatch(t *testing.T) { + if _, ok := sharedServerAddress(nil); ok { + t.Fatal("expected empty protocol address list to have no shared server address") + } + + if _, ok := sharedServerAddress([]runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + IPv6: netip.MustParseAddr("2001:db8::20"), + }, + }, + }); ok { + t.Fatal("expected mixed server address pairs to be treated as different") + } + + shared, ok := sharedServerAddress([]runnerCommon.RuntimeProtocolAddress{ + { + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + }, + { + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + }, + }) + if !ok { + t.Fatal("expected identical server address pairs to be shared") + } + if shared != (runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }) { + t.Fatalf("unexpected shared server address: %+v", shared) + } +} diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea.go b/src/presentation/ui/tui/runtime_backend_bubbletea.go index bfe48795..8a25e14f 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea.go @@ -39,14 +39,11 @@ func (b *bubbleTeaRuntimeBackend) disableRuntimeLogCapture() { func (b *bubbleTeaRuntimeBackend) runRuntimeDashboard(ctx context.Context, mode RuntimeMode, options RuntimeUIOptions) (bool, error) { dashboardOptions := bubbleTea.RuntimeDashboardOptions{ - Mode: bubbleTea.RuntimeDashboardClient, - LogFeed: bubbleRuntimeLogFeed(), - ReadyCh: options.ReadyCh, - Protocol: options.Protocol, - ServerIPv4: options.Address.ServerIPv4, - ServerIPv6: options.Address.ServerIPv6, - TunnelIPv4: options.Address.TunnelIPv4, - TunnelIPv6: options.Address.TunnelIPv6, + Mode: bubbleTea.RuntimeDashboardClient, + LogFeed: bubbleRuntimeLogFeed(), + ReadyCh: options.ReadyCh, + Protocol: options.Protocol, + ProtocolAddresses: options.Address.ProtocolAddresses, } if mode == RuntimeModeServer { dashboardOptions.Mode = bubbleTea.RuntimeDashboardServer diff --git a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go index ee443df5..102dbdd7 100644 --- a/src/presentation/ui/tui/runtime_backend_bubbletea_test.go +++ b/src/presentation/ui/tui/runtime_backend_bubbletea_test.go @@ -53,11 +53,17 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { if options.LogFeed != feed { t.Fatal("expected runtime log feed to be forwarded") } - if options.ServerIPv4 != netip.MustParseAddr("198.51.100.10") { - t.Fatalf("expected ServerIPv4 forwarded, got %v", options.ServerIPv4) + if len(options.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry forwarded, got %d", len(options.ProtocolAddresses)) } - if options.TunnelIPv4 != netip.MustParseAddr("10.0.0.2") { - t.Fatalf("expected TunnelIPv4 forwarded, got %v", options.TunnelIPv4) + if options.ProtocolAddresses[0].Protocol != settings.TCP { + t.Fatalf("expected protocol address protocol TCP, got %v", options.ProtocolAddresses[0].Protocol) + } + if options.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.10") { + t.Fatalf("expected server IPv4 forwarded, got %v", options.ProtocolAddresses[0].ServerAddress.IPv4) + } + if options.ProtocolAddresses[0].TunnelAddress.IPv4 != netip.MustParseAddr("10.0.0.2") { + t.Fatalf("expected tunnel IPv4 forwarded, got %v", options.ProtocolAddresses[0].TunnelAddress.IPv4) } if options.Protocol != settings.TCP { t.Fatalf("expected protocol forwarded, got %v", options.Protocol) @@ -66,8 +72,15 @@ func TestBubbleTeaRuntimeBackend_MappingAndHooks(t *testing.T) { } reconfigure, err := backend.runRuntimeDashboard(context.Background(), RuntimeModeServer, RuntimeUIOptions{ Address: runnerCommon.RuntimeAddressInfo{ - ServerIPv4: netip.MustParseAddr("198.51.100.10"), - TunnelIPv4: netip.MustParseAddr("10.0.0.2"), + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.TCP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.10"), + }, + TunnelAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("10.0.0.2"), + }, + }}, }, Protocol: settings.TCP, }) diff --git a/src/presentation/ui/tui/runtime_ui_test.go b/src/presentation/ui/tui/runtime_ui_test.go index 98795098..3d574c41 100644 --- a/src/presentation/ui/tui/runtime_ui_test.go +++ b/src/presentation/ui/tui/runtime_ui_test.go @@ -49,8 +49,11 @@ func TestRuntimeUI_Wrappers(t *testing.T) { if mode != RuntimeModeServer { t.Fatalf("expected server mode mapping, got %q", mode) } - if options.Address.ServerIPv4 != netip.MustParseAddr("198.51.100.1") { - t.Fatalf("expected forwarded server IPv4, got %v", options.Address.ServerIPv4) + if len(options.Address.ProtocolAddresses) != 1 { + t.Fatalf("expected one protocol address entry, got %d", len(options.Address.ProtocolAddresses)) + } + if options.Address.ProtocolAddresses[0].ServerAddress.IPv4 != netip.MustParseAddr("198.51.100.1") { + t.Fatalf("expected forwarded server IPv4, got %v", options.Address.ProtocolAddresses[0].ServerAddress.IPv4) } if options.Protocol != settings.UDP { t.Fatalf("expected forwarded protocol UDP, got %v", options.Protocol) @@ -59,7 +62,12 @@ func TestRuntimeUI_Wrappers(t *testing.T) { } quit, err := RunRuntimeDashboard(context.Background(), RuntimeModeServer, RuntimeUIOptions{ Address: runnerCommon.RuntimeAddressInfo{ - ServerIPv4: netip.MustParseAddr("198.51.100.1"), + ProtocolAddresses: []runnerCommon.RuntimeProtocolAddress{{ + Protocol: settings.UDP, + ServerAddress: runnerCommon.RuntimeAddressPair{ + IPv4: netip.MustParseAddr("198.51.100.1"), + }, + }}, }, Protocol: settings.UDP, })