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/fix-vag-cloud-auth.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions go/internal/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
79 changes: 79 additions & 0 deletions go/internal/drivers/registry_allowlist_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}
4 changes: 2 additions & 2 deletions scripts/ci-ui-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
92 changes: 75 additions & 17 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += '<fieldset><legend>Vehicle</legend>' +
'<div class="field-row"><div>' +
'<label>Proxy IP ' + help('LAN address of the TeslaBLEProxy. Bare IP uses port 8080; append ":port" to override (e.g. 192.168.1.50:1234).') + '</label>' +
'<input type="text" class="tesla-ip-input" data-driver-idx="' + idx + '" data-path="drivers.' + idx + '.config.ip" value="' + escHtml(vcfg.ip || '') + '" placeholder="192.168.1.50 (or 192.168.1.50:1234)">' +
'</div><div>' +
'<label>VIN ' + help('Vehicle Identification Number the proxy is paired to.') + '</label>' +
'<input type="text" data-path="drivers.' + idx + '.config.vin" value="' + escHtml(vcfg.vin || '') + '" placeholder="5YJ3E1EA1KF000000">' +
'</div></div>' +
'<div style="margin-top:8px;display:flex;gap:10px;align-items:center">' +
'<button class="btn-add tesla-verify-btn" type="button" data-driver-idx="' + idx + '">Verify connection</button>' +
'<span class="tesla-verify-status" data-driver-idx="' + idx + '" style="font-size:0.82rem;color:var(--text-dim)"></span>' +
'</div>' +
'</fieldset>';
// 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
? '<span class="creds-badge creds-saved">✓ Saved</span>'
: '<span class="creds-badge creds-missing">⚠ Not saved</span>';
html += '<fieldset><legend>VAG EU Data Act</legend>' +
'<div class="field-row"><div>' +
'<label>Brand ' + help('Brand account linked to this VIN on the VW Group EU Data Act portal.') + '</label>' +
'<select data-path="drivers.' + idx + '.config.brand">' +
'<option value=""' + (!vagBrand ? ' selected' : '') + '>Choose brand…</option>' +
['audi', 'volkswagen', 'skoda', 'seat', 'cupra'].map(function (brand) {
var labels = { audi: 'Audi', volkswagen: 'Volkswagen', skoda: 'Škoda', seat: 'SEAT', cupra: 'Cupra' };
return '<option value="' + brand + '"' + (vagBrand === brand ? ' selected' : '') + '>' + labels[brand] + '</option>';
}).join('') +
'</select>' +
'</div><div>' +
'<label>VIN ' + help('Vehicle Identification Number registered to the selected brand account.') + '</label>' +
'<input type="text" data-path="drivers.' + idx + '.config.vin" value="' + escHtml(vcfg.vin || '') + '" placeholder="WAUZZZ…">' +
'</div></div>' +
'<div class="field-row"><div>' +
'<label>Email ' + help('Email address for the selected VW Group brand account. VAG driver v0.2.0 and newer use it to renew the portal session automatically.') + '</label>' +
'<input type="email" autocomplete="username" data-path="drivers.' + idx + '.config.email" value="' + escHtml(vcfg.email || '') + '" placeholder="name@example.com">' +
'</div><div>' +
'<label>Password ' + vagPasswordBadge + ' ' + help('Stored as a masked driver secret. Leave empty to keep an already saved password. VAG driver v0.2.0 and newer use it for automatic re-login.') + '</label>' +
'<input type="password" autocomplete="current-password" data-path="drivers.' + idx + '.config.password" value="" placeholder="' +
(vagHasPassword ? '•••••••• (leave empty to keep)' : 'enter account password') + '">' +
'</div></div>' +
'<p style="color:var(--text-dim);font-size:0.75rem;margin:8px 0 0">' +
'VAG driver v0.2.0+ signs in again automatically when the portal session expires.' +
'</p>' +
'</fieldset>';
} 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 += '<fieldset><legend>Vehicle</legend>' +
'<div class="field-row"><div>' +
'<label>Proxy IP ' + help('LAN address of the TeslaBLEProxy. Bare IP uses port 8080; append ":port" to override (e.g. 192.168.1.50:1234).') + '</label>' +
'<input type="text" class="tesla-ip-input" data-driver-idx="' + idx + '" data-path="drivers.' + idx + '.config.ip" value="' + escHtml(vcfg.ip || '') + '" placeholder="192.168.1.50 (or 192.168.1.50:1234)">' +
'</div><div>' +
'<label>VIN ' + help('Vehicle Identification Number the proxy is paired to.') + '</label>' +
'<input type="text" data-path="drivers.' + idx + '.config.vin" value="' + escHtml(vcfg.vin || '') + '" placeholder="5YJ3E1EA1KF000000">' +
'</div></div>' +
'<div style="margin-top:8px;display:flex;gap:10px;align-items:center">' +
'<button class="btn-add tesla-verify-btn" type="button" data-driver-idx="' + idx + '">Verify connection</button>' +
'<span class="tesla-verify-status" data-driver-idx="' + idx + '" style="font-size:0.82rem;color:var(--text-dim)"></span>' +
'</div>' +
'</fieldset>';
}
}
if (isLocalHTTP) {
var isZap = (d.lua || '').indexOf('zap.lua') >= 0;
Expand Down Expand Up @@ -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.
Expand Down
Loading