From 7e625c587635ccc578492c391eccfedecb46cfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 22 Oct 2026 19:00:26 +0200 Subject: [PATCH] v0.33.7_docker_hostnames --- CHANGELOG.md | 1 + SHA256SUMS | 10 +++---- docker-agent/README.md | 4 +++ docker-agent/docker.go | 49 +++++++++++++++++++++++++++---- docker-agent/main_test.go | 38 +++++++++++++++++++++++- docs/TEST_REPORT.md | 61 --------------------------------------- 6 files changed, 90 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 114fb7b..a360f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## 0.33.7 +- Docker agent: suggest container names rather than name-port for newly published vhosts. Append port only for conflicts; preserve stable service keys and existing vhost hostnames. - Replace the redundant **Arrange Overview** visual editor modal with an account-scoped Dashboard preset manager. Save, load/overwrite and delete up to 20 named presets containing Dashboard density, card order/visibility and diff --git a/SHA256SUMS b/SHA256SUMS index b4d6067..41b18ec 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,7 +2,7 @@ 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore c793a8be7f4b76350e04ab36dbeec3792bb2dcf33c87a86f76fa2f330e26dd9f AGENTS.md -4d86a27c6b8f8931e4c43bbbd0da1a5880ffafd17bd139bd9ba93fa1e7c02af4 CHANGELOG.md +538acee273827bd09041e25fe7ee804284eb66ade85537cf3dcd7b968ec33b43 CHANGELOG.md d5dc2b778af98f52ad52a7515e2d1b4ba8c2e01a04cc6e02564377075b01c501 Cargo.lock 7fd5dd1ccae8fac77bbbea4bbf960f5cf762f712ff17a2e629a5dd8629eb7ff9 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile @@ -13,14 +13,14 @@ f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh -5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md +82fbf6deba6841f843ea5fb419efa03ee820c7fb2af00b1bf4d8f6cddad52b9e docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example -bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/docker.go +dcdf8b060d9529ceb8c286d0a592fa4bd876bd7fdcd1a178e4295e13cd88e6c0 docker-agent/docker.go 9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod 9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go -3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go +34552a436bc88a49d1a82a60875d4bd1c0c0b808759b6a099a7f954f44701ab0 docker-agent/main_test.go 68ad007eac216f115809695d3c2c6651e2850ff3d7334685ae6e49bc21b01087 docker-agent/model.go e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service @@ -64,7 +64,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY. 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 84d1523a18dc98d266a290ed7fb8fe2624bb37fd54f4f0b991ba37a7624d8a3a docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -420d8e3976882ef42c2b7dde409560825db72a22405b023876f9c39055671754 docs/TEST_REPORT.md +193704656501643c86470be699d2e1c05b5fbb678f68ffac1b3f5c51172bf433 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md diff --git a/docker-agent/README.md b/docker-agent/README.md index 1dbbefb..4647dd9 100644 --- a/docker-agent/README.md +++ b/docker-agent/README.md @@ -44,3 +44,7 @@ go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent . The inventory endpoint is `GET /v1/inventory` with `Authorization: Bearer `. `/healthz` does not expose Docker inventory. Both are served over HTTPS by default. `RUSTPROXY_DOCKER_ADVERTISE_IP` marks a preferred address but does not suppress automatic discovery of the host's other addresses. `RUSTPROXY_DOCKER_PUBLIC_IP_URL` adds the externally observed address when enabled. RustProxy can choose a concrete reported address independently for every Docker-backed vhost. + +## Default vhost hostname suggestions + +The agent reports a DNS-safe Docker container name as `technical_name` (or the Compose service name when the container name is unavailable). RustProxy pre-fills this name for newly published vhosts. For example, `/grafana` suggests `grafana`, not `grafana-3000`. If multiple published services have the same normalized container name, the agent appends the container's internal port; remaining collisions get a deterministic identifier. The stable `service_key` is unchanged. Already saved vhost hostnames are never rewritten by discovery. diff --git a/docker-agent/docker.go b/docker-agent/docker.go index 66b965b..771b69e 100644 --- a/docker-agent/docker.go +++ b/docker-agent/docker.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/sha256" "encoding/json" "fmt" "io" @@ -92,20 +93,18 @@ func serviceIdentity(c dockerContainer, p dockerPort) (key, technical string) { project := c.Labels["com.docker.compose.project"] composeService := c.Labels["com.docker.compose.service"] replica := c.Labels["com.docker.compose.container-number"] - suffix := strconv.Itoa(int(p.PrivatePort)) if project != "" && composeService != "" { if replica == "" { replica = name } key = fmt.Sprintf("compose:%s/%s/%s:%d/%s", project, composeService, replica, p.PrivatePort, p.Type) - technicalBase := project + "-" + composeService - if replica != "1" { - technicalBase += "-" + replica + technical = dnsLabel(name) + if name == "container" { + technical = dnsLabel(composeService) } - technical = dnsLabel(technicalBase + "-" + suffix) } else { key = fmt.Sprintf("container:%s:%d/%s", name, p.PrivatePort, p.Type) - technical = dnsLabel(name + "-" + suffix) + technical = dnsLabel(name) } return key, technical } @@ -163,5 +162,43 @@ func servicesFrom(containers []dockerContainer) []service { out = append(out, *item) } sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + // Prefer the Docker container name. Add the internal port only when names + // collide; retain stable service keys and prevent any duplicate hostnames. + counts := make(map[string]int, len(out)) + for _, item := range out { + counts[item.TechnicalName]++ + } + for i := range out { + if counts[out[i].TechnicalName] > 1 { + out[i].TechnicalName = dnsLabel(out[i].TechnicalName + "-" + strconv.Itoa(int(out[i].PrivatePort))) + } + } + // Port suffixes can still collide after normalization/truncation. + // Resolve these deterministically using a short hash of the stable key. + counts = make(map[string]int, len(out)) + for _, item := range out { + counts[item.TechnicalName]++ + } + used := make(map[string]struct{}, len(out)) + for i := range out { + name := out[i].TechnicalName + if counts[name] > 1 { + digest := sha256.Sum256([]byte(out[i].Key)) + name = fmt.Sprintf("%s-%x", strings.TrimRight(name[:min(len(name), 48)], "-"), digest[:6]) + } + // Extremely unlikely hash collisions are still resolved without + // changing the stable key or depending on Docker listing order. + for attempt := 0; ; attempt++ { + candidate := name + if attempt > 0 { + candidate = fmt.Sprintf("%s-%d", name[:min(len(name), 56)], attempt) + } + if _, exists := used[candidate]; !exists { + out[i].TechnicalName = candidate + used[candidate] = struct{}{} + break + } + } + } return out } diff --git a/docker-agent/main_test.go b/docker-agent/main_test.go index 5a5ffd1..81f7e12 100644 --- a/docker-agent/main_test.go +++ b/docker-agent/main_test.go @@ -41,7 +41,7 @@ func TestServicesFromUsesStableComposeIdentity(t *testing.T) { if len(got) != 1 { t.Fatalf("got %d services", len(got)) } - if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "lab-web-8080" { + if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "web-1" { t.Fatalf("unexpected service %#v", got[0]) } if len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18080 { @@ -72,6 +72,42 @@ func TestServicesFromKeepsComposeReplicasAndAllBindings(t *testing.T) { } } +func TestServiceNamesPreferContainerAndOnlyAppendPortsForCollisions(t *testing.T) { + items := []dockerContainer{ + {ID: "a", Names: []string{"/grafana"}, Labels: map[string]string{}, Ports: []dockerPort{{PrivatePort: 3000, PublicPort: 3000, Type: "tcp", IP: "0.0.0.0"}}}, + {ID: "b", Names: []string{"/frontend"}, Labels: map[string]string{"com.docker.compose.project": "example", "com.docker.compose.service": "web", "com.docker.compose.container-number": "1"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 8080, Type: "tcp", IP: "0.0.0.0"}}}, + {ID: "c", Names: []string{"/shared"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 8081, Type: "tcp", IP: "0.0.0.0"}, {PrivatePort: 443, PublicPort: 8443, Type: "tcp", IP: "0.0.0.0"}}}, + } + got := servicesFrom(items) + names := map[string]string{} + for _, item := range got { + names[item.Key] = item.TechnicalName + } + if names["container:grafana:3000/tcp"] != "grafana" || names["compose:example/web/1:80/tcp"] != "frontend" { + t.Fatalf("incorrect short names: %#v", names) + } + if names["container:shared:80/tcp"] != "shared-80" || names["container:shared:443/tcp"] != "shared-443" { + t.Fatalf("incorrect collision names: %#v", names) + } +} + +func TestServiceNamesRemainUniqueAfterDNSNormalization(t *testing.T) { + items := []dockerContainer{ + {Names: []string{"/A_B"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 18080, Type: "tcp"}}}, + {Names: []string{"/a-b"}, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 18081, Type: "tcp"}}}, + } + got := servicesFrom(items) + if len(got) != 2 || got[0].TechnicalName == got[1].TechnicalName { + t.Fatalf("names not unique: %#v", got) + } + reversed := servicesFrom([]dockerContainer{items[1], items[0]}) + for i := range got { + if got[i].TechnicalName != reversed[i].TechnicalName { + t.Fatalf("unstable names: %#v vs %#v", got, reversed) + } + } +} + func TestServicesFromSkipsLoopbackOnlyBindings(t *testing.T) { items := []dockerContainer{{ID: "abc", Names: []string{"/app"}, Labels: map[string]string{}, Ports: []dockerPort{ {IP: "127.0.0.1", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 7261581..e69de29 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,61 +0,0 @@ -# RustProxy 0.33.7 - verification report - -## Scope (2026-10-15) - -0.33.7 changes the Dashboard UI without changing proxy traffic handling or -telemetry collection: - -- Replace the duplicated **Arrange Overview** editor modal with an account-scoped - **Dashboard presets** manager. A named preset stores Dashboard density, card - order, visibility and optional custom widths; presets can be saved/updated, - loaded and deleted through the existing administrator preferences document. -- Keep actual Dashboard editing in the existing inline **Unlock layout** flow and - Settings visibility/order controls. -- Use the same monospace font family as **CONNECTION ACTIVITY** for Overview chart - titles, including compact chart cards and wide charts. -- No SQLite schema migration, new telemetry polling loop or proxy-path change is - introduced. - -## Checks completed - -- **PASS:** `python scripts/tests/dashboard_presets_0337_browser_test.py` against - the real bundled HTML/CSS/JS with a simulated preferences API at 320, 375, - 768 and 1440px. Covered save/update snapshot data, load, delete, responsive - modal layout, removal of the former duplicated editor controls, and chart-title - font parity with **CONNECTION ACTIVITY**. -- **PASS:** `python scripts/tests/dashboard_modal_0333_browser_test.py` after the - 0.33.7 preset conversion at 320, 375, 550, 620, 800, 900, 960, 1158, 1440 and - 1920px with no horizontal viewport overflow. -- **PASS:** updated `dashboard_layout_0331_browser_test.py` and - `dashboard_resizing_0333_browser_test.py`; inline editing still covers - add/hide/reorder/resize, live refresh, cancel/save and persisted custom widths, - while opening/closing the preset modal does not mutate the active layout. -- **PASS:** all 13 current `scripts/tests/*browser_test.py` simulated-browser - regressions, including chart heading/width/fixed CPU/stable scale coverage, - progress bars, sidebar, RAM, Web observability and Web Server live metrics. - The obsolete CPU autoscale regression was removed because 0.33.5 intentionally - replaced that behavior with a fixed 0-100% CPU scale and has its own current - regression test. -- **PASS:** `node scripts/tests/overview_samples_0330_ui_test.mjs`, - `node scripts/tests/dashboard_resume_rate_charts_ui_test.mjs` and - `node scripts/tests/dashboard_metric_visuals_ui_test.mjs`. -- **PASS:** JSON parsing for `src/admin/openapi.json`, `langs/en_US.json` and - `langs/pl_pl.json`; JavaScript syntax checks for the changed preferences/preset - files; Python syntax checks for changed browser tests; `python scripts/preflight.py`. -- **PASS:** `./dev.sh doctor` reports the source layout as OK and confirms that a - C compiler and Python are available. -- **PASS:** all eight `migrations/*.sql` applied successfully in two successive - cycles to a disposable in-memory SQLite database. This verifies SQL script - execution/idempotency in this context, not a production-data upgrade. -- **PASS:** parsed `scripts/deploy/upgrade.py --help` and - `scripts/migration/migrate_npm.py --help`; no live external upgrade/migration - was performed. - -## Not verified in this environment - -- `./dev.sh check` and `./dev.sh test` exit 127 because Cargo/rustc are not - installed. Therefore Rust compilation, Rust unit/integration tests, an actual - RustProxy process, real SSE traffic, production database upgrade and live load - testing are not verified here. -- The real-runtime acceptance gate remains open until Rust builds and runs against - representative test traffic.