From 526097f4d2cd5bc04fdbae3a23de22f03f091d6e Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 15 Jul 2026 10:24:42 +0200 Subject: [PATCH 1/2] feat(localnet): instance-scoped nginx vhosts for every Splice UI/API Serve each Splice UI/API behind an instance-scoped virtual host of the form ..localhost (e.g. wallet.localnet-2.localhost) instead of the flat *.localhost names, so URLs stay unambiguous across concurrently running localnets. - assets/nginx/{app-provider,app-user,sv}.conf: server_name is now a ${VHOST_*} placeholder (wallet, ans, scan, sv, json-ledger-api, grpc-ledger-api); the flat Splice names are dropped. The deprecated canton.localhost block is left flat. - overlay.go: add instanceVHost() + VHostService* consts; thread the instance name into WriteNginxVhostOverlay and inject the per-instance VHOST_* values as nginx container env (envsubst expands them at boot). - up.go / status.go: advertise wallet UIs at wallet..localhost in the welcome screen and status endpoints. - env.go: instance-scope CANTON_SCAN_UI_URL and emit per-role CANTON__{JSON,GRPC}_LEDGER_API_URL plus unqualified CANTON_{JSON,GRPC}_LEDGER_API_URL aliases (app-provider), each behind the matching ledger-api vhost. - token/registry_url.go + registry/client.go + registry/doc.go + canton integration test: thread the instance-scoped scan Host header through DevKit's own scan-registry client. - ui_reachability.go: dial loopback (multi-label *.localhost does not resolve via the OS/Go resolver on macOS) but carry the wallet vhost as the Host header so the probe validates the real route. Note the resolution caveat documented throughout: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but not in JVM/Node/Python resolvers, which must send an explicit Host header (HTTP) / :authority pseudo-header (gRPC) or add an /etc/hosts entry. --- assets/nginx/app-provider.conf | 26 ++++--- assets/nginx/app-user.conf | 25 +++++-- assets/nginx/sv.conf | 29 +++++--- internal/canton/integration_test.go | 18 +++-- internal/canton/registry/client.go | 14 ++-- internal/canton/registry/doc.go | 31 ++++---- internal/cli/localnet/env_test.go | 4 +- internal/localnet/env.go | 68 +++++++++++++---- internal/localnet/env_test.go | 76 ++++++++++++++++++- internal/localnet/overlay.go | 89 +++++++++++++++++------ internal/localnet/overlay_test.go | 54 ++++++++++---- internal/localnet/status.go | 21 ++++-- internal/localnet/status_test.go | 26 ++++--- internal/localnet/token/registry_url.go | 27 ++++--- internal/localnet/ui_reachability.go | 34 +++++++-- internal/localnet/ui_reachability_test.go | 33 +++++++-- internal/localnet/up.go | 36 ++++++--- internal/ui/handlers/auth_test.go | 4 +- internal/ui/handlers/instances_test.go | 2 +- 19 files changed, 449 insertions(+), 168 deletions(-) diff --git a/assets/nginx/app-provider.conf b/assets/nginx/app-provider.conf index d0a7f3e2..fbaa2e49 100644 --- a/assets/nginx/app-provider.conf +++ b/assets/nginx/app-provider.conf @@ -1,6 +1,17 @@ +# canton-devkit: server_name values are instance-scoped vhosts of the form +# ..localhost, injected via ${VHOST_*} env vars by +# WriteNginxVhostOverlay (e.g. wallet.localnet-2.localhost). The flat Splice +# names (wallet.localhost, ans.localhost, ...) are intentionally NOT served — +# DevKit advertises only the instance-scoped names so URLs stay unambiguous +# across concurrently running localnets. +# +# NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +# in the JVM/Node/Python resolvers (and some Rust HTTP clients). Programmatic +# clients on those runtimes must send an explicit `Host:` header (HTTP) or +# `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. server { listen ${APP_PROVIDER_UI_PORT}; - server_name ans.localhost; + server_name ${VHOST_ANS}; location /api/validator { rewrite ^\/(.*) /$1 break; proxy_pass http://splice:3${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; @@ -10,7 +21,8 @@ server { } } -# Deprecated, use json-ledger-api.localhost instead +# Deprecated, use the json-ledger-api vhost instead. Left on the flat +# canton.localhost name (upstream-deprecated; not worth instance-scoping). server { listen ${APP_PROVIDER_UI_PORT}; server_name canton.localhost; @@ -22,7 +34,7 @@ server { server { listen ${APP_PROVIDER_UI_PORT}; - server_name json-ledger-api.localhost; + server_name ${VHOST_JSON_LEDGER}; location / { proxy_pass http://canton:3${PARTICIPANT_JSON_API_PORT_SUFFIX}; include /etc/nginx/includes/cors-headers.conf; @@ -31,7 +43,7 @@ server { server { listen ${APP_PROVIDER_UI_PORT} http2; - server_name grpc-ledger-api.localhost; + server_name ${VHOST_GRPC_LEDGER}; location / { grpc_pass grpc://canton:3${PARTICIPANT_LEDGER_API_PORT_SUFFIX}; } @@ -40,11 +52,7 @@ server { server { listen ${APP_PROVIDER_UI_PORT}; - # canton-devkit: `localhost` added so the bare host URL DevKit advertises - # (http://localhost:${APP_PROVIDER_UI_PORT}) routes to the wallet. Upstream - # only listed `wallet.localhost`, so the bare Host fell through to the first - # server block on this port (ans.localhost) and served the name service. - server_name localhost wallet.localhost; + server_name ${VHOST_WALLET}; # Reverse proxy for /api/validator location /api/validator { diff --git a/assets/nginx/app-user.conf b/assets/nginx/app-user.conf index 288920e0..a70d9aab 100644 --- a/assets/nginx/app-user.conf +++ b/assets/nginx/app-user.conf @@ -1,6 +1,17 @@ +# canton-devkit: server_name values are instance-scoped vhosts of the form +# ..localhost, injected via ${VHOST_*} env vars by +# WriteNginxVhostOverlay (e.g. wallet.localnet-2.localhost). The flat Splice +# names (wallet.localhost, ans.localhost, ...) are intentionally NOT served — +# DevKit advertises only the instance-scoped names so URLs stay unambiguous +# across concurrently running localnets. +# +# NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +# in the JVM/Node/Python resolvers (and some Rust HTTP clients). Programmatic +# clients on those runtimes must send an explicit `Host:` header (HTTP) or +# `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. server { listen ${APP_USER_UI_PORT}; - server_name ans.localhost; + server_name ${VHOST_ANS}; location /api/validator { rewrite ^\/(.*) /$1 break; proxy_pass http://splice:2${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; @@ -10,7 +21,8 @@ server { } } -# Deprecated, use json-ledger-api.localhost instead +# Deprecated, use the json-ledger-api vhost instead. Left on the flat +# canton.localhost name (upstream-deprecated; not worth instance-scoping). server { listen ${APP_USER_UI_PORT}; server_name canton.localhost; @@ -22,7 +34,7 @@ server { server { listen ${APP_USER_UI_PORT}; - server_name json-ledger-api.localhost; + server_name ${VHOST_JSON_LEDGER}; location / { proxy_pass http://canton:2${PARTICIPANT_JSON_API_PORT_SUFFIX}; include /etc/nginx/includes/cors-headers.conf; @@ -31,7 +43,7 @@ server { server { listen ${APP_USER_UI_PORT} http2; - server_name grpc-ledger-api.localhost; + server_name ${VHOST_GRPC_LEDGER}; location / { grpc_pass grpc://canton:2${PARTICIPANT_LEDGER_API_PORT_SUFFIX}; } @@ -39,10 +51,7 @@ server { server { listen ${APP_USER_UI_PORT}; - # `localhost` already routes to the app-user wallet upstream; kept here so - # DevKit's embedded config matches the app-provider/sv fix and the bare host - # URL keeps working. - server_name localhost wallet.localhost; + server_name ${VHOST_WALLET}; # Reverse proxy for /api/validator location /api/validator { diff --git a/assets/nginx/sv.conf b/assets/nginx/sv.conf index ba599b03..0206a9e5 100644 --- a/assets/nginx/sv.conf +++ b/assets/nginx/sv.conf @@ -1,10 +1,20 @@ +# canton-devkit: server_name values are instance-scoped vhosts of the form +# ..localhost, injected via ${VHOST_*} env vars by +# WriteNginxVhostOverlay (e.g. wallet.localnet-2.localhost). The flat Splice +# names (wallet.localhost, scan.localhost, sv.localhost, ...) are +# intentionally NOT served — DevKit advertises only the instance-scoped +# names so URLs stay unambiguous across concurrently running localnets. +# +# NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +# in the JVM/Node/Python resolvers (and some Rust HTTP clients). Programmatic +# clients on those runtimes must send an explicit `Host:` header (HTTP) or +# `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. server { listen ${SV_UI_PORT}; - # canton-devkit: dropped `localhost` from this catch-all. Upstream had - # `server_name localhost _;` which claimed the bare host URL DevKit - # advertises (http://localhost:${SV_UI_PORT}) and tried to serve a static - # dir (/usr/share/nginx/sv-html) that does not exist in the image, so the - # sv wallet URL 404'd. `_` alone keeps the status/CORS default server. + # Catch-all default server: keeps the stub_status endpoint reachable and + # absorbs unmatched Host headers. `_` (not `localhost`) so the bare host + # URL does not fall through to the non-existent /usr/share/nginx/sv-html + # static dir (which 404'd before PR #279). server_name _; location = /status { @@ -23,7 +33,7 @@ server { server { listen ${SV_UI_PORT}; - server_name sv.localhost; + server_name ${VHOST_SV}; location /api/sv { rewrite ^\/(.*) /$1 break; @@ -36,7 +46,7 @@ server { server { listen ${SV_UI_PORT}; - server_name scan.localhost; + server_name ${VHOST_SCAN}; location /api/scan { rewrite ^\/(.*) /$1 break; @@ -53,10 +63,7 @@ server { server { listen ${SV_UI_PORT}; - # canton-devkit: `localhost` added so the bare host URL DevKit advertises - # (http://localhost:${SV_UI_PORT}) routes to the sv wallet instead of the - # static catch-all above. - server_name localhost wallet.localhost; + server_name ${VHOST_WALLET}; # Reverse proxy for /api/validator location /api/validator { diff --git a/internal/canton/integration_test.go b/internal/canton/integration_test.go index 06c9536f..6b663407 100644 --- a/internal/canton/integration_test.go +++ b/internal/canton/integration_test.go @@ -59,20 +59,22 @@ func localnetParticipantEndpoint() string { // // CRITICAL — the URL scheme/host matters for nginx routing on the SV // tenant. The /api/scan/* path only matches when the request Host -// header is `scan.localhost` (see Splice conf/nginx/sv.conf — there -// are multiple `server_name` blocks under one listen port; only the -// `scan.localhost` block proxies to the splice scan app). Hitting -// `http://localhost:/api/scan/v0/dso` returns the +// header is the instance-scoped `scan..localhost` (see +// assets/nginx/sv.conf — there are multiple `server_name` blocks under +// one listen port; only the scan block proxies to the splice scan app, +// and DevKit serves only the instance-scoped name). This harness runs +// against `localnet up dev`, so the host is `scan.dev.localhost`. +// Hitting `http://localhost:/api/scan/v0/dso` returns the // sv-html static index (200 with HTML body) because the default // server_name block serves the SV UI assets. // // The Go http.Client uses the URL's host as the Host header by // default, so the right form is: // -// export CANTON_DEVKIT_TEST_SCAN_URL=http://scan.localhost: +// export CANTON_DEVKIT_TEST_SCAN_URL=http://scan.dev.localhost: // -// `scan.localhost` resolves to 127.0.0.1 per RFC 6761; verified on -// macOS + Linux. To find the SV UI host port on a running instance: +// `scan.dev.localhost` resolves to 127.0.0.1 in Go's resolver. To find +// the SV UI host port on a running instance: // // docker port -nginx // @@ -81,7 +83,7 @@ func localnetScanBaseURL() string { if v := os.Getenv("CANTON_DEVKIT_TEST_SCAN_URL"); v != "" { return v } - return "http://scan.localhost:4000" + return "http://scan.dev.localhost:4000" } // devLocalNetTokenSource returns a TokenSource that signs JWTs with the diff --git a/internal/canton/registry/client.go b/internal/canton/registry/client.go index ceaafa46..cbe46c44 100644 --- a/internal/canton/registry/client.go +++ b/internal/canton/registry/client.go @@ -67,10 +67,12 @@ type DialOptions struct { // HostHeader, when non-empty, overrides the HTTP Host header sent // on every request (req.Host). Needed for Splice LocalNet's nginx // virtual-host routing: the scan app's `/registry` routes are gated - // behind `server_name scan.localhost`, so a request to the SV UI - // port must carry `Host: scan.localhost` to reach the scan - // upstream rather than the SV-info default vhost. On a real DevNet - // (where scan has its own DNS name) this stays empty. + // behind an instance-scoped `server_name scan..localhost`, + // so a request to the SV UI port must carry a matching + // `Host: scan..localhost` header to reach the scan + // upstream rather than the SV-info default vhost (DevKit's + // token.resolveRegistryURL supplies this). On a real DevNet (where + // scan has its own DNS name) this stays empty. HostHeader string // Version selects the token-standard transfer-instruction registry @@ -173,8 +175,8 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, into any req.Header.Set("Content-Type", "application/json") } // Host-header override for nginx virtual-host routing (LocalNet - // scan registry sits behind `server_name scan.localhost`). Setting - // req.Host — not req.Header.Set("Host", …) — is the correct knob; + // scan registry sits behind `server_name scan..localhost`). + // Setting req.Host — not req.Header.Set("Host", …) — is the correct knob; // net/http reads the Host field, not the header map, for the // request line's authority. if c.hostHeader != "" { diff --git a/internal/canton/registry/doc.go b/internal/canton/registry/doc.go index 125b9896..e535235a 100644 --- a/internal/canton/registry/doc.go +++ b/internal/canton/registry/doc.go @@ -32,25 +32,28 @@ // # CRITICAL — Host header / virtual-host routing // // Splice's nginx config defines multiple `server_name` blocks under one -// listen port. The /api/scan/* routes only match when the request Host -// header is `scan.localhost`; /api/validator/* needs `wallet.localhost` -// (per-tenant routing). With `Host: localhost` (the implicit default -// when BaseURL is `http://localhost:PORT`), requests fall through to -// the default server block which serves the tenant's SPA HTML — your -// 200-OK response will be `` instead of JSON, and the -// decode error message is opaque. +// listen port. On DevKit LocalNet the blocks are instance-scoped: the +// /api/scan/* routes only match Host `scan..localhost`; +// /api/validator/* needs `wallet..localhost` (per-tenant +// routing). With `Host: localhost` (the implicit default when BaseURL +// is `http://localhost:PORT`), requests fall through to the default +// server block which serves the tenant's SPA HTML — your 200-OK +// response will be `` instead of JSON, and the decode +// error message is opaque. // -// Pass the right virtual host via BaseURL. The `.localhost` TLD -// resolves to 127.0.0.1 per RFC 6761 (verified on macOS + Linux): +// Pass the right virtual host via BaseURL, e.g. for instance "dev": // // registry.Dial(registry.DialOptions{ -// BaseURL: "http://scan.localhost:", // for /api/scan/* -// BaseURL: "http://wallet.localhost:", // for /api/validator/* +// BaseURL: "http://scan.dev.localhost:", // for /api/scan/* +// BaseURL: "http://wallet.dev.localhost:", // for /api/validator/* // }) // -// If `.localhost` resolution fails in your environment (rare; some -// container runtimes strip it), use IP + the manual Host-header -// workaround via a wrapping http.RoundTripper in DialOptions.HTTPClient. +// *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +// in the JVM/Node/Python resolvers (and some Rust HTTP clients). Go's +// net resolver handles the `.localhost` suffix, so this client works +// out of the box; from other runtimes set the Host header explicitly +// (or add an /etc/hosts entry) via a wrapping http.RoundTripper in +// DialOptions.HTTPClient. // // # Auth // diff --git a/internal/cli/localnet/env_test.go b/internal/cli/localnet/env_test.go index dc2542b0..678fba93 100644 --- a/internal/cli/localnet/env_test.go +++ b/internal/cli/localnet/env_test.go @@ -94,8 +94,8 @@ func TestEnv_ShellOutputIsPosixQuoted(t *testing.T) { // Participant Ledger/JSON API ports a dApp dials directly. "export CANTON_PARTICIPANT_LEDGER_APP_USER_PORT='2901'", "export CANTON_PARTICIPANT_JSON_APP_USER_PORT='2975'", - // Scan UI surfaced explicitly with the scan.localhost vhost. - "export CANTON_SCAN_UI_URL='http://scan.localhost:4480'", + // Scan UI surfaced explicitly with the instance-scoped scan vhost. + "export CANTON_SCAN_UI_URL='http://scan.demo.localhost:4480'", "export CANTON_SV_JWT=''", "export CANTON_SV_USER='sv-user'", "export CANTON_SV_AUDIENCE='sv-aud'", diff --git a/internal/localnet/env.go b/internal/localnet/env.go index 7f5dc804..0da8e3e8 100644 --- a/internal/localnet/env.go +++ b/internal/localnet/env.go @@ -25,15 +25,17 @@ import ( // jwtRedactionPlaceholder — same sentinel so the surfaces don't drift. const EnvJWTRedaction = "" -// scanUIVHost is the nginx virtual-host name the Splice scan UI / -// scan registry routes live under on LocalNet. The scan app -// (`splice:5012` inside the docker network) is exposed on the host -// behind the SV UI port under `server_name scan.localhost` (see -// cluster/compose/localnet/conf/nginx/sv.conf, mirrored by -// internal/localnet/token.scanVHost). We emit an explicit -// CANTON_SCAN_UI_URL carrying this host hint rather than leaving -// operators to derive it from the bare sv_ui port. -const scanUIVHost = "scan.localhost" +// Splice UIs/APIs are served on LocalNet behind instance-scoped nginx +// virtual hosts of the form ..localhost (see +// WriteNginxVhostOverlay and instanceVHost). We surface those hosts in +// explicit, self-describing URL vars (CANTON_SCAN_UI_URL, +// CANTON_*_LEDGER_API_URL) rather than leaving operators to derive them +// from the bare UI ports. +// +// *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +// in the JVM/Node/Python resolvers (and some Rust HTTP clients) — those +// need an explicit Host header (HTTP) / :authority pseudo-header (gRPC) +// or an /etc/hosts entry. // BuildEnvExport assembles the shared apitypes.EnvExport for an // instance from its registry state. It is the single builder behind @@ -48,8 +50,10 @@ const scanUIVHost = "scan.localhost" // including the participant_ledger/admin/json_ ports captured // by CaptureCantonPorts and the sv_ui (scan UI) port — exactly the // endpoints an external dApp needs. -// 3. A derived CANTON_SCAN_UI_URL when the sv_ui port is recorded, -// carrying the scan.localhost vhost hint. +// 3. A derived CANTON_SCAN_UI_URL when the sv_ui port is recorded, plus +// per-role CANTON__{JSON,GRPC}_LEDGER_API_URL and unqualified +// CANTON_{JSON,GRPC}_LEDGER_API_URL aliases, each carrying the +// matching instance-scoped ..localhost vhost. // 4. state.Credentials -> CANTON__JWT (redacted unless // includeJWT) plus the user/audience pair that signed it. // 5. state.Parties -> CANTON__PARTY for the role parties @@ -85,12 +89,44 @@ func BuildEnvExport(name string, includeJWT bool) (apitypes.EnvExport, error) { out.Vars[PortEnvKey(logical)] = fmt.Sprintf("%d", port) } // The scan UI is reachable on the SV UI port behind the - // scan.localhost nginx vhost. Surface it under an explicit, - // self-describing key so a dApp doesn't have to know the vhost - // trick. Skipped when sv_ui wasn't captured (instance pre-dates - // port capture, or came up without the SV profile). + // scan..localhost nginx vhost. Surface it under an + // explicit, self-describing key so a dApp doesn't have to know the + // vhost trick. Skipped when sv_ui wasn't captured (instance + // pre-dates port capture, or came up without the SV profile). if port, ok := state.Ports["sv_ui"]; ok && port > 0 { - out.Vars["CANTON_SCAN_UI_URL"] = fmt.Sprintf("http://%s:%d", scanUIVHost, port) + out.Vars["CANTON_SCAN_UI_URL"] = fmt.Sprintf("http://%s:%d", + instanceVHost(VHostServiceScan, name), port) + } + + // The JSON and gRPC Ledger APIs are reachable on each role's UI port + // behind the json-ledger-api / grpc-ledger-api instance-scoped + // vhosts. Emit a per-role URL var for every recorded role UI port, + // plus unqualified CANTON_{JSON,GRPC}_LEDGER_API_URL aliases pointing + // at the app-provider participant (the common dApp target). gRPC URLs + // carry no scheme — the host:port is what a gRPC client dials, with + // the vhost as the :authority pseudo-header. + ledgerRolePortKeys := map[string]string{ + "app-user": "app_user_ui", + "app-provider": "app_provider_ui", + "sv": "sv_ui", + } + for role, portKey := range ledgerRolePortKeys { + port, ok := state.Ports[portKey] + if !ok || port <= 0 { + continue + } + prefix := CredEnvKeyPrefix(role) + out.Vars[prefix+"_JSON_LEDGER_API_URL"] = fmt.Sprintf("http://%s:%d", + instanceVHost(VHostServiceJSONLedger, name), port) + out.Vars[prefix+"_GRPC_LEDGER_API_URL"] = fmt.Sprintf("%s:%d", + instanceVHost(VHostServiceGRPCLedger, name), port) + } + // Unqualified aliases default to the app-provider participant. + if port, ok := state.Ports["app_provider_ui"]; ok && port > 0 { + out.Vars["CANTON_JSON_LEDGER_API_URL"] = fmt.Sprintf("http://%s:%d", + instanceVHost(VHostServiceJSONLedger, name), port) + out.Vars["CANTON_GRPC_LEDGER_API_URL"] = fmt.Sprintf("%s:%d", + instanceVHost(VHostServiceGRPCLedger, name), port) } for role, cred := range state.Credentials { diff --git a/internal/localnet/env_test.go b/internal/localnet/env_test.go index a3164cc7..b68b5f15 100644 --- a/internal/localnet/env_test.go +++ b/internal/localnet/env_test.go @@ -204,8 +204,8 @@ func TestBuildEnvExport_AuthFileRewriteTightensPerms(t *testing.T) { } // TestBuildEnvExport_ScanUIURL pins that the scan UI is surfaced under -// an explicit, self-describing key carrying the scan.localhost vhost -// hint. +// an explicit, self-describing key carrying the instance-scoped scan +// vhost hint. func TestBuildEnvExport_ScanUIURL(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) seedEnvState(t, "demo") @@ -214,8 +214,76 @@ func TestBuildEnvExport_ScanUIURL(t *testing.T) { if err != nil { t.Fatalf("BuildEnvExport: %v", err) } - if got := ex.Vars["CANTON_SCAN_UI_URL"]; got != "http://scan.localhost:4480" { - t.Errorf("CANTON_SCAN_UI_URL = %q, want http://scan.localhost:4480", got) + if got := ex.Vars["CANTON_SCAN_UI_URL"]; got != "http://scan.demo.localhost:4480" { + t.Errorf("CANTON_SCAN_UI_URL = %q, want http://scan.demo.localhost:4480", got) + } +} + +// TestBuildEnvExport_LedgerAPIURLs pins the per-role JSON/gRPC Ledger +// API URL vars behind the instance-scoped ledger vhosts. seedEnvState +// records app_user_ui (4485) and sv_ui (4480) but no app_provider_ui, +// so app-user + sv vars are present and the unqualified aliases are +// absent. +func TestBuildEnvExport_LedgerAPIURLs(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedEnvState(t, "demo") + + ex, err := BuildEnvExport("demo", false) + if err != nil { + t.Fatalf("BuildEnvExport: %v", err) + } + want := map[string]string{ + "CANTON_APP_USER_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4485", + "CANTON_APP_USER_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4485", + "CANTON_SV_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4480", + "CANTON_SV_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4480", + } + for k, v := range want { + if got := ex.Vars[k]; got != v { + t.Errorf("%s = %q, want %q", k, got, v) + } + } + // No app_provider_ui port → no app-provider vars, no aliases. + for _, k := range []string{ + "CANTON_APP_PROVIDER_JSON_LEDGER_API_URL", + "CANTON_APP_PROVIDER_GRPC_LEDGER_API_URL", + "CANTON_JSON_LEDGER_API_URL", + "CANTON_GRPC_LEDGER_API_URL", + } { + if got, ok := ex.Vars[k]; ok { + t.Errorf("%s should be absent (no app_provider_ui port), got %q", k, got) + } + } +} + +// TestBuildEnvExport_LedgerAPIAliases pins the unqualified +// CANTON_{JSON,GRPC}_LEDGER_API_URL aliases pointing at the +// app-provider participant when its UI port is recorded. +func TestBuildEnvExport_LedgerAPIAliases(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + s := registry.NewState("demo", "0.6.4") + s.ProjectDir = t.TempDir() + s.DataDir = "/test/demo" + s.Status = registry.StatusRunning + s.Ports = map[string]int{"app_provider_ui": 4486} + if err := registry.Write(s); err != nil { + t.Fatalf("seed: %v", err) + } + + ex, err := BuildEnvExport("demo", false) + if err != nil { + t.Fatalf("BuildEnvExport: %v", err) + } + want := map[string]string{ + "CANTON_APP_PROVIDER_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4486", + "CANTON_APP_PROVIDER_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4486", + "CANTON_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4486", + "CANTON_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4486", + } + for k, v := range want { + if got := ex.Vars[k]; got != v { + t.Errorf("%s = %q, want %q", k, got, v) + } } } diff --git a/internal/localnet/overlay.go b/internal/localnet/overlay.go index 16b2e51c..f89a8a28 100644 --- a/internal/localnet/overlay.go +++ b/internal/localnet/overlay.go @@ -251,26 +251,60 @@ var nginxVhostConfNames = []string{ "sv.conf", } -// WriteNginxVhostOverlay fixes the wallet URLs DevKit advertises. +// Splice service names for the instance-scoped nginx virtual hosts. Each +// UI/API the localnet nginx fronts is reachable at +// ..localhost: (see instanceVHost). The names +// match Splice's own docs (wallet, ans, scan, sv, json-ledger-api, +// grpc-ledger-api). +const ( + VHostServiceWallet = "wallet" + VHostServiceANS = "ans" + VHostServiceScan = "scan" + VHostServiceSV = "sv" + VHostServiceJSONLedger = "json-ledger-api" + VHostServiceGRPCLedger = "grpc-ledger-api" +) + +// instanceVHost returns the instance-scoped nginx virtual host for a +// service, e.g. instanceVHost("wallet", "localnet-2") == +// "wallet.localnet-2.localhost". This is the single source of truth for +// the hostname shape shared by the nginx overlay (which injects the +// values as ${VHOST_*} env vars) and every URL/env emitter. // -// Splice's nginx routes by Host header. The bare host URL DevKit prints -// (http://localhost:) matches no `*.localhost` vhost, so nginx -// falls through to the first server block on the port: -// - app-provider: first block is `ans.localhost` → served the name -// service instead of the wallet. -// - sv: catch-all `server_name localhost _` served a non-existent -// static dir → 404. +// Instance names are already validated as DNS labels +// (registry.ValidateName), so the result is always a valid hostname. // -// The upstream .conf files live in the content-hash-verified Splice -// cache (shared across instances), so we do NOT edit them. Instead we -// materialize DevKit-owned copies — with `localhost` added to each -// wallet server block — into /nginx/ and emit a compose -// overlay that remaps nginx's template bind-mounts onto them. +// *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +// in the JVM/Node/Python resolvers (and some Rust HTTP clients) — those +// need an explicit Host header (HTTP) / :authority pseudo-header (gRPC) +// or an /etc/hosts entry. +func instanceVHost(service, instance string) string { + return service + "." + instance + ".localhost" +} + +// WriteNginxVhostOverlay rewrites the hostnames DevKit's nginx serves so +// each UI/API is reachable at an instance-scoped virtual host of the +// form ..localhost (e.g. wallet.localnet-2.localhost). // -// nginx.conf and the includes/ dir are unchanged upstream, so the -// overlay still points those at ${LOCALNET_DIR}/conf/nginx (the cache). -// Compose merges `volumes` by appending, so the whole mount list is -// re-declared under `!override` (same tactic as WriteLoopbackPortsOverlay). +// Splice's nginx routes by Host header, defining several `server_name` +// blocks per listen port. DevKit ships its own copies of the role .conf +// files (assets/nginx/*.conf) whose server_name is a ${VHOST_*} env var; +// this overlay injects the per-instance value for each var via a compose +// `environment:` block, and bind-mounts the DevKit copies over Splice's +// upstream templates. +// +// Only the instance-scoped names are served — the flat Splice names +// (wallet.localhost, scan.localhost, ...) are intentionally dropped so +// URLs stay unambiguous across concurrently running localnets. DevKit's +// own scan-registry client threads the same instance-scoped Host header +// (see internal/localnet/token.resolveRegistryURL). +// +// The upstream .conf files live in the content-hash-verified Splice +// cache (shared across instances), so we do NOT edit them. nginx.conf +// and the includes/ dir are unchanged upstream, so the overlay still +// points those at ${LOCALNET_DIR}/conf/nginx (the cache). Compose merges +// `volumes` by appending, so the whole mount list is re-declared under +// `!override` (same tactic as WriteLoopbackPortsOverlay). // // The overlay is written to /nginx-vhosts.yaml. Caller appends // it to the ComposeFiles list AFTER the loopback overlay. warnw receives @@ -278,10 +312,13 @@ var nginxVhostConfNames = []string{ // may be nil. // // Returns the absolute path of the written overlay. -func WriteNginxVhostOverlay(dataDir string, warnw io.Writer) (string, error) { +func WriteNginxVhostOverlay(dataDir, instanceName string, warnw io.Writer) (string, error) { if dataDir == "" { return "", fmt.Errorf("WriteNginxVhostOverlay: empty dataDir") } + if instanceName == "" { + return "", fmt.Errorf("WriteNginxVhostOverlay: empty instanceName") + } nginxDir := filepath.Join(dataDir, "nginx") if err := os.MkdirAll(nginxDir, 0o755); err != nil { return "", fmt.Errorf("create nginx overlay dir %s: %w", nginxDir, err) @@ -299,18 +336,28 @@ func WriteNginxVhostOverlay(dataDir string, warnw io.Writer) (string, error) { } } - // Compose overlay: re-declare the full nginx volume list under + // Compose overlay: inject the per-instance ${VHOST_*} values as nginx + // container env (the official nginx image runs envsubst on the mounted + // templates at boot), then re-declare the full nginx volume list under // !override. The three role templates point at the DevKit copies; // nginx.conf and includes/ stay on the upstream cache paths. The // destination keeps Splice's `${..._PROFILE}` gating so a disabled // profile still parks the file at an inactive `.coff.template` name. var b strings.Builder b.WriteString("# Generated by canton-devkit.\n") - b.WriteString("# Overrides Splice's nginx server configs so the bare host URL\n") - b.WriteString("# (http://localhost:) routes to each role's wallet.\n") + b.WriteString("# Serves each Splice UI/API at an instance-scoped virtual host\n") + b.WriteString("# (." + instanceName + ".localhost:) and remaps\n") + b.WriteString("# nginx's server configs onto the DevKit-owned copies.\n") b.WriteString("# Requires Docker Compose v2.24.0+ for the !override YAML tag.\n") b.WriteString("services:\n") b.WriteString(" nginx:\n") + b.WriteString(" environment:\n") + fmt.Fprintf(&b, " VHOST_WALLET: %q\n", instanceVHost(VHostServiceWallet, instanceName)) + fmt.Fprintf(&b, " VHOST_ANS: %q\n", instanceVHost(VHostServiceANS, instanceName)) + fmt.Fprintf(&b, " VHOST_SCAN: %q\n", instanceVHost(VHostServiceScan, instanceName)) + fmt.Fprintf(&b, " VHOST_SV: %q\n", instanceVHost(VHostServiceSV, instanceName)) + fmt.Fprintf(&b, " VHOST_JSON_LEDGER: %q\n", instanceVHost(VHostServiceJSONLedger, instanceName)) + fmt.Fprintf(&b, " VHOST_GRPC_LEDGER: %q\n", instanceVHost(VHostServiceGRPCLedger, instanceName)) b.WriteString(" volumes: !override\n") b.WriteString(" - ${LOCALNET_DIR}/conf/nginx/nginx.conf:/etc/nginx/nginx.conf\n") fmt.Fprintf(&b, " - %s/app-provider.conf:/etc/nginx/templates/app-provider.c${APP_PROVIDER_PROFILE}f.template\n", filepath.ToSlash(nginxDir)) diff --git a/internal/localnet/overlay_test.go b/internal/localnet/overlay_test.go index 4e126cfe..7f8acc13 100644 --- a/internal/localnet/overlay_test.go +++ b/internal/localnet/overlay_test.go @@ -224,7 +224,8 @@ func TestWriteContainerRenameOverlay_RelaxesNginxSpliceDep(t *testing.T) { func TestWriteNginxVhostOverlay(t *testing.T) { tmp := t.TempDir() - path, err := WriteNginxVhostOverlay(tmp, nil) + const instance = "localnet-2" + path, err := WriteNginxVhostOverlay(tmp, instance, nil) if err != nil { t.Fatalf("WriteNginxVhostOverlay: %v", err) } @@ -232,23 +233,33 @@ func TestWriteNginxVhostOverlay(t *testing.T) { t.Errorf("unexpected filename: %s", path) } - // The three role .conf files must be materialized, each with the - // bare `localhost` added to a wallet server block so the bare host - // URL routes to the wallet. + // The three role .conf files must be materialized. Their server_name + // directives are ${VHOST_*} env vars (expanded by envsubst at nginx + // boot), so the materialized copies carry the placeholders, not the + // resolved hostnames. The flat Splice names must be gone. for _, name := range nginxVhostConfNames { confPath := filepath.Join(tmp, "nginx", name) body, rerr := os.ReadFile(confPath) if rerr != nil { t.Fatalf("missing materialized %s: %v", name, rerr) } - if !strings.Contains(string(body), "server_name localhost wallet.localhost;") { - t.Errorf("%s: wallet block missing bare `localhost`\n---\n%s", name, body) + s := string(body) + if !strings.Contains(s, "server_name ${VHOST_WALLET};") { + t.Errorf("%s: wallet block missing ${VHOST_WALLET} server_name\n---\n%s", name, s) + } + // Drop the flat wallet.localhost name entirely (comments aside). + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, "wallet.localhost") { + t.Errorf("%s: flat wallet.localhost still present in a directive: %q", name, trimmed) + } } } - // The sv catch-all must no longer claim `localhost` (it 404'd): its - // active directive is now the bare `_`. Check the directive lines - // only, ignoring explanatory comments that reference the old form. + // The sv catch-all stays the bare `_`. svBody, err := os.ReadFile(filepath.Join(tmp, "nginx", "sv.conf")) if err != nil { t.Fatal(err) @@ -259,9 +270,6 @@ func TestWriteNginxVhostOverlay(t *testing.T) { if strings.HasPrefix(trimmed, "#") { continue } - if trimmed == "server_name localhost _;" { - t.Errorf("sv.conf still has the 404-ing `localhost _` catch-all directive") - } if trimmed == "server_name _;" { sawCatchAll = true } @@ -270,7 +278,8 @@ func TestWriteNginxVhostOverlay(t *testing.T) { t.Errorf("sv.conf missing the `server_name _;` catch-all directive\n%s", svBody) } - // The compose overlay must remap the three role templates onto the + // The compose overlay must inject the per-instance ${VHOST_*} values + // as nginx container env and remap the three role templates onto the // materialized copies under !override while leaving nginx.conf and // includes/ pointed at the untouched Splice cache. body, err := os.ReadFile(path) @@ -280,6 +289,13 @@ func TestWriteNginxVhostOverlay(t *testing.T) { s := string(body) for _, want := range []string{ " nginx:", + " environment:", + ` VHOST_WALLET: "wallet.localnet-2.localhost"`, + ` VHOST_ANS: "ans.localnet-2.localhost"`, + ` VHOST_SCAN: "scan.localnet-2.localhost"`, + ` VHOST_SV: "sv.localnet-2.localhost"`, + ` VHOST_JSON_LEDGER: "json-ledger-api.localnet-2.localhost"`, + ` VHOST_GRPC_LEDGER: "grpc-ledger-api.localnet-2.localhost"`, " volumes: !override", filepath.ToSlash(filepath.Join(tmp, "nginx")) + "/app-provider.conf:/etc/nginx/templates/app-provider.c${APP_PROVIDER_PROFILE}f.template", filepath.ToSlash(filepath.Join(tmp, "nginx")) + "/app-user.conf:/etc/nginx/templates/app-user.c${APP_USER_PROFILE}f.template", @@ -294,14 +310,20 @@ func TestWriteNginxVhostOverlay(t *testing.T) { } func TestWriteNginxVhostOverlay_RejectsEmptyDataDir(t *testing.T) { - if _, err := WriteNginxVhostOverlay("", nil); err == nil { + if _, err := WriteNginxVhostOverlay("", "localnet-2", nil); err == nil { t.Error("expected error for empty dataDir") } } +func TestWriteNginxVhostOverlay_RejectsEmptyInstanceName(t *testing.T) { + if _, err := WriteNginxVhostOverlay(t.TempDir(), "", nil); err == nil { + t.Error("expected error for empty instanceName") + } +} + func TestWriteNginxVhostOverlay_PreservesOperatorEdits(t *testing.T) { tmp := t.TempDir() - if _, err := WriteNginxVhostOverlay(tmp, nil); err != nil { + if _, err := WriteNginxVhostOverlay(tmp, "localnet-2", nil); err != nil { t.Fatalf("first write: %v", err) } edited := filepath.Join(tmp, "nginx", "sv.conf") @@ -310,7 +332,7 @@ func TestWriteNginxVhostOverlay_PreservesOperatorEdits(t *testing.T) { t.Fatal(err) } // A second run must not clobber the operator's edit. - if _, err := WriteNginxVhostOverlay(tmp, nil); err != nil { + if _, err := WriteNginxVhostOverlay(tmp, "localnet-2", nil); err != nil { t.Fatalf("second write: %v", err) } got, err := os.ReadFile(edited) diff --git a/internal/localnet/status.go b/internal/localnet/status.go index eca19856..dbf26aec 100644 --- a/internal/localnet/status.go +++ b/internal/localnet/status.go @@ -85,7 +85,7 @@ func CollectStatus(ctx context.Context, name string, live, includeJWT bool) (typ ContainerPrefix: s.ContainerPrefix, ProjectDir: s.ProjectDir, DataDir: s.DataDir, - Endpoints: endpointsFromPorts(s.Ports), + Endpoints: endpointsFromPorts(s.Name, s.Ports), Credentials: credentialsFor(s.Credentials, includeJWT), } @@ -165,7 +165,7 @@ func collapseState(state, health string) string { } } -func endpointsFromPorts(ports map[string]int) []types.Endpoint { +func endpointsFromPorts(instance string, ports map[string]int) []types.Endpoint { if len(ports) == 0 { return nil } @@ -173,10 +173,11 @@ func endpointsFromPorts(ports map[string]int) []types.Endpoint { known := map[string]meta{ "app_user_ui": {"Wallet · app-user", "http"}, "app_provider_ui": {"Wallet · app-provider", "http"}, - // The bare host URLs below route to each role's wallet via the - // DevKit nginx-vhost overlay (see WriteNginxVhostOverlay); the - // Scan / name-service UIs sit behind their *.localhost vhosts on - // the same ports. + // The wallet URLs are the instance-scoped vhost + // (wallet..localhost) served by the DevKit nginx-vhost + // overlay (see WriteNginxVhostOverlay); the Scan / name-service / + // ledger-API UIs sit behind their own ..localhost + // vhosts on the same ports. "sv_ui": {"Wallet · sv", "http"}, "swagger_ui": {"Swagger · JSON API", "http"}, "postgres": {"Postgres", "postgresql"}, @@ -200,12 +201,18 @@ func endpointsFromPorts(ports map[string]int) []types.Endpoint { if !ok { m = meta{label: k, scheme: "tcp"} } + // Wallet UIs are served at the instance-scoped wallet vhost; + // everything else stays on the bare loopback host. + host := "localhost" + if isWalletUIKey(k) { + host = instanceVHost(VHostServiceWallet, instance) + } out = append(out, types.Endpoint{ Key: k, Label: m.label, Port: p, Scheme: m.scheme, - URL: fmt.Sprintf("%s://localhost:%d", m.scheme, p), + URL: fmt.Sprintf("%s://%s:%d", m.scheme, host, p), }) } return out diff --git a/internal/localnet/status_test.go b/internal/localnet/status_test.go index e74601e7..e0fc6163 100644 --- a/internal/localnet/status_test.go +++ b/internal/localnet/status_test.go @@ -74,7 +74,7 @@ func TestStatus_TableRendersHeaderAndSections(t *testing.T) { t.Fatalf("exit code = %d, stderr=%q", code, errBuf.String()) } body := out.String() - for _, want := range []string{"Name", "demo", "Splice", "0.6.4", "SERVICES", "canton-domain", "participant-alice", "ENDPOINTS", "Wallet · app-user", "http://localhost:4485", "IDENTITIES", "sv-user"} { + for _, want := range []string{"Name", "demo", "Splice", "0.6.4", "SERVICES", "canton-domain", "participant-alice", "ENDPOINTS", "Wallet · app-user", "http://wallet.demo.localhost:4485", "IDENTITIES", "sv-user"} { if !strings.Contains(body, want) { t.Errorf("output missing %q\nfull:\n%s", want, body) } @@ -224,7 +224,7 @@ func TestStatus_UIUnreachableWarnsWithRemediation(t *testing.T) { installFakeStatusProber(t, func(context.Context, *registry.State) ([]types.ServiceStatus, error) { return []types.ServiceStatus{{Name: "nginx", State: "healthy", Image: "nginx"}}, nil }) - installFakeUIProbe(t, func(_ context.Context, rawURL string) error { + installFakeUIProbe(t, func(_ context.Context, rawURL, _ string) error { if rawURL == "http://localhost:4485" { return io.EOF } @@ -256,7 +256,7 @@ func TestStatus_UIReachableRendersNoWarning(t *testing.T) { installFakeStatusProber(t, func(context.Context, *registry.State) ([]types.ServiceStatus, error) { return nil, nil }) - installFakeUIProbe(t, func(context.Context, string) error { return nil }) + installFakeUIProbe(t, func(context.Context, string, string) error { return nil }) var out, errBuf bytes.Buffer code := RunStatus(context.Background(), &out, &errBuf, &StatusOptions{Name: "demo", Format: "table"}) @@ -276,7 +276,7 @@ func TestStatus_JSONCarriesReachability(t *testing.T) { installFakeStatusProber(t, func(context.Context, *registry.State) ([]types.ServiceStatus, error) { return nil, nil }) - installFakeUIProbe(t, func(context.Context, string) error { return io.EOF }) + installFakeUIProbe(t, func(context.Context, string, string) error { return io.EOF }) var out, errBuf bytes.Buffer code := RunStatus(context.Background(), &out, &errBuf, &StatusOptions{Name: "demo", Format: "json"}) @@ -307,7 +307,7 @@ func TestStatus_UIProbeSkippedWhenNotRunning(t *testing.T) { return nil, nil }) called := false - installFakeUIProbe(t, func(context.Context, string) error { called = true; return nil }) + installFakeUIProbe(t, func(context.Context, string, string) error { called = true; return nil }) var out, errBuf bytes.Buffer if code := RunStatus(context.Background(), &out, &errBuf, &StatusOptions{Name: "demo", Format: "table"}); code != ExitSuccess { @@ -325,7 +325,7 @@ func TestStatus_UIProbeSkippedWhenDockerQueryFails(t *testing.T) { return nil, errors.New("docker daemon unreachable") }) called := false - installFakeUIProbe(t, func(context.Context, string) error { called = true; return nil }) + installFakeUIProbe(t, func(context.Context, string, string) error { called = true; return nil }) var out, errBuf bytes.Buffer if code := RunStatus(context.Background(), &out, &errBuf, &StatusOptions{Name: "demo", Format: "table"}); code != ExitSuccess { @@ -356,11 +356,12 @@ func TestCollapseState(t *testing.T) { } func TestEndpointsFromPorts(t *testing.T) { - got := endpointsFromPorts(map[string]int{"app_user_ui": 4485, "weird_service": 9999}) + got := endpointsFromPorts("localnet-2", map[string]int{"app_user_ui": 4485, "weird_service": 9999}) if len(got) != 2 { t.Fatalf("got %d endpoints, want 2", len(got)) } - if got[0].Key != "app_user_ui" || got[0].Label != "Wallet · app-user" || got[0].URL != "http://localhost:4485" { + // Wallet UIs get the instance-scoped wallet vhost URL. + if got[0].Key != "app_user_ui" || got[0].Label != "Wallet · app-user" || got[0].URL != "http://wallet.localnet-2.localhost:4485" { t.Errorf("known endpoint mapping wrong: %+v", got[0]) } if got[1].Key != "weird_service" || got[1].Label != "weird_service" || got[1].Scheme != "tcp" { @@ -371,7 +372,7 @@ func TestEndpointsFromPorts(t *testing.T) { // Pins the per-role wallet endpoint keys and labels; the Wallet // screen resolves its iframe URL by key. func TestEndpointsFromPorts_WalletKeysStablePerRole(t *testing.T) { - got := endpointsFromPorts(map[string]int{ + got := endpointsFromPorts("localnet-2", map[string]int{ "app_user_ui": 4485, "app_provider_ui": 4486, "sv_ui": 4487, @@ -393,11 +394,16 @@ func TestEndpointsFromPorts_WalletKeysStablePerRole(t *testing.T) { if e.Label != label { t.Errorf("key %q label = %q, want %q", key, e.Label, label) } + // Every wallet UI resolves to the instance-scoped wallet vhost. + wantHost := "wallet.localnet-2.localhost" + if !strings.Contains(e.URL, "//"+wantHost+":") { + t.Errorf("key %q URL = %q, want host %q", key, e.URL, wantHost) + } } } func TestEndpointsFromPorts_SkipsZeroPorts(t *testing.T) { - got := endpointsFromPorts(map[string]int{"app_user_ui": 0, "postgres": 5432}) + got := endpointsFromPorts("localnet-2", map[string]int{"app_user_ui": 0, "postgres": 5432}) if len(got) != 1 || got[0].Label != "Postgres" { t.Errorf("zero ports should be skipped, got %+v", got) } diff --git a/internal/localnet/token/registry_url.go b/internal/localnet/token/registry_url.go index 782a7ac0..6887f9e1 100644 --- a/internal/localnet/token/registry_url.go +++ b/internal/localnet/token/registry_url.go @@ -11,20 +11,27 @@ import ( // registry endpoints (`/registry/transfer-instruction/v2/...`) are // served by the Splice scan app (`splice:5012` inside the docker // network), which the LocalNet nginx exposes behind the SV UI port -// under the `scan.localhost` virtual host: +// under the instance-scoped `scan..localhost` virtual host +// (DevKit's nginx-vhost overlay serves only the instance-scoped name, +// not the flat `scan.localhost`): // -// cluster/compose/localnet/conf/nginx/sv.conf -// server { listen ${SV_UI_PORT}; server_name scan.localhost; +// assets/nginx/sv.conf +// server { listen ${SV_UI_PORT}; server_name ${VHOST_SCAN}; // location /registry { proxy_pass http://splice:5012/registry; } } // // So from the host, the registry base URL is the SV UI port with a -// `Host: scan.localhost` header — derived here from the registry -// state's recorded ports. +// `Host: scan..localhost` header — derived here from the +// registry state's recorded ports and the instance name. -// scanVHost is the nginx virtual-host name the scan registry routes -// live under on LocalNet. Real DevNet deployments give scan its own -// DNS name, so this override is LocalNet-specific. -const scanVHost = "scan.localhost" +// scanVHost returns the instance-scoped nginx virtual host the scan +// registry routes live under on LocalNet. Mirrors +// localnet.instanceVHost("scan", …); duplicated here to avoid a package +// import cycle (localnet imports this token package). Real DevNet +// deployments give scan its own DNS name, so this override is +// LocalNet-specific. +func scanVHost(instance string) string { + return "scan." + instance + ".localhost" +} // resolveRegistryURL returns the (baseURL, hostHeader) the registry // client needs to reach the scan app's V2 transfer endpoints for the @@ -52,7 +59,7 @@ func resolveRegistryURL(instance, override string) (baseURL, hostHeader string, "the instance so ports are captured, or pass --registry-url "+ "with the scan app's URL explicitly", instance) } - return "http://localhost:" + strconv.Itoa(port), scanVHost, nil + return "http://localhost:" + strconv.Itoa(port), scanVHost(instance), nil } // resolveRegistryToken returns the bearer JWT the scan registry diff --git a/internal/localnet/ui_reachability.go b/internal/localnet/ui_reachability.go index 57b2171e..13a02605 100644 --- a/internal/localnet/ui_reachability.go +++ b/internal/localnet/ui_reachability.go @@ -39,8 +39,11 @@ var uiProbePortKeys = []string{"app_user_ui", "app_provider_ui", "sv_ui"} const uiProbeTimeout = 2 * time.Second // uiProbeFn is the test seam for the per-URL HTTP probe. nil selects -// defaultUIProbe (same pattern as statusProberFn). -var uiProbeFn func(ctx context.Context, rawURL string) error +// defaultUIProbe (same pattern as statusProberFn). hostHeader, when +// non-empty, overrides the request Host — needed for the wallet UI, +// whose route only matches the instance-scoped wallet vhost (the flat +// loopback Host now falls through to a different server block). +var uiProbeFn func(ctx context.Context, rawURL, hostHeader string) error // uiProbeClient never follows redirects: a 30x from nginx already // proves the UI answers HTTP, and login redirects would otherwise @@ -55,13 +58,16 @@ var uiProbeClient = &http.Client{ // code counts as reachable — a 404/502 still proves nginx is serving // on the port. Only transport-level failures (empty reply, refused, // timeout) count as unreachable. -func defaultUIProbe(ctx context.Context, rawURL string) error { +func defaultUIProbe(ctx context.Context, rawURL, hostHeader string) error { ctx, cancel := context.WithTimeout(ctx, uiProbeTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } + if hostHeader != "" { + req.Host = hostHeader + } resp, err := uiProbeClient.Do(req) if err != nil { return err @@ -98,10 +104,20 @@ func probeUIEndpoints(ctx context.Context, s *registry.State, endpoints []types. if e.Scheme != "http" || !uiPorts[e.Port] { continue } + // Always dial loopback (multi-label *.localhost does NOT resolve + // via the OS/Go resolver on macOS, so we can't dial the vhost + // directly). Wallet UIs only answer on their instance-scoped + // vhost, so carry it as the Host header to validate the real + // route rather than whatever server block owns the bare Host. + var host string + if isWalletUIKey(e.Key) { + host = instanceVHost(VHostServiceWallet, s.Name) + } + dialURL := fmt.Sprintf("http://localhost:%d", e.Port) wg.Add(1) go func() { defer wg.Done() - err := probe(ctx, e.URL) + err := probe(ctx, dialURL, host) if err == nil { e.Reachability = types.ReachabilityOK return @@ -160,6 +176,7 @@ func uiReachabilityCheck(ctx context.Context) docker.CheckResult { instance string key string url string + host string // Host-header override for vhost-scoped routes stale bool detail string // filled by the probe on failure } @@ -179,10 +196,17 @@ func uiReachabilityCheck(ctx context.Context) docker.CheckResult { stale := hasStaleLoopbackOverlay(s.DataDir) for _, key := range uiProbePortKeys { if port := s.Ports[key]; port > 0 { + // All probed UI keys are wallet UIs, which only answer + // on their instance-scoped wallet vhost. + host := "" + if isWalletUIKey(key) { + host = instanceVHost(VHostServiceWallet, e.Name) + } targets = append(targets, uiTarget{ instance: e.Name, key: key, url: fmt.Sprintf("http://localhost:%d", port), + host: host, stale: stale, }) } @@ -210,7 +234,7 @@ func uiReachabilityCheck(ctx context.Context) docker.CheckResult { wg.Add(1) go func() { defer wg.Done() - if perr := probe(ctx, t.url); perr != nil { + if perr := probe(ctx, t.url, t.host); perr != nil { t.detail = uiProbeErrorDetail(perr) } }() diff --git a/internal/localnet/ui_reachability_test.go b/internal/localnet/ui_reachability_test.go index 1e16be16..3515b31a 100644 --- a/internal/localnet/ui_reachability_test.go +++ b/internal/localnet/ui_reachability_test.go @@ -18,7 +18,7 @@ import ( "github.com/bitdynamics-ab/canton-devkit/internal/registry" ) -func installFakeUIProbe(t *testing.T, fn func(ctx context.Context, rawURL string) error) { +func installFakeUIProbe(t *testing.T, fn func(ctx context.Context, rawURL, hostHeader string) error) { t.Helper() prev := uiProbeFn uiProbeFn = fn @@ -31,13 +31,18 @@ func installFakeUIProbe(t *testing.T, fn func(ctx context.Context, rawURL string type recordingUIProbe struct { mu sync.Mutex calls []string - failWith map[string]error // URL → error; nil entry = reachable + hosts map[string]string // URL → Host header the probe received + failWith map[string]error // URL → error; nil entry = reachable } -func (p *recordingUIProbe) probe(_ context.Context, rawURL string) error { +func (p *recordingUIProbe) probe(_ context.Context, rawURL, hostHeader string) error { p.mu.Lock() defer p.mu.Unlock() p.calls = append(p.calls, rawURL) + if p.hosts == nil { + p.hosts = map[string]string{} + } + p.hosts[rawURL] = hostHeader return p.failWith[rawURL] } @@ -47,12 +52,18 @@ func (p *recordingUIProbe) called() []string { return append([]string(nil), p.calls...) } +func (p *recordingUIProbe) hostFor(rawURL string) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.hosts[rawURL] +} + func TestDefaultUIProbe_AnyHTTPStatusIsReachable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) // 404 still proves nginx answers })) defer srv.Close() - if err := defaultUIProbe(context.Background(), srv.URL); err != nil { + if err := defaultUIProbe(context.Background(), srv.URL, ""); err != nil { t.Errorf("404 endpoint should count as reachable, got %v", err) } } @@ -65,7 +76,7 @@ func TestDefaultUIProbe_RedirectIsReachableWithoutFollowing(t *testing.T) { http.Redirect(w, r, "/loop", http.StatusFound) })) defer srv.Close() - if err := defaultUIProbe(context.Background(), srv.URL); err != nil { + if err := defaultUIProbe(context.Background(), srv.URL, ""); err != nil { t.Errorf("30x endpoint should count as reachable, got %v", err) } } @@ -86,7 +97,7 @@ func TestDefaultUIProbe_EmptyReplyIsUnreachable(t *testing.T) { } }() - perr := defaultUIProbe(context.Background(), "http://"+ln.Addr().String()) + perr := defaultUIProbe(context.Background(), "http://"+ln.Addr().String(), "") if perr == nil { t.Fatal("empty-reply endpoint should be unreachable") } @@ -164,7 +175,7 @@ func TestProbeUIEndpoints_MarksOnlyBrowserUIPorts(t *testing.T) { "postgres": 5432, }, } - endpoints := endpointsFromPorts(s.Ports) + endpoints := endpointsFromPorts(s.Name, s.Ports) probeUIEndpoints(context.Background(), s, endpoints) byPort := map[int]types.Endpoint{} @@ -186,6 +197,12 @@ func TestProbeUIEndpoints_MarksOnlyBrowserUIPorts(t *testing.T) { if calls := probe.called(); len(calls) != 2 { t.Errorf("probe called for %v, want exactly the two UI ports", calls) } + // Wallet UIs are dialed on loopback (multi-label *.localhost doesn't + // resolve) but carry their instance-scoped wallet vhost as the Host + // header, else nginx routes to a different server block. + if got := probe.hostFor("http://localhost:4485"); got != "wallet.demo.localhost" { + t.Errorf("app_user_ui probe Host = %q, want wallet.demo.localhost", got) + } } func TestProbeUIEndpoints_StaleOverlayEnrichesDetail(t *testing.T) { @@ -200,7 +217,7 @@ func TestProbeUIEndpoints_StaleOverlayEnrichesDetail(t *testing.T) { t.Fatalf("write overlay: %v", err) } s := ®istry.State{Name: "demo", DataDir: dataDir, Ports: map[string]int{"app_user_ui": 4485}} - endpoints := endpointsFromPorts(s.Ports) + endpoints := endpointsFromPorts(s.Name, s.Ports) probeUIEndpoints(context.Background(), s, endpoints) if !strings.Contains(endpoints[0].ReachabilityDetail, "stale loopback-ports.yaml") { diff --git a/internal/localnet/up.go b/internal/localnet/up.go index 75c1a81e..2f424ba6 100644 --- a/internal/localnet/up.go +++ b/internal/localnet/up.go @@ -346,15 +346,13 @@ func RunUp(ctx context.Context, prog Progress, opts *UpOptions) int { } composeFiles = append(composeFiles, loopbackPath) - // Fix the wallet URLs DevKit advertises. Splice's nginx routes by - // Host header; the bare host URL we print (http://localhost:) - // matches no `*.localhost` vhost and falls through to the first - // server block on the port — the name service (app-provider) or a - // 404 static catch-all (sv). This overlay bind-mounts DevKit-owned - // copies of the role .conf files (with `localhost` added to each - // wallet block) over the upstream ones, without touching the - // content-hash-verified Splice cache. - nginxPath, err := WriteNginxVhostOverlay(dataDir, prog.Err()) + // Serve each Splice UI/API at an instance-scoped virtual host + // (..localhost). Splice's nginx routes by Host + // header; this overlay bind-mounts DevKit-owned copies of the role + // .conf files (server_name = ${VHOST_*}) over the upstream ones and + // injects the per-instance vhost values as nginx container env, + // without touching the content-hash-verified Splice cache. + nginxPath, err := WriteNginxVhostOverlay(dataDir, opts.Name, prog.Err()) if err != nil { prog.FailStep(StepPersistState, "Failed to write nginx-vhost overlay", err) return ExitRuntimeFailure @@ -775,7 +773,13 @@ func renderWelcome(out io.Writer, name, spliceVersion string, state *registry.St if !ok { continue } - url := fmt.Sprintf("%s://localhost:%d", e.scheme, port) + // Wallet UIs are served at the instance-scoped vhost; everything + // else (swagger, postgres) stays on the bare loopback host. + host := "localhost" + if isWalletUIKey(e.key) { + host = instanceVHost(VHostServiceWallet, name) + } + url := fmt.Sprintf("%s://%s:%d", e.scheme, host, port) endpoints = append(endpoints, term.Endpoint{ Category: e.category, Label: e.label, @@ -926,6 +930,18 @@ type endpointDisplay struct { external bool // browsable URL → render "↗" + OSC 8 hyperlink } +// walletUIPortKeys are the state.json port keys whose UI is a wallet +// served behind the instance-scoped wallet vhost. Shared by the +// welcome-screen and status URL builders so both scope wallet URLs the +// same way. +var walletUIPortKeys = map[string]bool{ + "app_user_ui": true, + "app_provider_ui": true, + "sv_ui": true, +} + +func isWalletUIKey(key string) bool { return walletUIPortKeys[key] } + // shortSHA returns the first 7 characters of a git SHA (or the whole // string if shorter). Used only for the uncurated-tag warning. func shortSHA(s string) string { diff --git a/internal/ui/handlers/auth_test.go b/internal/ui/handlers/auth_test.go index d8418d8a..ef64f8ee 100644 --- a/internal/ui/handlers/auth_test.go +++ b/internal/ui/handlers/auth_test.go @@ -216,8 +216,8 @@ func TestAppConfig_EnvFormatIsDefault(t *testing.T) { // Real on-ledger party id (CANTON__PARTY), distinct // from the credential's user name. "CANTON_APP_PROVIDER_PARTY=app-provider::1220deadbeef", - // Scan UI surfaced explicitly with the scan.localhost vhost. - "CANTON_SCAN_UI_URL=http://scan.localhost:4480", + // Scan UI surfaced explicitly with the instance-scoped scan vhost. + "CANTON_SCAN_UI_URL=http://scan.demo.localhost:4480", } { if !strings.Contains(string(body), want) { t.Errorf("env body missing %q\nbody:\n%s", want, body) diff --git a/internal/ui/handlers/instances_test.go b/internal/ui/handlers/instances_test.go index af68a01b..1919a60f 100644 --- a/internal/ui/handlers/instances_test.go +++ b/internal/ui/handlers/instances_test.go @@ -141,7 +141,7 @@ func TestDetail_ReturnsInstance(t *testing.T) { if got.SchemaVersion != types.SchemaVersion { t.Errorf("SchemaVersion = %d, want %d", got.SchemaVersion, types.SchemaVersion) } - if len(got.Endpoints) == 0 || got.Endpoints[0].URL != "http://localhost:4441" { + if len(got.Endpoints) == 0 || got.Endpoints[0].URL != "http://wallet.demo.localhost:4441" { t.Errorf("Endpoints = %+v, want wallet URL", got.Endpoints) } } From c71a2c5636eedc1f5b507b9c0b61ec59e3e4a815 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 15 Jul 2026 12:43:53 +0200 Subject: [PATCH 2/2] feat(localnet): role-scoped nginx vhosts for wallet and ledger APIs Serve role-specific Splice services at role-scoped instance vhosts of the form ...localhost (wallet, json-ledger-api, grpc-ledger-api, ans) so per-role UIs/APIs are unambiguous. Single-per- instance services (scan, sv) keep the shorter . shape. The longer role-scoped names overflow nginx's default 64-byte server-name hash bucket, so add a DevKit-owned http-context tuning snippet (00-devkit-tuning.conf) that bumps server_names_hash_bucket_size to 128 and mount it straight into conf.d; without it nginx aborts on boot with "could not build server_names_hash". --- assets/nginx/00-devkit-tuning.conf | 12 ++++ assets/nginx/app-provider.conf | 17 ++--- assets/nginx/app-user.conf | 17 ++--- assets/nginx/sv.conf | 14 +++-- internal/localnet/env.go | 28 +++++---- internal/localnet/env_test.go | 18 +++--- internal/localnet/overlay.go | 76 ++++++++++++++++++++--- internal/localnet/overlay_test.go | 39 +++++++++--- internal/localnet/status.go | 16 ++--- internal/localnet/status_test.go | 25 ++++---- internal/localnet/ui_reachability.go | 18 ++---- internal/localnet/ui_reachability_test.go | 6 +- internal/localnet/up.go | 38 ++++++++---- internal/ui/handlers/instances_test.go | 2 +- 14 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 assets/nginx/00-devkit-tuning.conf diff --git a/assets/nginx/00-devkit-tuning.conf b/assets/nginx/00-devkit-tuning.conf new file mode 100644 index 00000000..c3ba506a --- /dev/null +++ b/assets/nginx/00-devkit-tuning.conf @@ -0,0 +1,12 @@ +# canton-devkit: http-context tuning, included ahead of the role server +# blocks via nginx's `include /etc/nginx/conf.d/*.conf` (the `00-` prefix +# sorts it first). This file carries NO server{} block — its directives +# land directly in the http context. +# +# DevKit's role-scoped vhosts (...localhost, +# e.g. grpc-ledger-api.app-provider.localnet-2.localhost) are longer than +# nginx's default 64-byte server-name hash bucket, so without this nginx +# fails to boot with: +# [emerg] could not build server_names_hash, you should increase +# server_names_hash_bucket_size: 64 +server_names_hash_bucket_size 128; diff --git a/assets/nginx/app-provider.conf b/assets/nginx/app-provider.conf index fbaa2e49..2699ec8b 100644 --- a/assets/nginx/app-provider.conf +++ b/assets/nginx/app-provider.conf @@ -1,7 +1,8 @@ -# canton-devkit: server_name values are instance-scoped vhosts of the form -# ..localhost, injected via ${VHOST_*} env vars by -# WriteNginxVhostOverlay (e.g. wallet.localnet-2.localhost). The flat Splice -# names (wallet.localhost, ans.localhost, ...) are intentionally NOT served — +# canton-devkit: server_name values are role-scoped instance vhosts of the +# form ...localhost (role = app-provider here), +# injected via ${VHOST_*_APP_PROVIDER} env vars by WriteNginxVhostOverlay +# (e.g. wallet.app-provider.localnet-2.localhost). The flat Splice names +# (wallet.localhost, ans.localhost, ...) are intentionally NOT served — # DevKit advertises only the instance-scoped names so URLs stay unambiguous # across concurrently running localnets. # @@ -11,7 +12,7 @@ # `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. server { listen ${APP_PROVIDER_UI_PORT}; - server_name ${VHOST_ANS}; + server_name ${VHOST_ANS_APP_PROVIDER}; location /api/validator { rewrite ^\/(.*) /$1 break; proxy_pass http://splice:3${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; @@ -34,7 +35,7 @@ server { server { listen ${APP_PROVIDER_UI_PORT}; - server_name ${VHOST_JSON_LEDGER}; + server_name ${VHOST_JSON_LEDGER_APP_PROVIDER}; location / { proxy_pass http://canton:3${PARTICIPANT_JSON_API_PORT_SUFFIX}; include /etc/nginx/includes/cors-headers.conf; @@ -43,7 +44,7 @@ server { server { listen ${APP_PROVIDER_UI_PORT} http2; - server_name ${VHOST_GRPC_LEDGER}; + server_name ${VHOST_GRPC_LEDGER_APP_PROVIDER}; location / { grpc_pass grpc://canton:3${PARTICIPANT_LEDGER_API_PORT_SUFFIX}; } @@ -52,7 +53,7 @@ server { server { listen ${APP_PROVIDER_UI_PORT}; - server_name ${VHOST_WALLET}; + server_name ${VHOST_WALLET_APP_PROVIDER}; # Reverse proxy for /api/validator location /api/validator { diff --git a/assets/nginx/app-user.conf b/assets/nginx/app-user.conf index a70d9aab..2eada518 100644 --- a/assets/nginx/app-user.conf +++ b/assets/nginx/app-user.conf @@ -1,7 +1,8 @@ -# canton-devkit: server_name values are instance-scoped vhosts of the form -# ..localhost, injected via ${VHOST_*} env vars by -# WriteNginxVhostOverlay (e.g. wallet.localnet-2.localhost). The flat Splice -# names (wallet.localhost, ans.localhost, ...) are intentionally NOT served — +# canton-devkit: server_name values are role-scoped instance vhosts of the +# form ...localhost (role = app-user here), +# injected via ${VHOST_*_APP_USER} env vars by WriteNginxVhostOverlay +# (e.g. wallet.app-user.localnet-2.localhost). The flat Splice names +# (wallet.localhost, ans.localhost, ...) are intentionally NOT served — # DevKit advertises only the instance-scoped names so URLs stay unambiguous # across concurrently running localnets. # @@ -11,7 +12,7 @@ # `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. server { listen ${APP_USER_UI_PORT}; - server_name ${VHOST_ANS}; + server_name ${VHOST_ANS_APP_USER}; location /api/validator { rewrite ^\/(.*) /$1 break; proxy_pass http://splice:2${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; @@ -34,7 +35,7 @@ server { server { listen ${APP_USER_UI_PORT}; - server_name ${VHOST_JSON_LEDGER}; + server_name ${VHOST_JSON_LEDGER_APP_USER}; location / { proxy_pass http://canton:2${PARTICIPANT_JSON_API_PORT_SUFFIX}; include /etc/nginx/includes/cors-headers.conf; @@ -43,7 +44,7 @@ server { server { listen ${APP_USER_UI_PORT} http2; - server_name ${VHOST_GRPC_LEDGER}; + server_name ${VHOST_GRPC_LEDGER_APP_USER}; location / { grpc_pass grpc://canton:2${PARTICIPANT_LEDGER_API_PORT_SUFFIX}; } @@ -51,7 +52,7 @@ server { server { listen ${APP_USER_UI_PORT}; - server_name ${VHOST_WALLET}; + server_name ${VHOST_WALLET_APP_USER}; # Reverse proxy for /api/validator location /api/validator { diff --git a/assets/nginx/sv.conf b/assets/nginx/sv.conf index 0206a9e5..c67c377e 100644 --- a/assets/nginx/sv.conf +++ b/assets/nginx/sv.conf @@ -1,8 +1,10 @@ -# canton-devkit: server_name values are instance-scoped vhosts of the form -# ..localhost, injected via ${VHOST_*} env vars by -# WriteNginxVhostOverlay (e.g. wallet.localnet-2.localhost). The flat Splice -# names (wallet.localhost, scan.localhost, sv.localhost, ...) are -# intentionally NOT served — DevKit advertises only the instance-scoped +# canton-devkit: server_name values are instance-scoped vhosts. The SV +# node's own UIs are single-per-instance (scan..localhost via +# ${VHOST_SCAN}, sv..localhost via ${VHOST_SV}); the sv-role +# wallet is role-scoped (wallet.sv..localhost via +# ${VHOST_WALLET_SV}). All injected as env vars by WriteNginxVhostOverlay. +# The flat Splice names (wallet.localhost, scan.localhost, sv.localhost, ...) +# are intentionally NOT served — DevKit advertises only the instance-scoped # names so URLs stay unambiguous across concurrently running localnets. # # NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT @@ -63,7 +65,7 @@ server { server { listen ${SV_UI_PORT}; - server_name ${VHOST_WALLET}; + server_name ${VHOST_WALLET_SV}; # Reverse proxy for /api/validator location /api/validator { diff --git a/internal/localnet/env.go b/internal/localnet/env.go index 0da8e3e8..ca9eee4b 100644 --- a/internal/localnet/env.go +++ b/internal/localnet/env.go @@ -50,10 +50,11 @@ const EnvJWTRedaction = "" // including the participant_ledger/admin/json_ ports captured // by CaptureCantonPorts and the sv_ui (scan UI) port — exactly the // endpoints an external dApp needs. -// 3. A derived CANTON_SCAN_UI_URL when the sv_ui port is recorded, plus -// per-role CANTON__{JSON,GRPC}_LEDGER_API_URL and unqualified +// 3. A derived CANTON_SCAN_UI_URL when the sv_ui port is recorded +// (single-service scan..localhost vhost), plus per-role +// CANTON__{JSON,GRPC}_LEDGER_API_URL and unqualified // CANTON_{JSON,GRPC}_LEDGER_API_URL aliases, each carrying the -// matching instance-scoped ..localhost vhost. +// matching role-scoped ...localhost vhost. // 4. state.Credentials -> CANTON__JWT (redacted unless // includeJWT) plus the user/audience pair that signed it. // 5. state.Parties -> CANTON__PARTY for the role parties @@ -99,12 +100,13 @@ func BuildEnvExport(name string, includeJWT bool) (apitypes.EnvExport, error) { } // The JSON and gRPC Ledger APIs are reachable on each role's UI port - // behind the json-ledger-api / grpc-ledger-api instance-scoped - // vhosts. Emit a per-role URL var for every recorded role UI port, - // plus unqualified CANTON_{JSON,GRPC}_LEDGER_API_URL aliases pointing - // at the app-provider participant (the common dApp target). gRPC URLs - // carry no scheme — the host:port is what a gRPC client dials, with - // the vhost as the :authority pseudo-header. + // behind the role-scoped json-ledger-api...localhost / + // grpc-ledger-api...localhost vhosts. Emit a per-role + // URL var for every recorded role UI port, plus unqualified + // CANTON_{JSON,GRPC}_LEDGER_API_URL aliases pointing at the + // app-provider participant (the common dApp target). gRPC URLs carry + // no scheme — the host:port is what a gRPC client dials, with the + // vhost as the :authority pseudo-header. ledgerRolePortKeys := map[string]string{ "app-user": "app_user_ui", "app-provider": "app_provider_ui", @@ -117,16 +119,16 @@ func BuildEnvExport(name string, includeJWT bool) (apitypes.EnvExport, error) { } prefix := CredEnvKeyPrefix(role) out.Vars[prefix+"_JSON_LEDGER_API_URL"] = fmt.Sprintf("http://%s:%d", - instanceVHost(VHostServiceJSONLedger, name), port) + instanceVHostRole(VHostServiceJSONLedger, role, name), port) out.Vars[prefix+"_GRPC_LEDGER_API_URL"] = fmt.Sprintf("%s:%d", - instanceVHost(VHostServiceGRPCLedger, name), port) + instanceVHostRole(VHostServiceGRPCLedger, role, name), port) } // Unqualified aliases default to the app-provider participant. if port, ok := state.Ports["app_provider_ui"]; ok && port > 0 { out.Vars["CANTON_JSON_LEDGER_API_URL"] = fmt.Sprintf("http://%s:%d", - instanceVHost(VHostServiceJSONLedger, name), port) + instanceVHostRole(VHostServiceJSONLedger, "app-provider", name), port) out.Vars["CANTON_GRPC_LEDGER_API_URL"] = fmt.Sprintf("%s:%d", - instanceVHost(VHostServiceGRPCLedger, name), port) + instanceVHostRole(VHostServiceGRPCLedger, "app-provider", name), port) } for role, cred := range state.Credentials { diff --git a/internal/localnet/env_test.go b/internal/localnet/env_test.go index b68b5f15..68a08ba8 100644 --- a/internal/localnet/env_test.go +++ b/internal/localnet/env_test.go @@ -220,7 +220,7 @@ func TestBuildEnvExport_ScanUIURL(t *testing.T) { } // TestBuildEnvExport_LedgerAPIURLs pins the per-role JSON/gRPC Ledger -// API URL vars behind the instance-scoped ledger vhosts. seedEnvState +// API URL vars behind the role-scoped ledger vhosts. seedEnvState // records app_user_ui (4485) and sv_ui (4480) but no app_provider_ui, // so app-user + sv vars are present and the unqualified aliases are // absent. @@ -233,10 +233,10 @@ func TestBuildEnvExport_LedgerAPIURLs(t *testing.T) { t.Fatalf("BuildEnvExport: %v", err) } want := map[string]string{ - "CANTON_APP_USER_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4485", - "CANTON_APP_USER_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4485", - "CANTON_SV_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4480", - "CANTON_SV_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4480", + "CANTON_APP_USER_JSON_LEDGER_API_URL": "http://json-ledger-api.app-user.demo.localhost:4485", + "CANTON_APP_USER_GRPC_LEDGER_API_URL": "grpc-ledger-api.app-user.demo.localhost:4485", + "CANTON_SV_JSON_LEDGER_API_URL": "http://json-ledger-api.sv.demo.localhost:4480", + "CANTON_SV_GRPC_LEDGER_API_URL": "grpc-ledger-api.sv.demo.localhost:4480", } for k, v := range want { if got := ex.Vars[k]; got != v { @@ -275,10 +275,10 @@ func TestBuildEnvExport_LedgerAPIAliases(t *testing.T) { t.Fatalf("BuildEnvExport: %v", err) } want := map[string]string{ - "CANTON_APP_PROVIDER_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4486", - "CANTON_APP_PROVIDER_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4486", - "CANTON_JSON_LEDGER_API_URL": "http://json-ledger-api.demo.localhost:4486", - "CANTON_GRPC_LEDGER_API_URL": "grpc-ledger-api.demo.localhost:4486", + "CANTON_APP_PROVIDER_JSON_LEDGER_API_URL": "http://json-ledger-api.app-provider.demo.localhost:4486", + "CANTON_APP_PROVIDER_GRPC_LEDGER_API_URL": "grpc-ledger-api.app-provider.demo.localhost:4486", + "CANTON_JSON_LEDGER_API_URL": "http://json-ledger-api.app-provider.demo.localhost:4486", + "CANTON_GRPC_LEDGER_API_URL": "grpc-ledger-api.app-provider.demo.localhost:4486", } for k, v := range want { if got := ex.Vars[k]; got != v { diff --git a/internal/localnet/overlay.go b/internal/localnet/overlay.go index f89a8a28..6fcc510c 100644 --- a/internal/localnet/overlay.go +++ b/internal/localnet/overlay.go @@ -251,6 +251,15 @@ var nginxVhostConfNames = []string{ "sv.conf", } +// nginxTuningConfName is a DevKit-owned http-context snippet mounted +// straight into conf.d (not a Splice template). It bumps +// server_names_hash_bucket_size so nginx can boot with DevKit's longer +// role-scoped vhosts (...localhost); the +// default 64-byte bucket is too small and nginx aborts with a +// "could not build server_names_hash" emerg. The "00-" prefix sorts it +// ahead of the role server blocks in the include glob. +const nginxTuningConfName = "00-devkit-tuning.conf" + // Splice service names for the instance-scoped nginx virtual hosts. Each // UI/API the localnet nginx fronts is reachable at // ..localhost: (see instanceVHost). The names @@ -265,6 +274,11 @@ const ( VHostServiceGRPCLedger = "grpc-ledger-api" ) +// vhostRoles are the wallet-serving roles, in the order their +// VHOST_WALLET_ env vars are emitted. Each maps to a role .conf on +// its own UI port. +var vhostRoles = []string{"app-user", "app-provider", "sv"} + // instanceVHost returns the instance-scoped nginx virtual host for a // service, e.g. instanceVHost("wallet", "localnet-2") == // "wallet.localnet-2.localhost". This is the single source of truth for @@ -282,6 +296,22 @@ func instanceVHost(service, instance string) string { return service + "." + instance + ".localhost" } +// instanceVHostRole is instanceVHost for services that exist once per +// role/participant (wallet, ans, json-ledger-api, grpc-ledger-api). The +// role becomes its own subdomain label so the URL self-describes which +// participant it hits, e.g. +// instanceVHostRole("wallet", "app-user", "localnet-2") == +// "wallet.app-user.localnet-2.localhost". Single-per-instance services +// (scan, sv) keep the shorter instanceVHost shape. +// +// Same resolution caveat as instanceVHost — one extra label doesn't +// change browser/curl/Go behavior, but the stricter JVM/Node/Python +// resolvers still need an explicit Host header / :authority or an +// /etc/hosts entry. +func instanceVHostRole(service, role, instance string) string { + return service + "." + role + "." + instance + ".localhost" +} + // WriteNginxVhostOverlay rewrites the hostnames DevKit's nginx serves so // each UI/API is reachable at an instance-scoped virtual host of the // form ..localhost (e.g. wallet.localnet-2.localhost). @@ -336,6 +366,17 @@ func WriteNginxVhostOverlay(dataDir, instanceName string, warnw io.Writer) (stri } } + // The http-context tuning snippet is a DevKit invariant (nginx won't + // boot without the bigger bucket), so it is NOT edit-preserving — + // always overwrite with the bundled copy. + tuning, err := fs.ReadFile(assets.FS, "nginx/"+nginxTuningConfName) + if err != nil { + return "", fmt.Errorf("read embedded nginx/%s: %w", nginxTuningConfName, err) + } + if err := os.WriteFile(filepath.Join(nginxDir, nginxTuningConfName), tuning, 0o644); err != nil { + return "", fmt.Errorf("materialize nginx/%s: %w", nginxTuningConfName, err) + } + // Compose overlay: inject the per-instance ${VHOST_*} values as nginx // container env (the official nginx image runs envsubst on the mounted // templates at boot), then re-declare the full nginx volume list under @@ -343,26 +384,47 @@ func WriteNginxVhostOverlay(dataDir, instanceName string, warnw io.Writer) (stri // nginx.conf and includes/ stay on the upstream cache paths. The // destination keeps Splice's `${..._PROFILE}` gating so a disabled // profile still parks the file at an inactive `.coff.template` name. + // + // Per-role services (wallet, ans, json/grpc-ledger-api) get a + // role-suffixed env var per role .conf (VHOST_WALLET_APP_USER, …) so + // each conf can carry a distinct role-scoped hostname while sharing + // the one nginx env namespace. Single-per-instance services (scan, + // sv) keep a plain VHOST_SCAN / VHOST_SV. var b strings.Builder b.WriteString("# Generated by canton-devkit.\n") - b.WriteString("# Serves each Splice UI/API at an instance-scoped virtual host\n") - b.WriteString("# (." + instanceName + ".localhost:) and remaps\n") - b.WriteString("# nginx's server configs onto the DevKit-owned copies.\n") + b.WriteString("# Serves each Splice UI/API at an instance-scoped virtual host:\n") + b.WriteString("# per-role: .." + instanceName + ".localhost:\n") + b.WriteString("# single: ." + instanceName + ".localhost:\n") + b.WriteString("# and remaps nginx's server configs onto the DevKit-owned copies.\n") b.WriteString("# Requires Docker Compose v2.24.0+ for the !override YAML tag.\n") b.WriteString("services:\n") b.WriteString(" nginx:\n") b.WriteString(" environment:\n") - fmt.Fprintf(&b, " VHOST_WALLET: %q\n", instanceVHost(VHostServiceWallet, instanceName)) - fmt.Fprintf(&b, " VHOST_ANS: %q\n", instanceVHost(VHostServiceANS, instanceName)) + // Wallet is served for every role; ans and the ledger APIs only by + // the app-user / app-provider confs (sv.conf has no such blocks). + for _, role := range vhostRoles { + seg := normalizeEnvSegment(role) + fmt.Fprintf(&b, " VHOST_WALLET_%s: %q\n", seg, instanceVHostRole(VHostServiceWallet, role, instanceName)) + } + for _, role := range []string{"app-user", "app-provider"} { + seg := normalizeEnvSegment(role) + fmt.Fprintf(&b, " VHOST_ANS_%s: %q\n", seg, instanceVHostRole(VHostServiceANS, role, instanceName)) + fmt.Fprintf(&b, " VHOST_JSON_LEDGER_%s: %q\n", seg, instanceVHostRole(VHostServiceJSONLedger, role, instanceName)) + fmt.Fprintf(&b, " VHOST_GRPC_LEDGER_%s: %q\n", seg, instanceVHostRole(VHostServiceGRPCLedger, role, instanceName)) + } + // Single-per-instance services (the SV node's scan / sv UIs). fmt.Fprintf(&b, " VHOST_SCAN: %q\n", instanceVHost(VHostServiceScan, instanceName)) fmt.Fprintf(&b, " VHOST_SV: %q\n", instanceVHost(VHostServiceSV, instanceName)) - fmt.Fprintf(&b, " VHOST_JSON_LEDGER: %q\n", instanceVHost(VHostServiceJSONLedger, instanceName)) - fmt.Fprintf(&b, " VHOST_GRPC_LEDGER: %q\n", instanceVHost(VHostServiceGRPCLedger, instanceName)) b.WriteString(" volumes: !override\n") b.WriteString(" - ${LOCALNET_DIR}/conf/nginx/nginx.conf:/etc/nginx/nginx.conf\n") fmt.Fprintf(&b, " - %s/app-provider.conf:/etc/nginx/templates/app-provider.c${APP_PROVIDER_PROFILE}f.template\n", filepath.ToSlash(nginxDir)) fmt.Fprintf(&b, " - %s/app-user.conf:/etc/nginx/templates/app-user.c${APP_USER_PROFILE}f.template\n", filepath.ToSlash(nginxDir)) fmt.Fprintf(&b, " - %s/sv.conf:/etc/nginx/templates/sv.c${SV_PROFILE}f.template\n", filepath.ToSlash(nginxDir)) + // http-context tuning snippet: mount straight into conf.d (bypassing + // the templates/envsubst step, since it has no ${VHOST_*} vars) so + // `include /etc/nginx/conf.d/*.conf` picks it up ahead of the + // envsubst-rendered role blocks. + fmt.Fprintf(&b, " - %s/%s:/etc/nginx/conf.d/%s\n", filepath.ToSlash(nginxDir), nginxTuningConfName, nginxTuningConfName) b.WriteString(" - ${LOCALNET_DIR}/conf/nginx/swagger-ui:/etc/nginx/includes\n") path := filepath.Join(dataDir, "nginx-vhosts.yaml") diff --git a/internal/localnet/overlay_test.go b/internal/localnet/overlay_test.go index 7f8acc13..fa6b0624 100644 --- a/internal/localnet/overlay_test.go +++ b/internal/localnet/overlay_test.go @@ -236,7 +236,14 @@ func TestWriteNginxVhostOverlay(t *testing.T) { // The three role .conf files must be materialized. Their server_name // directives are ${VHOST_*} env vars (expanded by envsubst at nginx // boot), so the materialized copies carry the placeholders, not the - // resolved hostnames. The flat Splice names must be gone. + // resolved hostnames. The wallet block is role-scoped, so each conf + // carries its own ${VHOST_WALLET_} var. The flat Splice names + // must be gone. + walletVarByConf := map[string]string{ + "app-provider.conf": "${VHOST_WALLET_APP_PROVIDER}", + "app-user.conf": "${VHOST_WALLET_APP_USER}", + "sv.conf": "${VHOST_WALLET_SV}", + } for _, name := range nginxVhostConfNames { confPath := filepath.Join(tmp, "nginx", name) body, rerr := os.ReadFile(confPath) @@ -244,8 +251,9 @@ func TestWriteNginxVhostOverlay(t *testing.T) { t.Fatalf("missing materialized %s: %v", name, rerr) } s := string(body) - if !strings.Contains(s, "server_name ${VHOST_WALLET};") { - t.Errorf("%s: wallet block missing ${VHOST_WALLET} server_name\n---\n%s", name, s) + walletVar := walletVarByConf[name] + if !strings.Contains(s, "server_name "+walletVar+";") { + t.Errorf("%s: wallet block missing %s server_name\n---\n%s", name, walletVar, s) } // Drop the flat wallet.localhost name entirely (comments aside). for _, line := range strings.Split(s, "\n") { @@ -278,6 +286,17 @@ func TestWriteNginxVhostOverlay(t *testing.T) { t.Errorf("sv.conf missing the `server_name _;` catch-all directive\n%s", svBody) } + // The http-context tuning snippet must be materialized with the + // bigger server-name hash bucket — nginx can't boot with the longer + // role-scoped vhosts under the default 64-byte bucket. + tuning, err := os.ReadFile(filepath.Join(tmp, "nginx", "00-devkit-tuning.conf")) + if err != nil { + t.Fatalf("missing materialized tuning conf: %v", err) + } + if !strings.Contains(string(tuning), "server_names_hash_bucket_size 128;") { + t.Errorf("tuning conf missing bucket-size directive\n%s", tuning) + } + // The compose overlay must inject the per-instance ${VHOST_*} values // as nginx container env and remap the three role templates onto the // materialized copies under !override while leaving nginx.conf and @@ -290,16 +309,22 @@ func TestWriteNginxVhostOverlay(t *testing.T) { for _, want := range []string{ " nginx:", " environment:", - ` VHOST_WALLET: "wallet.localnet-2.localhost"`, - ` VHOST_ANS: "ans.localnet-2.localhost"`, + ` VHOST_WALLET_APP_USER: "wallet.app-user.localnet-2.localhost"`, + ` VHOST_WALLET_APP_PROVIDER: "wallet.app-provider.localnet-2.localhost"`, + ` VHOST_WALLET_SV: "wallet.sv.localnet-2.localhost"`, + ` VHOST_ANS_APP_USER: "ans.app-user.localnet-2.localhost"`, + ` VHOST_ANS_APP_PROVIDER: "ans.app-provider.localnet-2.localhost"`, + ` VHOST_JSON_LEDGER_APP_USER: "json-ledger-api.app-user.localnet-2.localhost"`, + ` VHOST_JSON_LEDGER_APP_PROVIDER: "json-ledger-api.app-provider.localnet-2.localhost"`, + ` VHOST_GRPC_LEDGER_APP_USER: "grpc-ledger-api.app-user.localnet-2.localhost"`, + ` VHOST_GRPC_LEDGER_APP_PROVIDER: "grpc-ledger-api.app-provider.localnet-2.localhost"`, ` VHOST_SCAN: "scan.localnet-2.localhost"`, ` VHOST_SV: "sv.localnet-2.localhost"`, - ` VHOST_JSON_LEDGER: "json-ledger-api.localnet-2.localhost"`, - ` VHOST_GRPC_LEDGER: "grpc-ledger-api.localnet-2.localhost"`, " volumes: !override", filepath.ToSlash(filepath.Join(tmp, "nginx")) + "/app-provider.conf:/etc/nginx/templates/app-provider.c${APP_PROVIDER_PROFILE}f.template", filepath.ToSlash(filepath.Join(tmp, "nginx")) + "/app-user.conf:/etc/nginx/templates/app-user.c${APP_USER_PROFILE}f.template", filepath.ToSlash(filepath.Join(tmp, "nginx")) + "/sv.conf:/etc/nginx/templates/sv.c${SV_PROFILE}f.template", + filepath.ToSlash(filepath.Join(tmp, "nginx")) + "/00-devkit-tuning.conf:/etc/nginx/conf.d/00-devkit-tuning.conf", "${LOCALNET_DIR}/conf/nginx/nginx.conf:/etc/nginx/nginx.conf", "${LOCALNET_DIR}/conf/nginx/swagger-ui:/etc/nginx/includes", } { diff --git a/internal/localnet/status.go b/internal/localnet/status.go index dbf26aec..4346252c 100644 --- a/internal/localnet/status.go +++ b/internal/localnet/status.go @@ -173,11 +173,11 @@ func endpointsFromPorts(instance string, ports map[string]int) []types.Endpoint known := map[string]meta{ "app_user_ui": {"Wallet · app-user", "http"}, "app_provider_ui": {"Wallet · app-provider", "http"}, - // The wallet URLs are the instance-scoped vhost - // (wallet..localhost) served by the DevKit nginx-vhost - // overlay (see WriteNginxVhostOverlay); the Scan / name-service / - // ledger-API UIs sit behind their own ..localhost - // vhosts on the same ports. + // The wallet URLs are the role-scoped vhost + // (wallet...localhost) served by the DevKit + // nginx-vhost overlay (see WriteNginxVhostOverlay); the Scan / + // name-service / ledger-API UIs sit behind their own + // [.]..localhost vhosts on the same ports. "sv_ui": {"Wallet · sv", "http"}, "swagger_ui": {"Swagger · JSON API", "http"}, "postgres": {"Postgres", "postgresql"}, @@ -201,11 +201,11 @@ func endpointsFromPorts(instance string, ports map[string]int) []types.Endpoint if !ok { m = meta{label: k, scheme: "tcp"} } - // Wallet UIs are served at the instance-scoped wallet vhost; + // Wallet UIs are served at the role-scoped wallet vhost; // everything else stays on the bare loopback host. host := "localhost" - if isWalletUIKey(k) { - host = instanceVHost(VHostServiceWallet, instance) + if vh := walletVHostForKey(k, instance); vh != "" { + host = vh } out = append(out, types.Endpoint{ Key: k, diff --git a/internal/localnet/status_test.go b/internal/localnet/status_test.go index e0fc6163..fff3ce14 100644 --- a/internal/localnet/status_test.go +++ b/internal/localnet/status_test.go @@ -74,7 +74,7 @@ func TestStatus_TableRendersHeaderAndSections(t *testing.T) { t.Fatalf("exit code = %d, stderr=%q", code, errBuf.String()) } body := out.String() - for _, want := range []string{"Name", "demo", "Splice", "0.6.4", "SERVICES", "canton-domain", "participant-alice", "ENDPOINTS", "Wallet · app-user", "http://wallet.demo.localhost:4485", "IDENTITIES", "sv-user"} { + for _, want := range []string{"Name", "demo", "Splice", "0.6.4", "SERVICES", "canton-domain", "participant-alice", "ENDPOINTS", "Wallet · app-user", "http://wallet.app-user.demo.localhost:4485", "IDENTITIES", "sv-user"} { if !strings.Contains(body, want) { t.Errorf("output missing %q\nfull:\n%s", want, body) } @@ -360,8 +360,8 @@ func TestEndpointsFromPorts(t *testing.T) { if len(got) != 2 { t.Fatalf("got %d endpoints, want 2", len(got)) } - // Wallet UIs get the instance-scoped wallet vhost URL. - if got[0].Key != "app_user_ui" || got[0].Label != "Wallet · app-user" || got[0].URL != "http://wallet.localnet-2.localhost:4485" { + // Wallet UIs get the role-scoped wallet vhost URL. + if got[0].Key != "app_user_ui" || got[0].Label != "Wallet · app-user" || got[0].URL != "http://wallet.app-user.localnet-2.localhost:4485" { t.Errorf("known endpoint mapping wrong: %+v", got[0]) } if got[1].Key != "weird_service" || got[1].Label != "weird_service" || got[1].Scheme != "tcp" { @@ -381,23 +381,22 @@ func TestEndpointsFromPorts_WalletKeysStablePerRole(t *testing.T) { for _, e := range got { byKey[e.Key] = e } - for key, label := range map[string]string{ - "app_user_ui": "Wallet · app-user", - "app_provider_ui": "Wallet · app-provider", - "sv_ui": "Wallet · sv", + for key, want := range map[string]struct{ label, host string }{ + "app_user_ui": {"Wallet · app-user", "wallet.app-user.localnet-2.localhost"}, + "app_provider_ui": {"Wallet · app-provider", "wallet.app-provider.localnet-2.localhost"}, + "sv_ui": {"Wallet · sv", "wallet.sv.localnet-2.localhost"}, } { e, ok := byKey[key] if !ok { t.Errorf("no endpoint with key %q: %+v", key, got) continue } - if e.Label != label { - t.Errorf("key %q label = %q, want %q", key, e.Label, label) + if e.Label != want.label { + t.Errorf("key %q label = %q, want %q", key, e.Label, want.label) } - // Every wallet UI resolves to the instance-scoped wallet vhost. - wantHost := "wallet.localnet-2.localhost" - if !strings.Contains(e.URL, "//"+wantHost+":") { - t.Errorf("key %q URL = %q, want host %q", key, e.URL, wantHost) + // Every wallet UI resolves to its role-scoped wallet vhost. + if !strings.Contains(e.URL, "//"+want.host+":") { + t.Errorf("key %q URL = %q, want host %q", key, e.URL, want.host) } } } diff --git a/internal/localnet/ui_reachability.go b/internal/localnet/ui_reachability.go index 13a02605..1405ffd1 100644 --- a/internal/localnet/ui_reachability.go +++ b/internal/localnet/ui_reachability.go @@ -106,13 +106,10 @@ func probeUIEndpoints(ctx context.Context, s *registry.State, endpoints []types. } // Always dial loopback (multi-label *.localhost does NOT resolve // via the OS/Go resolver on macOS, so we can't dial the vhost - // directly). Wallet UIs only answer on their instance-scoped - // vhost, so carry it as the Host header to validate the real - // route rather than whatever server block owns the bare Host. - var host string - if isWalletUIKey(e.Key) { - host = instanceVHost(VHostServiceWallet, s.Name) - } + // directly). Wallet UIs only answer on their role-scoped vhost, + // so carry it as the Host header to validate the real route + // rather than whatever server block owns the bare Host. + host := walletVHostForKey(e.Key, s.Name) dialURL := fmt.Sprintf("http://localhost:%d", e.Port) wg.Add(1) go func() { @@ -197,11 +194,8 @@ func uiReachabilityCheck(ctx context.Context) docker.CheckResult { for _, key := range uiProbePortKeys { if port := s.Ports[key]; port > 0 { // All probed UI keys are wallet UIs, which only answer - // on their instance-scoped wallet vhost. - host := "" - if isWalletUIKey(key) { - host = instanceVHost(VHostServiceWallet, e.Name) - } + // on their role-scoped wallet vhost. + host := walletVHostForKey(key, e.Name) targets = append(targets, uiTarget{ instance: e.Name, key: key, diff --git a/internal/localnet/ui_reachability_test.go b/internal/localnet/ui_reachability_test.go index 3515b31a..4d5c3e22 100644 --- a/internal/localnet/ui_reachability_test.go +++ b/internal/localnet/ui_reachability_test.go @@ -198,10 +198,10 @@ func TestProbeUIEndpoints_MarksOnlyBrowserUIPorts(t *testing.T) { t.Errorf("probe called for %v, want exactly the two UI ports", calls) } // Wallet UIs are dialed on loopback (multi-label *.localhost doesn't - // resolve) but carry their instance-scoped wallet vhost as the Host + // resolve) but carry their role-scoped wallet vhost as the Host // header, else nginx routes to a different server block. - if got := probe.hostFor("http://localhost:4485"); got != "wallet.demo.localhost" { - t.Errorf("app_user_ui probe Host = %q, want wallet.demo.localhost", got) + if got := probe.hostFor("http://localhost:4485"); got != "wallet.app-user.demo.localhost" { + t.Errorf("app_user_ui probe Host = %q, want wallet.app-user.demo.localhost", got) } } diff --git a/internal/localnet/up.go b/internal/localnet/up.go index 2f424ba6..2a4ed493 100644 --- a/internal/localnet/up.go +++ b/internal/localnet/up.go @@ -773,11 +773,12 @@ func renderWelcome(out io.Writer, name, spliceVersion string, state *registry.St if !ok { continue } - // Wallet UIs are served at the instance-scoped vhost; everything - // else (swagger, postgres) stays on the bare loopback host. + // Wallet UIs are served at the role-scoped wallet vhost; + // everything else (swagger, postgres) stays on the bare loopback + // host. host := "localhost" - if isWalletUIKey(e.key) { - host = instanceVHost(VHostServiceWallet, name) + if vh := walletVHostForKey(e.key, name); vh != "" { + host = vh } url := fmt.Sprintf("%s://%s:%d", e.scheme, host, port) endpoints = append(endpoints, term.Endpoint{ @@ -930,17 +931,28 @@ type endpointDisplay struct { external bool // browsable URL → render "↗" + OSC 8 hyperlink } -// walletUIPortKeys are the state.json port keys whose UI is a wallet -// served behind the instance-scoped wallet vhost. Shared by the -// welcome-screen and status URL builders so both scope wallet URLs the -// same way. -var walletUIPortKeys = map[string]bool{ - "app_user_ui": true, - "app_provider_ui": true, - "sv_ui": true, +// walletUIRoleByKey maps a state.json UI port key to the Splice role +// whose wallet is served behind that port. The wallet vhost is +// role-scoped (wallet...localhost), so URL builders and +// the reachability probe resolve the role from the port key here. +// Shared by the welcome-screen, status, and probe code so they all +// scope wallet URLs the same way. +var walletUIRoleByKey = map[string]string{ + "app_user_ui": "app-user", + "app_provider_ui": "app-provider", + "sv_ui": "sv", } -func isWalletUIKey(key string) bool { return walletUIPortKeys[key] } +// walletVHostForKey returns the role-scoped wallet vhost for a UI port +// key, e.g. walletVHostForKey("app_user_ui", "localnet-2") == +// "wallet.app-user.localnet-2.localhost". Empty for non-wallet keys. +func walletVHostForKey(key, instance string) string { + role, ok := walletUIRoleByKey[key] + if !ok { + return "" + } + return instanceVHostRole(VHostServiceWallet, role, instance) +} // shortSHA returns the first 7 characters of a git SHA (or the whole // string if shorter). Used only for the uncurated-tag warning. diff --git a/internal/ui/handlers/instances_test.go b/internal/ui/handlers/instances_test.go index 1919a60f..9641f042 100644 --- a/internal/ui/handlers/instances_test.go +++ b/internal/ui/handlers/instances_test.go @@ -141,7 +141,7 @@ func TestDetail_ReturnsInstance(t *testing.T) { if got.SchemaVersion != types.SchemaVersion { t.Errorf("SchemaVersion = %d, want %d", got.SchemaVersion, types.SchemaVersion) } - if len(got.Endpoints) == 0 || got.Endpoints[0].URL != "http://wallet.demo.localhost:4441" { + if len(got.Endpoints) == 0 || got.Endpoints[0].URL != "http://wallet.app-user.demo.localhost:4441" { t.Errorf("Endpoints = %+v, want wallet URL", got.Endpoints) } }