From 4038334c283d9b5ac6ede9c80c4709d44499d959 Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Mon, 19 Oct 2026 02:40:26 +0100
Subject: [PATCH] [feat](maestro): resolve web app links from owner-controlled
sources
A service announces a web block only while its listener is enabled and serving, and the manifest own href is ignored. Resolve the URL server-side with the precedence manifest url > per-service operator override > global base-domain template > request host plus published port, validate every source at resolution time, and omit a block that has no usable target. The list endpoint resolves a fresh copy, so cached containers are never mutated and the console only ever opens a server-produced href.
---
src/handlers/containers/list.go | 49 ++++-
src/handlers/containers/list_test.go | 153 +++++++++++++++
src/handlers/containers/web_evidence_test.go | 178 ++++++++++++++++++
src/handlers/weblinks/weblinks.go | 108 +++++++++++
src/lib/containers/installed.go | 5 +
src/lib/types/service.go | 15 ++
src/lib/types/web.go | 178 ++++++++++++++++++
src/lib/types/web_test.go | 187 +++++++++++++++++++
src/lib/webcfg/webcfg.go | 98 ++++++++++
9 files changed, 967 insertions(+), 4 deletions(-)
create mode 100644 src/handlers/containers/list_test.go
create mode 100644 src/handlers/containers/web_evidence_test.go
create mode 100644 src/handlers/weblinks/weblinks.go
create mode 100644 src/lib/types/web.go
create mode 100644 src/lib/types/web_test.go
create mode 100644 src/lib/webcfg/webcfg.go
diff --git a/src/handlers/containers/list.go b/src/handlers/containers/list.go
index d007b95..6c2a502 100644
--- a/src/handlers/containers/list.go
+++ b/src/handlers/containers/list.go
@@ -21,22 +21,28 @@ import (
"net/http"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg"
)
-// NewListContainersHandler takes the cache instance from main and wraps it in a handler function
-func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc {
+// NewListContainersHandler takes the cache instance from main and wraps it in a
+// handler function. dataRoot resolves the operator web-link sources; the
+// request host drives the derived href fallback.
+func NewListContainersHandler(cache *containers.ContainerCache, dataRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cache.RLock()
defer cache.RUnlock()
w.Header().Set("Content-Type", "application/json")
- if cache.GetServices() == nil {
+ services := cache.GetServices()
+ if services == nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("No services available\n"))
+ return
}
- services := cache.GetServices()
+ services = resolveWebHrefs(services, dataRoot, r)
if err := json.NewEncoder(w).Encode(services); err != nil {
log.Printf("Error encoding services: %v", err)
http.Error(w, "Error marshaling service data", http.StatusInternalServerError)
@@ -44,3 +50,38 @@ func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc
}
}
}
+
+// resolveWebHrefs copies the service list and resolves each live `web` block's
+// href server-side (contract rev 8.2 §16.2, S40/S43). The cached value never
+// carries an href, and a block with no valid target is omitted entirely. The
+// returned slice is fresh: the cache's Container.Web pointers are never
+// mutated.
+func resolveWebHrefs(services []types.Container, dataRoot string, r *http.Request) []types.Container {
+ cfg := webcfg.Load(dataRoot)
+ out := make([]types.Container, len(services))
+ copy(out, services)
+ for i := range out {
+ c := &out[i]
+ if c.Web == nil || !c.Web.Enabled {
+ c.Web = nil
+ continue
+ }
+ name := c.ServiceName
+ if name == "" {
+ name = c.Name
+ }
+ w := *c.Web
+ href, ok := types.ResolveWebHref(&w, types.WebHrefSources{
+ ServiceName: name,
+ OperatorOverride: cfg.OverrideFor(name),
+ BaseDomainTemplate: cfg.Template(),
+ }, c.HostPortFor(w.Port), r)
+ if !ok {
+ c.Web = nil
+ continue
+ }
+ w.Href = href
+ c.Web = &w
+ }
+ return out
+}
diff --git a/src/handlers/containers/list_test.go b/src/handlers/containers/list_test.go
new file mode 100644
index 0000000..8223335
--- /dev/null
+++ b/src/handlers/containers/list_test.go
@@ -0,0 +1,153 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package containers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg"
+)
+
+func serveList(t *testing.T, cache *libcontainers.ContainerCache, dataRoot, host string) []types.Container {
+ t.Helper()
+ h := NewListContainersHandler(cache, dataRoot)
+ r := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
+ r.Host = host
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
+ }
+ var out []types.Container
+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ // No href may ever carry a non-http(s) scheme, whatever the manifest said.
+ for _, c := range out {
+ if c.Web != nil && !strings.HasPrefix(c.Web.Href, "http://") && !strings.HasPrefix(c.Web.Href, "https://") {
+ t.Fatalf("unsafe href for %s: %q", c.ServiceName, c.Web.Href)
+ }
+ }
+ return out
+}
+
+func byName(items []types.Container) map[string]types.Container {
+ m := make(map[string]types.Container, len(items))
+ for _, c := range items {
+ m[c.ServiceName] = c
+ }
+ return m
+}
+
+// TestListContainersResolvesWebHref covers the headline feature end to end at
+// the handler: a service with a valid web block gets a SERVER-resolved href, an
+// unsafe manifest url is skipped in favor of the derived host:port, and a
+// service with no block, a disabled block, or an invalid port omits `web`
+// entirely. The cache itself is never mutated.
+func TestListContainersResolvesWebHref(t *testing.T) {
+ root := t.TempDir()
+ cache := libcontainers.NewContainerCache()
+ cache.AddService(types.Container{
+ ID: "c1", Name: "calendar", ServiceName: "calendar", Status: "running",
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
+ Web: &types.WebApp{Enabled: true, Port: 8080},
+ })
+ cache.AddService(types.Container{ID: "c2", Name: "no-web", ServiceName: "no-web", Status: "running"})
+ cache.AddService(types.Container{
+ ID: "c3", Name: "bad", ServiceName: "bad", Status: "running",
+ Web: &types.WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"},
+ })
+ cache.AddService(types.Container{
+ ID: "c4", Name: "disabled", ServiceName: "disabled", Status: "running",
+ Web: &types.WebApp{Enabled: false, Port: 8080},
+ })
+ cache.AddService(types.Container{
+ ID: "c5", Name: "badport", ServiceName: "badport", Status: "running",
+ Web: &types.WebApp{Enabled: true, Port: 0},
+ })
+
+ got := byName(serveList(t, cache, root, "console.example.com:8080"))
+
+ if c := got["calendar"]; c.Web == nil || c.Web.Href != "http://console.example.com:8083" {
+ t.Fatalf("calendar web = %+v, want derived href http://console.example.com:8083", c.Web)
+ }
+ // An unsafe manifest url is skipped, never emitted: the derived host:port
+ // source takes over.
+ if c := got["bad"]; c.Web == nil || c.Web.Href != "http://console.example.com:8080" {
+ t.Fatalf("bad web = %+v, want the derived href (unsafe url skipped)", c.Web)
+ }
+ for _, name := range []string{"no-web", "disabled", "badport"} {
+ if c := got[name]; c.Web != nil {
+ t.Errorf("%s must omit `web` entirely, got %+v", name, c.Web)
+ }
+ }
+
+ // The cached entry must keep the unresolved block (href is per-request).
+ cached, ok := cache.GetService("calendar")
+ if !ok {
+ t.Fatal("calendar missing from cache")
+ }
+ if cached.Web == nil || cached.Web.Href != "" {
+ t.Fatalf("cache was mutated with a resolved href: %+v", cached.Web)
+ }
+}
+
+// TestListContainersWebSourcePrecedence covers the operator sources: override
+// beats the global template, the template beats the derived host:port, and the
+// manifest url beats both.
+func TestListContainersWebSourcePrecedence(t *testing.T) {
+ root := t.TempDir()
+ if err := webcfg.Save(root, webcfg.Config{
+ BaseDomainTemplate: "https://.home.example",
+ Overrides: map[string]string{"override-svc": "https://calendar.home.example"},
+ }); err != nil {
+ t.Fatalf("webcfg.Save: %v", err)
+ }
+
+ cache := libcontainers.NewContainerCache()
+ cache.AddService(types.Container{
+ ID: "1", Name: "override-svc", ServiceName: "override-svc", Status: "running",
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
+ Web: &types.WebApp{Enabled: true, Port: 8080},
+ })
+ cache.AddService(types.Container{
+ ID: "2", Name: "templated", ServiceName: "templated", Status: "running",
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8084, Guest: 8080}}},
+ Web: &types.WebApp{Enabled: true, Port: 8080},
+ })
+ cache.AddService(types.Container{
+ ID: "3", Name: "manifest", ServiceName: "manifest", Status: "running",
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8085, Guest: 8080}}},
+ Web: &types.WebApp{Enabled: true, Port: 8080, URL: "https://manifest.example"},
+ })
+
+ got := byName(serveList(t, cache, root, "console.example.com:8080"))
+ if c := got["override-svc"]; c.Web == nil || c.Web.Href != "https://calendar.home.example:8080" {
+ t.Fatalf("override href = %+v", c.Web)
+ }
+ if c := got["templated"]; c.Web == nil || c.Web.Href != "https://templated.home.example:8080" {
+ t.Fatalf("template href = %+v", c.Web)
+ }
+ if c := got["manifest"]; c.Web == nil || c.Web.Href != "https://manifest.example:8080" {
+ t.Fatalf("manifest href = %+v", c.Web)
+ }
+}
diff --git a/src/handlers/containers/web_evidence_test.go b/src/handlers/containers/web_evidence_test.go
new file mode 100644
index 0000000..b1e478b
--- /dev/null
+++ b/src/handlers/containers/web_evidence_test.go
@@ -0,0 +1,178 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package containers
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
+)
+
+// TestWebChipEvidenceTable is the raw-evidence generator for task-29's headline
+// feature. It logs, for each manifest `web` source the operator or a service
+// can supply, exactly what `GET /api/containers` returns: the resolved href, or
+// `` when no valid source exists. Run with:
+//
+// go test ./handlers/containers/ -run TestWebChipEvidenceTable -v
+func TestWebChipEvidenceTable(t *testing.T) {
+ root := t.TempDir()
+ cache := libcontainers.NewContainerCache()
+
+ type row struct {
+ name string
+ web *types.WebApp
+ }
+ rows := []row{
+ {"valid-derived", &types.WebApp{Enabled: true, Port: 8080}},
+ {"url-javascript", &types.WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"}},
+ {"url-data", &types.WebApp{Enabled: true, Port: 8080, URL: "data:text/html,"}},
+ {"url-file", &types.WebApp{Enabled: true, Port: 8080, URL: "file:///etc/passwd"}},
+ {"url-userinfo", &types.WebApp{Enabled: true, Port: 8080, URL: "https://user:pw@evil.example"}},
+ {"url-control-char", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/\x01"}},
+ {"url-newline", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/\n"}},
+ {"url-space", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/a b"}},
+ {"url-angle-bracket", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/"}},
+ {"url-empty-host", &types.WebApp{Enabled: true, Port: 8080, URL: "https:///path"}},
+ {"url-no-scheme", &types.WebApp{Enabled: true, Port: 8080, URL: "evil.example/x"}},
+ {"url-valid-https", &types.WebApp{Enabled: true, Port: 8080, URL: "https://calendar.example/app"}},
+ {"port-0", &types.WebApp{Enabled: true, Port: 0}},
+ {"port--1", &types.WebApp{Enabled: true, Port: -1}},
+ {"port-65536", &types.WebApp{Enabled: true, Port: 65536}},
+ {"listener-disabled", &types.WebApp{Enabled: false, Port: 8080}},
+ {"no-web-block", nil},
+ }
+ for i, r := range rows {
+ cache.AddService(types.Container{
+ ID: "ev" + r.name,
+ Name: r.name,
+ ServiceName: r.name,
+ Status: "running",
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
+ Web: r.web,
+ })
+ _ = i
+ }
+
+ got := byName(serveList(t, cache, root, "console.example.com:8080"))
+ for _, r := range rows {
+ c := got[r.name]
+ if c.Web == nil {
+ t.Logf("%-18s -> web=", r.name)
+ continue
+ }
+ raw, _ := json.Marshal(c.Web)
+ t.Logf("%-18s -> web=%s", r.name, raw)
+ }
+}
+
+// TestWebChipEvidenceNoFallback covers the acceptance case where an unsafe
+// TestWebChipEvidenceUnsafeURLNeverBecomesHref is the acceptance evidence for
+// the rejection cases. Contract §16.2 says an *invalid present source is
+// skipped* and the next source in precedence is used, so with an enabled block
+// and a valid declared port the derived `http://:`
+// source still yields a safe href — the unsafe manifest string is NEVER the
+// link. `web` is omitted entirely only when no source can produce a valid href
+// (no block, disabled, invalid port — see TestWebChipEvidenceTable).
+func TestWebChipEvidenceUnsafeURLNeverBecomesHref(t *testing.T) {
+ root := t.TempDir()
+ cache := libcontainers.NewContainerCache()
+
+ unsafe := map[string]string{
+ "javascript": "javascript:alert(1)",
+ "data": "data:text/html,",
+ "file": "file:///etc/passwd",
+ "userinfo": "https://user:pass@evil.example",
+ "control": "https://evil.example/\x01",
+ "newline": "https://evil.example/\n",
+ }
+ for name, url := range unsafe {
+ cache.AddService(types.Container{
+ ID: "nf" + name,
+ Name: name,
+ ServiceName: name,
+ Status: "running",
+ // No host port binding: the derived source falls back to the
+ // declared port, so the result is http://:8080.
+ Web: &types.WebApp{Enabled: true, Port: 8080, URL: url},
+ })
+ }
+
+ got := byName(serveList(t, cache, root, "console.example.com:8080"))
+ for name, url := range unsafe {
+ c := got[name]
+ if c.Web == nil {
+ t.Logf("%-10s -> web=", name)
+ continue
+ }
+ t.Logf("%-10s -> href=%q (unsafe url %q never used)", name, c.Web.Href, url)
+ if c.Web.Href == url || strings.Contains(c.Web.Href, "evil.example") || strings.Contains(c.Web.Href, "javascript:") ||
+ strings.Contains(c.Web.Href, "data:") || strings.Contains(c.Web.Href, "file:") || strings.Contains(c.Web.Href, "user:") {
+ t.Errorf("%s: unsafe url leaked into href: %q", name, c.Web.Href)
+ }
+ if !strings.HasPrefix(c.Web.Href, "http://") && !strings.HasPrefix(c.Web.Href, "https://") {
+ t.Errorf("%s: href is not http(s): %q", name, c.Web.Href)
+ }
+ }
+}
+
+// TestWebChipEvidenceInvalidPortOmitted is the "no valid href can be built"
+// acceptance case: even an otherwise valid web block with an invalid port has
+// no source at all, so `/api/containers` omits `web` entirely (no chip, no
+// link).
+func TestWebChipEvidenceInvalidPortOmitted(t *testing.T) {
+ root := t.TempDir()
+ cache := libcontainers.NewContainerCache()
+ for name, port := range map[string]int{"port0": 0, "portNeg": -1, "portBig": 65536} {
+ cache.AddService(types.Container{
+ ID: "ip" + name, Name: name, ServiceName: name, Status: "running",
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
+ Web: &types.WebApp{Enabled: true, Port: port, URL: "https://calendar.example"},
+ })
+ }
+ got := byName(serveList(t, cache, root, "console.example.com:8080"))
+ for name := range map[string]int{"port0": 0, "portNeg": -1, "portBig": 65536} {
+ if c := got[name]; c.Web != nil {
+ t.Errorf("%s must omit `web` entirely, got %+v", name, c.Web)
+ }
+ t.Logf("%-8s -> web=", name)
+ }
+}
+
+// TestManifestNonNumericPortFailsDecode is the evidence for the non-numeric
+// port case: a manifest whose `web.port` is not a number cannot decode, so the
+// service never advertises a web app at all (fail-closed at the boundary).
+func TestManifestNonNumericPortFailsDecode(t *testing.T) {
+ for _, body := range []string{
+ `{"name":"svc","web":{"enabled":true,"port":"abc"}}`,
+ `{"name":"svc","web":{"enabled":true,"port":"8080"}}`,
+ } {
+ var m types.Manifest
+ err := json.Unmarshal([]byte(body), &m)
+ t.Logf("%s -> err=%v", body, err)
+ if err == nil {
+ t.Fatalf("non-numeric web.port must fail manifest decode: %s", body)
+ }
+ }
+ // A numeric port decodes fine (control case).
+ var ok types.Manifest
+ if err := json.Unmarshal([]byte(`{"name":"svc","web":{"enabled":true,"port":8080}}`), &ok); err != nil {
+ t.Fatalf("numeric web.port must decode: %v", err)
+ }
+ t.Logf("numeric port control -> web=%+v", ok.Web)
+}
diff --git a/src/handlers/weblinks/weblinks.go b/src/handlers/weblinks/weblinks.go
new file mode 100644
index 0000000..9fa6c24
--- /dev/null
+++ b/src/handlers/weblinks/weblinks.go
@@ -0,0 +1,108 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package weblinks exposes the operator-controlled URL sources used by the Web
+// App capability (contract rev 8.2 §16.2): a global base-domain template and
+// per-service overrides. Owner-gated; the values are only ever base URLs, and
+// every source is re-validated at resolution time.
+package weblinks
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg"
+)
+
+// Handler serves GET/PUT /api/web-links.
+type Handler struct {
+ dataRoot string
+}
+
+// NewHandler builds the owner-gated web-links handler.
+func NewHandler(dataRoot string) *Handler {
+ return &Handler{dataRoot: dataRoot}
+}
+
+// Root handles both the read and the full-replacement write. PUT is a full
+// replacement: omitted fields clear, matching the catalog's PUT semantics.
+func (h *Handler) Root(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ h.get(w)
+ case http.MethodPut:
+ h.put(w, r)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
+ }
+}
+
+func (h *Handler) get(w http.ResponseWriter) {
+ cfg := webcfg.Load(h.dataRoot)
+ if cfg.Overrides == nil {
+ cfg.Overrides = map[string]string{}
+ }
+ writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *Handler) put(w http.ResponseWriter, r *http.Request) {
+ var body webcfg.Config
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
+ return
+ }
+ if body.BaseDomainTemplate != "" && !containsServicePlaceholder(body.BaseDomainTemplate) {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
+ return
+ }
+ if !types.ValidWebTemplate(body.BaseDomainTemplate) {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
+ return
+ }
+ for service, raw := range body.Overrides {
+ if service == "" || !types.ValidWebBase(raw) {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
+ return
+ }
+ }
+ if body.Overrides == nil {
+ body.Overrides = map[string]string{}
+ }
+ if err := webcfg.Save(h.dataRoot, body); err != nil {
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
+ return
+ }
+ writeJSON(w, http.StatusOK, body)
+}
+
+func containsServicePlaceholder(tpl string) bool {
+ for i := 0; i+9 <= len(tpl); i++ {
+ if tpl[i:i+9] == "" {
+ return true
+ }
+ }
+ return false
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeError(w http.ResponseWriter, status int, code string) {
+ writeJSON(w, status, map[string]any{"error": map[string]string{"code": code}})
+}
diff --git a/src/lib/containers/installed.go b/src/lib/containers/installed.go
index 0519f07..b119bbc 100644
--- a/src/lib/containers/installed.go
+++ b/src/lib/containers/installed.go
@@ -70,6 +70,7 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
HasAuth: metadata.HasAuth,
PublicKey: metadata.PublicKey,
Config: metadata.Config,
+ Web: metadata.Web,
Title: metadata.Title,
Vendor: metadata.Vendor,
Author: metadata.Author,
@@ -104,6 +105,10 @@ func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest)
if m.Config != nil {
metadata.Config = m.Config
}
+ // The manifest's `web` block is announced only while the listener is
+ // enabled and serving (honesty rule §16.1), so it replaces the persisted
+ // value — including clearing it when the service stops advertising one.
+ metadata.Web = m.Web
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err)
}
diff --git a/src/lib/types/service.go b/src/lib/types/service.go
index fcae4c5..7ceccc2 100644
--- a/src/lib/types/service.go
+++ b/src/lib/types/service.go
@@ -28,6 +28,21 @@ type Manifest struct {
// exactly one key; the key names the field and its value is the field spec.
// The manifest is a pure schema — it never carries a value.
Config []ConfigItem `json:"config,omitempty"`
+ // Web is the Web App capability (contract rev 8.2 §16.1). A service
+ // announces it only while its browser listener is enabled and serving
+ // (honesty rule). Maestro validates it and resolves Href server-side; the
+ // manifest's own "href" is ignored.
+ Web *WebApp `json:"web,omitempty"`
+}
+
+// WebApp is the Web App capability (contract rev 8.2). A service declares it in
+// its manifest; Maestro validates it and resolves Href server-side. The
+// manifest's own "href" is ignored — Href is response-only.
+type WebApp struct {
+ Enabled bool `json:"enabled"`
+ Port int `json:"port,omitempty"`
+ URL string `json:"url,omitempty"`
+ Href string `json:"href,omitempty"`
}
// ConfigItem is a single-key config declaration in a service manifest. The
diff --git a/src/lib/types/web.go b/src/lib/types/web.go
new file mode 100644
index 0000000..1eb24e1
--- /dev/null
+++ b/src/lib/types/web.go
@@ -0,0 +1,178 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package types
+
+import (
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// WebHrefSources are the Maestro-side, operator-controlled URL sources layered
+// above the (untrusted) manifest url. Precedence (S43), highest first:
+// manifest web.url -> OperatorOverride -> BaseDomainTemplate -> derived host:port.
+type WebHrefSources struct {
+ ServiceName string
+ OperatorOverride string
+ BaseDomainTemplate string // e.g. "https://."
+}
+
+// ResolveWebHref turns an untrusted manifest web block plus the operator URL
+// sources into a link target. ok=false means the caller must omit `web` from
+// the response entirely (rule 4). hostPort is the host-published port bound to
+// web.Port (0 when unpublished).
+func ResolveWebHref(web *WebApp, src WebHrefSources, hostPort int, r *http.Request) (string, bool) {
+ if web == nil || !web.Enabled {
+ return "", false
+ }
+ if web.Port < 1 || web.Port > 65535 {
+ return "", false
+ }
+ // Sources 1-3: explicit bases, highest precedence first. A present but
+ // invalid source is skipped (logged by the caller), never emitted. A raw
+ // source carrying a control character is rejected outright — trimming
+ // whitespace must not launder a smuggled newline into a chip target.
+ for _, raw := range []string{
+ web.URL,
+ src.OperatorOverride,
+ renderTemplate(src.BaseDomainTemplate, src.ServiceName),
+ } {
+ if raw == "" || hasControlChars(raw) {
+ continue
+ }
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ if href, ok := normalizeBase(raw, web.Port); ok {
+ return href, true
+ }
+ }
+ // Source 4: derive from the console request host + published port.
+ host := requestHostname(r)
+ if host == "" {
+ return "", false
+ }
+ if hostPort < 1 || hostPort > 65535 {
+ hostPort = web.Port
+ }
+ if hostPort < 1 || hostPort > 65535 {
+ return "", false
+ }
+ scheme := "http"
+ if r != nil && r.TLS != nil {
+ scheme = "https"
+ }
+ return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(hostPort)), true
+}
+
+// ValidWebBase reports whether raw is an acceptable operator-supplied base URL
+// (http/https, a host, no userinfo, no control chars). Used by the owner-gated
+// web-links API to reject an obviously bad override early; resolution still
+// re-validates every source, so this is a convenience, not the security gate.
+func ValidWebBase(raw string) bool {
+ if raw == "" {
+ return true
+ }
+ _, ok := normalizeBase(raw, 0)
+ return ok
+}
+
+// ValidWebTemplate is ValidWebBase for the global base-domain template: the
+// literal placeholder is expected there and substituted before
+// validation (at resolution time renderTemplate does the same).
+func ValidWebTemplate(raw string) bool {
+ return ValidWebBase(strings.ReplaceAll(raw, "", "service"))
+}
+
+// normalizeBase validates a candidate base URL (http/https, no userinfo, no
+// control chars or unresolved template markers) and applies the declared port
+// when the URL carries no explicit port and the port is not the scheme default.
+// ok=false means "invalid, skip to the next source".
+func normalizeBase(raw string, declared int) (string, bool) {
+ if strings.ContainsAny(raw, " \t\r\n<>") || hasControlChars(raw) {
+ return "", false
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.User != nil || u.Hostname() == "" {
+ return "", false
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return "", false
+ }
+ if u.Port() == "" && declared != schemeDefaultPort(u.Scheme) {
+ u.Host = net.JoinHostPort(u.Hostname(), strconv.Itoa(declared))
+ return u.String(), true
+ }
+ return raw, true
+}
+
+// hasControlChars reports whether s carries a C0 control character or DEL.
+// url.Parse tolerates some of them (e.g. a bare \x00 in a path), so the check
+// is explicit: a manifest url is never allowed to smuggle them into a chip.
+func hasControlChars(s string) bool {
+ for _, r := range s {
+ if r < 0x20 || r == 0x7f {
+ return true
+ }
+ }
+ return false
+}
+
+// renderTemplate substitutes in the global base-domain template.
+func renderTemplate(tpl, service string) string {
+ tpl = strings.TrimSpace(tpl)
+ if tpl == "" || service == "" {
+ return ""
+ }
+ return strings.ReplaceAll(tpl, "", service)
+}
+
+// HostPortFor returns the host-published port bound to the container port
+// guest, or 0 when the port is not published.
+func (c Container) HostPortFor(guest int) int {
+ for _, p := range c.Network.Ports {
+ if p.Guest == guest && p.Host > 0 {
+ return p.Host
+ }
+ }
+ return 0
+}
+
+func schemeDefaultPort(scheme string) int {
+ if scheme == "https" {
+ return 443
+ }
+ return 80
+}
+
+// requestHostname returns the hostname of the console request (port stripped),
+// or "" when it is unusable. Bracketed IPv6 literals are unwrapped.
+func requestHostname(r *http.Request) string {
+ if r == nil {
+ return ""
+ }
+ h := strings.TrimSpace(r.Host)
+ if h == "" || strings.ContainsAny(h, "/\\@?# \t\r\n") || hasControlChars(h) {
+ return ""
+ }
+ if host, _, err := net.SplitHostPort(h); err == nil {
+ return host
+ }
+ return strings.Trim(h, "[]")
+}
diff --git a/src/lib/types/web_test.go b/src/lib/types/web_test.go
new file mode 100644
index 0000000..f193b9f
--- /dev/null
+++ b/src/lib/types/web_test.go
@@ -0,0 +1,187 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package types
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func req(host string, isTLS bool) *http.Request {
+ r := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
+ r.Host = host
+ if isTLS {
+ r.TLS = &tls.ConnectionState{}
+ }
+ return r
+}
+
+func TestResolveWebHrefManifestWins(t *testing.T) {
+ web := &WebApp{Enabled: true, Port: 8080, URL: "https://app.example.com"}
+ href, ok := ResolveWebHref(web, WebHrefSources{
+ ServiceName: "keepnote",
+ OperatorOverride: "https://override.example.com",
+ BaseDomainTemplate: "https://.example.com",
+ }, 8081, req("192.168.1.5:8080", false))
+ if !ok {
+ t.Fatal("expected a resolved href")
+ }
+ // The declared port is applied because the URL carries no explicit port.
+ if want := "https://app.example.com:8080"; href != want {
+ t.Fatalf("href = %q, want %q", href, want)
+ }
+}
+
+func TestResolveWebHrefPrecedence(t *testing.T) {
+ base := WebHrefSources{
+ ServiceName: "keepnote",
+ OperatorOverride: "https://override.example.com",
+ BaseDomainTemplate: "https://.example.com",
+ }
+ r := req("192.168.1.5:8080", false)
+
+ // Invalid manifest url is skipped → operator override wins.
+ href, ok := ResolveWebHref(&WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"}, base, 0, r)
+ if !ok || href != "https://override.example.com:8080" {
+ t.Fatalf("override fallback = %q ok=%v", href, ok)
+ }
+
+ // Invalid override skipped → template renders with the service name.
+ src := base
+ src.OperatorOverride = "data:text/html,x"
+ href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 0, r)
+ if !ok || href != "https://keepnote.example.com:8080" {
+ t.Fatalf("template fallback = %q ok=%v", href, ok)
+ }
+
+ // All explicit sources invalid → derived from the request host + published port.
+ src = WebHrefSources{ServiceName: "keepnote", OperatorOverride: "file:///etc/passwd", BaseDomainTemplate: "not a url"}
+ href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 8081, r)
+ if !ok || href != "http://192.168.1.5:8081" {
+ t.Fatalf("derived fallback = %q ok=%v", href, ok)
+ }
+
+ // No published port → the declared port is used.
+ href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 0, r)
+ if !ok || href != "http://192.168.1.5:8080" {
+ t.Fatalf("derived declared-port fallback = %q ok=%v", href, ok)
+ }
+
+ // TLS request → https derived link.
+ href, ok = ResolveWebHref(&WebApp{Enabled: true, Port: 8080}, src, 0, req("console.example.com", true))
+ if !ok || href != "https://console.example.com:8080" {
+ t.Fatalf("derived tls fallback = %q ok=%v", href, ok)
+ }
+}
+
+func TestResolveWebHrefRejectsUnsafeSources(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ }{
+ {"javascript scheme", "javascript:alert(1)"},
+ {"data scheme", "data:text/html;base64,PHNjcmlwdD4="},
+ {"file scheme", "file:///etc/passwd"},
+ {"userinfo", "https://user:secret@example.com/"},
+ {"control char", "https://example.com/\x01"},
+ {"newline", "https://example.com/\n"},
+ {"space", "https://exa mple.com/"},
+ {"angle bracket", "https://example.com/