diff --git a/.changeset/ipv6-endpoint-formatting.md b/.changeset/ipv6-endpoint-formatting.md new file mode 100644 index 00000000..66b9133a --- /dev/null +++ b/.changeset/ipv6-endpoint-formatting.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Format bare and already-bracketed IPv6 literals, including Nova zone IDs, correctly for CTEK, Nova, and driver-fingerprint endpoints. diff --git a/go/internal/api/api_drivers_fingerprint.go b/go/internal/api/api_drivers_fingerprint.go index 297225e5..84e28b9f 100644 --- a/go/internal/api/api_drivers_fingerprint.go +++ b/go/internal/api/api_drivers_fingerprint.go @@ -184,7 +184,7 @@ func (s *Server) fingerprintOne(luaPath, protocol, host string, port, unit int) } defer cap.Close() env := drivers.NewHostEnv("__fingerprint", telemetry.NewStore()).WithModbus(cap) - env.SetEndpoint(fmt.Sprintf("modbus://%s:%d", host, port)) + env.SetEndpoint(fingerprintEndpoint("modbus", host, port)) fp, _ := drivers.RunFingerprint(luaPath, env, target) return fp case "http": @@ -194,7 +194,7 @@ func (s *Server) fingerprintOne(luaPath, protocol, host string, port, unit int) allowedEndpoint := net.JoinHostPort(host, strconv.Itoa(port)) env := drivers.NewHostEnv("__fingerprint", telemetry.NewStore()). WithHTTP().WithHTTPAllowedHosts([]string{allowedEndpoint}) - env.SetEndpoint(fmt.Sprintf("http://%s:%d", host, port)) + env.SetEndpoint(fingerprintEndpoint("http", host, port)) fp, _ := drivers.RunFingerprint(luaPath, env, target) return fp default: @@ -202,6 +202,10 @@ func (s *Server) fingerprintOne(luaPath, protocol, host string, port, unit int) } } +func fingerprintEndpoint(scheme, host string, port int) string { + return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(port)) +} + // normalizeFingerprintHost accepts a bare IP address or DNS/mDNS hostname. // URL components, userinfo, and embedded ports are rejected so the target // table and HTTP allowlist always describe exactly one endpoint. diff --git a/go/internal/api/api_drivers_fingerprint_test.go b/go/internal/api/api_drivers_fingerprint_test.go index 83b86543..467a049f 100644 --- a/go/internal/api/api_drivers_fingerprint_test.go +++ b/go/internal/api/api_drivers_fingerprint_test.go @@ -98,6 +98,15 @@ func TestHandleDriverFingerprintRequiresModbusFactory(t *testing.T) { } } +func TestFingerprintEndpointFormatsIPv6(t *testing.T) { + if got := fingerprintEndpoint("modbus", "fd00::5", 502); got != "modbus://[fd00::5]:502" { + t.Fatalf("Modbus endpoint = %q, want modbus://[fd00::5]:502", got) + } + if got := fingerprintEndpoint("http", "fd00::5", 80); got != "http://[fd00::5]:80" { + t.Fatalf("HTTP endpoint = %q, want http://[fd00::5]:80", got) + } +} + func TestHandleDriverFingerprintRanksMatches(t *testing.T) { dir := t.TempDir() // Two Modbus drivers — one claims the device, one declines — plus a diff --git a/go/internal/evcloud/ctek.go b/go/internal/evcloud/ctek.go index 306f4194..543b5b48 100644 --- a/go/internal/evcloud/ctek.go +++ b/go/internal/evcloud/ctek.go @@ -3,6 +3,8 @@ package evcloud import ( "errors" "fmt" + "net" + "strconv" "strings" "time" @@ -105,7 +107,7 @@ func (c *CTEK) ListChargers(cfg *config.EVCharger) ([]Charger, error) { cli, err := c.dial(host, port, unitID) if err != nil { - return nil, fmt.Errorf("ctek: dial %s:%d (unit %d): %w", host, port, unitID, err) + return nil, fmt.Errorf("ctek: dial %s (unit %d): %w", ctekAddress(host, port), unitID, err) } defer cli.Close() @@ -146,7 +148,7 @@ func decodeCTEKSerial(regs []uint16) string { // via simonvetter, sets the unit ID, and returns a thin adapter that // implements ctekClient. func ctekDialReal(host string, port, unitID int) (ctekClient, error) { - url := fmt.Sprintf("tcp://%s:%d", host, port) + url := "tcp://" + ctekAddress(host, port) cli, err := sv.NewClient(&sv.ClientConfiguration{ URL: url, Timeout: ctekProbeTimeout, @@ -163,6 +165,25 @@ func ctekDialReal(host string, port, unitID int) (ctekClient, error) { return &ctekRealClient{cli: cli}, nil } +func ctekAddress(host string, port int) string { + return net.JoinHostPort(unbracketIPv6Host(host), strconv.Itoa(port)) +} + +func unbracketIPv6Host(host string) string { + if len(host) < 2 || host[0] != '[' || host[len(host)-1] != ']' { + return host + } + inner := host[1 : len(host)-1] + address := inner + if zone := strings.LastIndexByte(address, '%'); zone >= 0 { + address = address[:zone] + } + if strings.Contains(address, ":") && net.ParseIP(address) != nil { + return inner + } + return host +} + type ctekRealClient struct{ cli *sv.ModbusClient } func (r *ctekRealClient) ReadHolding(addr, count uint16) ([]uint16, error) { diff --git a/go/internal/evcloud/ctek_test.go b/go/internal/evcloud/ctek_test.go index d7449f62..90216beb 100644 --- a/go/internal/evcloud/ctek_test.go +++ b/go/internal/evcloud/ctek_test.go @@ -155,3 +155,25 @@ func TestCTekDescribe(t *testing.T) { t.Errorf("defaults: got port=%d unit=%d, want 502/1", d.DefaultPort, d.DefaultUnitID) } } + +func TestCTekAddressFormatsHost(t *testing.T) { + cases := []struct { + name string + host string + want string + }{ + {name: "bare IPv6", host: "fd00::5", want: "[fd00::5]:1502"}, + {name: "bracketed IPv6", host: "[fd00::5]", want: "[fd00::5]:1502"}, + {name: "raw zone ID", host: "fe80::5%en0", want: "[fe80::5%en0]:1502"}, + {name: "bracketed raw zone ID", host: "[fe80::5%en0]", want: "[fe80::5%en0]:1502"}, + {name: "IPv4", host: "10.0.0.5", want: "10.0.0.5:1502"}, + {name: "hostname", host: "charger.local", want: "charger.local:1502"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ctekAddress(tc.host, 1502); got != tc.want { + t.Fatalf("address = %q, want %s", got, tc.want) + } + }) + } +} diff --git a/go/internal/nova/publisher.go b/go/internal/nova/publisher.go index 8bbd77e1..4d392194 100644 --- a/go/internal/nova/publisher.go +++ b/go/internal/nova/publisher.go @@ -5,6 +5,9 @@ import ( "encoding/json" "fmt" "log/slog" + "net" + "strconv" + "strings" "sync" "time" @@ -88,7 +91,7 @@ func Start(cfg *config.Nova, id *Identity, store *state.Store, tel *telemetry.St scheme = "ssl" } opts := paho.NewClientOptions(). - AddBroker(fmt.Sprintf("%s://%s:%d", scheme, cfg.MQTTHost, cfg.MQTTPort)). + AddBroker(novaBrokerURL(scheme, cfg.MQTTHost, cfg.MQTTPort)). SetClientID("ftw-nova-" + sanitizeTopicSegment(cfg.GatewaySerial)). SetAutoReconnect(true). SetConnectRetry(true). @@ -106,7 +109,7 @@ func Start(cfg *config.Nova, id *Identity, store *state.Store, tel *telemetry.St }). SetOnConnectHandler(func(_ paho.Client) { slog.Info("nova MQTT connected", - "broker", fmt.Sprintf("%s:%d", cfg.MQTTHost, cfg.MQTTPort), + "broker", novaBrokerAddress(cfg.MQTTHost, cfg.MQTTPort), "gateway_serial", cfg.GatewaySerial) p.requestDriverInventory() }). @@ -122,6 +125,38 @@ func Start(cfg *config.Nova, id *Identity, store *state.Store, tel *telemetry.St return p, nil } +func novaBrokerAddress(host string, port int) string { + return net.JoinHostPort(unbracketIPv6Host(host), strconv.Itoa(port)) +} + +func novaBrokerURL(scheme, host string, port int) string { + host = encodeIPv6ZoneForURL(unbracketIPv6Host(host)) + return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(port)) +} + +func unbracketIPv6Host(host string) string { + if len(host) < 2 || host[0] != '[' || host[len(host)-1] != ']' { + return host + } + inner := host[1 : len(host)-1] + address := inner + if zone := strings.LastIndexByte(address, '%'); zone >= 0 { + address = address[:zone] + } + if strings.Contains(address, ":") && net.ParseIP(address) != nil { + return inner + } + return host +} + +func encodeIPv6ZoneForURL(host string) string { + percent := strings.IndexByte(host, '%') + if percent < 0 || !strings.Contains(host[:percent], ":") || strings.HasPrefix(host[percent:], "%25") { + return host + } + return host[:percent] + "%25" + host[percent+1:] +} + // Stop shuts down the publish loop and disconnects from the broker. // Idempotent. func (p *Publisher) Stop() { diff --git a/go/internal/nova/publisher_test.go b/go/internal/nova/publisher_test.go index f0ac7d5f..256ea042 100644 --- a/go/internal/nova/publisher_test.go +++ b/go/internal/nova/publisher_test.go @@ -2,6 +2,7 @@ package nova import ( "encoding/json" + "net/url" "testing" "time" @@ -43,6 +44,43 @@ func TestDriverInventoryContentSHAIgnoresGeneratedAt(t *testing.T) { } } +func TestNovaBrokerURLFormatsIPv6(t *testing.T) { + cases := []struct { + name string + host string + want string + }{ + {name: "IPv4", host: "10.0.0.5", want: "ssl://10.0.0.5:8883"}, + {name: "hostname", host: "mqtt.local", want: "ssl://mqtt.local:8883"}, + {name: "bare IPv6", host: "fd00::5", want: "ssl://[fd00::5]:8883"}, + {name: "bracketed IPv6", host: "[fd00::5]", want: "ssl://[fd00::5]:8883"}, + {name: "raw zone ID", host: "fe80::5%en0", want: "ssl://[fe80::5%25en0]:8883"}, + {name: "bracketed raw zone ID", host: "[fe80::5%en0]", want: "ssl://[fe80::5%25en0]:8883"}, + {name: "already encoded zone ID", host: "fe80::5%25en0", want: "ssl://[fe80::5%25en0]:8883"}, + {name: "bracketed encoded zone ID", host: "[fe80::5%25en0]", want: "ssl://[fe80::5%25en0]:8883"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := novaBrokerURL("ssl", tc.host, 8883) + if got != tc.want { + t.Fatalf("broker URL = %q, want %s", got, tc.want) + } + if _, err := url.Parse(got); err != nil { + t.Fatalf("broker URL does not parse: %v", err) + } + }) + } + if got := novaBrokerAddress("[fd00::5]", 1883); got != "[fd00::5]:1883" { + t.Fatalf("bracketed broker address = %q, want [fd00::5]:1883", got) + } + if got := novaBrokerAddress("10.0.0.5", 1883); got != "10.0.0.5:1883" { + t.Fatalf("IPv4 broker address = %q, want 10.0.0.5:1883", got) + } + if got := novaBrokerAddress("[fe80::5%en0]", 1883); got != "[fe80::5%en0]:1883" { + t.Fatalf("zone broker address = %q, want [fe80::5%%en0]:1883", got) + } +} + // TestAssemble_PicksUpClean Snake CaseFromLuaEmit confirms that // arbitrary fields a Lua driver emits inside host.emit() flow into // the clean payload unmodified — because the emit convention and