diff --git a/.changeset/fix-vag-cloud-auth.md b/.changeset/fix-vag-cloud-auth.md new file mode 100644 index 000000000..838e91f87 --- /dev/null +++ b/.changeset/fix-vag-cloud-auth.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Fix VAG EU Data Act setup and automatic sign-in by hydrating driver-declared HTTP hosts in Core, and show the VAG brand, VIN, email, and password fields without proxy or legacy cookie controls. diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index ca1f5a9e7..5a7e2bdcd 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -590,6 +590,19 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo if cfg.Capabilities.HTTP != nil { env.WithHTTP() hosts := mergeAllowedHosts(cfg.Capabilities.HTTP.AllowedHosts, cfg.Config) + // Cloud drivers declare their fixed network boundary in DRIVER.http_hosts. + // Older saved configs (and connection probes built from them) may predate + // that metadata and therefore have no capabilities.http.allowed_hosts. + // Hydrate only from the Lua driver's own declaration; never from operator + // input. Explicit config hosts are retained and merged above. + if entry, err := ParseCatalogFile(cfg.Lua); err == nil { + for _, h := range entry.HTTPHosts { + h = strings.TrimSpace(h) + if h != "" && !slices.Contains(hosts, h) { + hosts = append(hosts, h) + } + } + } if len(hosts) > 0 { env.WithHTTPAllowedHosts(hosts) } diff --git a/go/internal/drivers/registry_allowlist_test.go b/go/internal/drivers/registry_allowlist_test.go index 2fe07658b..1c7ae01b7 100644 --- a/go/internal/drivers/registry_allowlist_test.go +++ b/go/internal/drivers/registry_allowlist_test.go @@ -1,10 +1,14 @@ package drivers import ( + "context" + "os" + "path/filepath" "reflect" "testing" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/telemetry" ) func TestMergeAllowedHosts(t *testing.T) { @@ -156,3 +160,78 @@ func TestTcpAllowedHostsFor(t *testing.T) { }) } } + +// A cloud driver owns its fixed network boundary in DRIVER.http_hosts. +// Existing configs may predate that metadata and therefore carry an empty +// capabilities.http.allowed_hosts. Both ordinary startup and connection +// probes must hydrate the same driver-declared hosts before Lua starts. +func TestRegistryHydratesHTTPHostsFromDriverMetadata(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cloud.lua") + src := `DRIVER = { + id = "cloud", + name = "Cloud", + read_only = true, + protocols = { "http" }, + capabilities = { "vehicle" }, + http_hosts = { "api.example.test", " identity.example.test ", "api.example.test" }, +} +function driver_init(config) + local r, err = host.http_request{url = "https://not-declared.invalid/data"} + assert(r == nil, "unexpected request") + assert(err and err:find("not in allowed_hosts", 1, true), + "driver-declared allowlist was not installed: " .. tostring(err)) +end +function driver_poll() return 60000 end +` + if err := os.WriteFile(path, []byte(src), 0600); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + add func(*Registry, context.Context, config.Driver) error + stop func(*Registry, string) + }{ + { + name: "startup", + add: func(r *Registry, ctx context.Context, cfg config.Driver) error { + return r.Add(ctx, cfg) + }, + stop: func(r *Registry, name string) { r.Remove(name) }, + }, + { + name: "probe", + add: func(r *Registry, ctx context.Context, cfg config.Driver) error { + return r.AddProbe(ctx, cfg) + }, + stop: func(r *Registry, name string) { r.RemoveProbe(name) }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + r := NewRegistry(telemetry.NewStore()) + cfg := config.Driver{ + Name: "cloud-" + tc.name, + Lua: path, + Capabilities: config.Capabilities{ + HTTP: &config.HTTPCapability{}, + }, + } + if err := tc.add(r, context.Background(), cfg); err != nil { + t.Fatalf("add with DRIVER.http_hosts: %v", err) + } + t.Cleanup(func() { tc.stop(r, cfg.Name) }) + + r.mu.Lock() + rd := r.rec[cfg.Name] + r.mu.Unlock() + if rd == nil { + t.Fatal("driver was not registered") + } + want := []string{"api.example.test", "identity.example.test"} + if !reflect.DeepEqual(rd.env.HTTPAllowedHosts, want) { + t.Fatalf("HTTPAllowedHosts = %v, want %v", rd.env.HTTPAllowedHosts, want) + } + }) + } +} diff --git a/scripts/ci-ui-browser.sh b/scripts/ci-ui-browser.sh index 7d2caf9e9..9de535d94 100755 --- a/scripts/ci-ui-browser.sh +++ b/scripts/ci-ui-browser.sh @@ -188,8 +188,8 @@ curl_json /api/status curl_json /api/drivers curl_json /api/config -smoke_page "$browser" / "view-live" "1440,1000" desktop +smoke_page "$browser" / "view-overview" "1440,1000" desktop smoke_page "$browser" /setup "wizard" "1440,1000" desktop -smoke_page "$browser" / "view-live" "390,844" mobile +smoke_page "$browser" / "view-overview" "390,844" mobile log "ok" diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index d08ca6ba8..01c32b9ac 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -1186,24 +1186,72 @@ var isCloudDriver = !isVehicleDriver && !isApiCredsDriver && cap.http != null && !hasHostField && (hasAuthField || Object.keys(dcfg).length === 0); if (isVehicleDriver) { - // TeslaBLEProxy-style drivers only need the LAN IP of the - // proxy and the VIN it's paired to. "Verify connection" - // makes the backend issue a one-shot vehicle_data poll so - // the operator can confirm pairing before saving. var vcfg = d.config || {}; - html += '
'; + // Match both the configured logical path and the catalog entry. + // Repository-installed drivers may use a versioned/managed path, so + // filename-only detection can misclassify VAG as TeslaBLEProxy and + // render Proxy IP while hiding the VAG email fieldset. + var isVAGVehicle = (d.lua || '').indexOf('vag_vehicle.lua') >= 0 || + !!(catalogEntry && catalogEntry.id === 'vag_vehicle'); + if (isVAGVehicle) { + // Core merges the driver's DRIVER.http_hosts into the allowlist, + // so the UI leaves capabilities.http.allowed_hosts as saved. + + // VAG EU Data Act is a cloud vehicle driver, not a + // TeslaBLEProxy-style LAN driver. Brand is required by + // vag_vehicle.lua and must be part of the row so the generic + // connection probe receives it together with VIN and secrets. + var vagBrand = String(vcfg.brand || '').toLowerCase(); + var vagHasPassword = d.has_password === true || + (typeof vcfg.password === 'string' && vcfg.password !== ''); + var vagPasswordBadge = vagHasPassword + ? '✓ Saved' + : '⚠ Not saved'; + html += ''; + } else { + // TeslaBLEProxy-style drivers only need the LAN IP of the + // proxy and the VIN it's paired to. "Verify connection" + // makes the backend issue a one-shot vehicle_data poll so + // the operator can confirm pairing before saving. + html += ''; + } } if (isLocalHTTP) { var isZap = (d.lua || '').indexOf('zap.lua') >= 0; @@ -1737,6 +1785,16 @@ return k !== 'client_secret' && k !== 'refresh_token'; }); } + // VAG v0.2.0+ renders email/password in its dedicated fieldset. + // Cookie is retained in config as a legacy fallback for older + // Core/driver versions, but it is not part of the normal UI. + // Do not delete or overwrite an already saved cookie here. + var isVAG = (d.lua || '').indexOf('vag_vehicle.lua') >= 0 || + !!(entry && entry.id === 'vag_vehicle'); + if (isVAG) { + secrets = secrets.filter(function (k) { return k !== 'cookie'; }); + } + // Cloud credentials already render config.password. A second // Secrets field bound to the same path (Easee, Zaptec) saves // whichever input is read last and shows the wrong hint.