Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ipv6-endpoint-formatting.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions go/internal/api/api_drivers_fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -194,14 +194,18 @@ 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:
return drivers.Fingerprint{Match: drivers.MatchUnknown, Err: "unsupported protocol: " + protocol}
}
}

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.
Expand Down
9 changes: 9 additions & 0 deletions go/internal/api/api_drivers_fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions go/internal/evcloud/ctek.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package evcloud
import (
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions go/internal/evcloud/ctek_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
39 changes: 37 additions & 2 deletions go/internal/nova/publisher.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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).
Expand All @@ -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()
}).
Expand All @@ -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
Comment on lines +154 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode raw zone IDs that begin with 25

When the raw IPv6 zone itself starts with 25 (for example, the common numeric interface index in fe80::5%25), this prefix check mistakes it for an already URL-encoded delimiter and returns ssl://[fe80::5%25]:8883. url.Parse, as used by Paho's AddBroker, decodes that host to [fe80::5%]:8883, which cannot be resolved, so Nova never connects on that interface. The input representation must be canonicalized or otherwise disambiguated rather than treating every %25... suffix as pre-encoded.

Useful? React with 👍 / 👎.

}
return host[:percent] + "%25" + host[percent+1:]
}

// Stop shuts down the publish loop and disconnects from the broker.
// Idempotent.
func (p *Publisher) Stop() {
Expand Down
38 changes: 38 additions & 0 deletions go/internal/nova/publisher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package nova

import (
"encoding/json"
"net/url"
"testing"
"time"

Expand Down Expand Up @@ -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
Expand Down