From 2094b1e0ef9405e69c0b16bf1040f561f0eded0c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 4 Aug 2026 09:46:15 +0200 Subject: [PATCH 1/2] fix: format IPv6 endpoints for integrations --- .changeset/ipv6-endpoint-formatting.md | 5 +++++ go/internal/api/api_drivers_fingerprint.go | 8 ++++++-- go/internal/api/api_drivers_fingerprint_test.go | 9 +++++++++ go/internal/evcloud/ctek.go | 10 ++++++++-- go/internal/evcloud/ctek_test.go | 6 ++++++ go/internal/nova/publisher.go | 14 ++++++++++++-- go/internal/nova/publisher_test.go | 9 +++++++++ 7 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 .changeset/ipv6-endpoint-formatting.md diff --git a/.changeset/ipv6-endpoint-formatting.md b/.changeset/ipv6-endpoint-formatting.md new file mode 100644 index 00000000..6b04965f --- /dev/null +++ b/.changeset/ipv6-endpoint-formatting.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Format IPv6 literals 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..12592feb 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,10 @@ func ctekDialReal(host string, port, unitID int) (ctekClient, error) { return &ctekRealClient{cli: cli}, nil } +func ctekAddress(host string, port int) string { + return net.JoinHostPort(host, strconv.Itoa(port)) +} + 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..144660a1 100644 --- a/go/internal/evcloud/ctek_test.go +++ b/go/internal/evcloud/ctek_test.go @@ -155,3 +155,9 @@ func TestCTekDescribe(t *testing.T) { t.Errorf("defaults: got port=%d unit=%d, want 502/1", d.DefaultPort, d.DefaultUnitID) } } + +func TestCTekAddressFormatsIPv6(t *testing.T) { + if got := ctekAddress("fd00::5", 1502); got != "[fd00::5]:1502" { + t.Fatalf("address = %q, want [fd00::5]:1502", got) + } +} diff --git a/go/internal/nova/publisher.go b/go/internal/nova/publisher.go index 8bbd77e1..94ab8069 100644 --- a/go/internal/nova/publisher.go +++ b/go/internal/nova/publisher.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log/slog" + "net" + "strconv" "sync" "time" @@ -88,7 +90,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 +108,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 +124,14 @@ 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(host, strconv.Itoa(port)) +} + +func novaBrokerURL(scheme, host string, port int) string { + return scheme + "://" + novaBrokerAddress(host, port) +} + // 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..2a929b8e 100644 --- a/go/internal/nova/publisher_test.go +++ b/go/internal/nova/publisher_test.go @@ -43,6 +43,15 @@ func TestDriverInventoryContentSHAIgnoresGeneratedAt(t *testing.T) { } } +func TestNovaBrokerURLFormatsIPv6(t *testing.T) { + if got := novaBrokerURL("ssl", "fd00::5", 8883); got != "ssl://[fd00::5]:8883" { + t.Fatalf("broker URL = %q, want ssl://[fd00::5]:8883", got) + } + if got := novaBrokerAddress("mqtt.local", 1883); got != "mqtt.local:1883" { + t.Fatalf("broker address = %q, want mqtt.local: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 From eebe93e3de1f0e440c3f7292c782002f7940cf71 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 4 Aug 2026 10:25:06 +0200 Subject: [PATCH 2/2] fix: normalize IPv6 integration hosts --- .changeset/ipv6-endpoint-formatting.md | 2 +- go/internal/evcloud/ctek.go | 17 ++++++++++- go/internal/evcloud/ctek_test.go | 22 +++++++++++++-- go/internal/nova/publisher.go | 29 +++++++++++++++++-- go/internal/nova/publisher_test.go | 39 ++++++++++++++++++++++---- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/.changeset/ipv6-endpoint-formatting.md b/.changeset/ipv6-endpoint-formatting.md index 6b04965f..66b9133a 100644 --- a/.changeset/ipv6-endpoint-formatting.md +++ b/.changeset/ipv6-endpoint-formatting.md @@ -2,4 +2,4 @@ "ftw": patch --- -Format IPv6 literals correctly for CTEK, Nova, and driver-fingerprint endpoints. +Format bare and already-bracketed IPv6 literals, including Nova zone IDs, correctly for CTEK, Nova, and driver-fingerprint endpoints. diff --git a/go/internal/evcloud/ctek.go b/go/internal/evcloud/ctek.go index 12592feb..543b5b48 100644 --- a/go/internal/evcloud/ctek.go +++ b/go/internal/evcloud/ctek.go @@ -166,7 +166,22 @@ func ctekDialReal(host string, port, unitID int) (ctekClient, error) { } func ctekAddress(host string, port int) string { - return net.JoinHostPort(host, strconv.Itoa(port)) + 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 } diff --git a/go/internal/evcloud/ctek_test.go b/go/internal/evcloud/ctek_test.go index 144660a1..90216beb 100644 --- a/go/internal/evcloud/ctek_test.go +++ b/go/internal/evcloud/ctek_test.go @@ -156,8 +156,24 @@ func TestCTekDescribe(t *testing.T) { } } -func TestCTekAddressFormatsIPv6(t *testing.T) { - if got := ctekAddress("fd00::5", 1502); got != "[fd00::5]:1502" { - t.Fatalf("address = %q, want [fd00::5]:1502", got) +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 94ab8069..4d392194 100644 --- a/go/internal/nova/publisher.go +++ b/go/internal/nova/publisher.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "strconv" + "strings" "sync" "time" @@ -125,11 +126,35 @@ func Start(cfg *config.Nova, id *Identity, store *state.Store, tel *telemetry.St } func novaBrokerAddress(host string, port int) string { - return net.JoinHostPort(host, strconv.Itoa(port)) + return net.JoinHostPort(unbracketIPv6Host(host), strconv.Itoa(port)) } func novaBrokerURL(scheme, host string, port int) string { - return scheme + "://" + novaBrokerAddress(host, port) + 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. diff --git a/go/internal/nova/publisher_test.go b/go/internal/nova/publisher_test.go index 2a929b8e..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" @@ -44,11 +45,39 @@ func TestDriverInventoryContentSHAIgnoresGeneratedAt(t *testing.T) { } func TestNovaBrokerURLFormatsIPv6(t *testing.T) { - if got := novaBrokerURL("ssl", "fd00::5", 8883); got != "ssl://[fd00::5]:8883" { - t.Fatalf("broker URL = %q, want ssl://[fd00::5]:8883", got) - } - if got := novaBrokerAddress("mqtt.local", 1883); got != "mqtt.local:1883" { - t.Fatalf("broker address = %q, want mqtt.local:1883", got) + 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) } }