[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.
This commit is contained in:
2026-10-19 02:40:26 +01:00
parent 786b7a3b5f
commit 4038334c28
9 changed files with 967 additions and 4 deletions
+45 -4
View File
@@ -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
}
+153
View File
@@ -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 <https://www.gnu.org/licenses/>.
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://<service>.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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
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
// `<omitted>` 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,<script>alert(1)</script>"}},
{"url-file", &types.WebApp{Enabled: true, Port: 8080, URL: "file:///etc/passwd"}},
{"url-userinfo", &types.WebApp{Enabled: true, Port: 8080, URL: "https://user:[email protected]"}},
{"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/<x>"}},
{"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=<omitted>", 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://<request host>:<declared port>`
// 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,<script>alert(1)</script>",
"file": "file:///etc/passwd",
"userinfo": "https://user:[email protected]",
"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://<host>: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=<omitted>", 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=<omitted>", 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)
}
+108
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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] == "<service>" {
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}})
}
+5
View File
@@ -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)
}
+15
View File
@@ -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
+178
View File
@@ -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 <https://www.gnu.org/licenses/>.
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://<service>.<domain>"
}
// 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 <service> 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>", "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 <service> 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>", 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, "[]")
}
+187
View File
@@ -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 <https://www.gnu.org/licenses/>.
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://<service>.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://<service>.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,<b>x</b>"
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:[email protected]/"},
{"control char", "https://example.com/\x01"},
{"newline", "https://example.com/\n"},
{"space", "https://exa mple.com/"},
{"angle bracket", "https://example.com/<script>"},
{"empty host", "https:///path"},
{"no scheme", "//example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Only the manifest source is present, so a rejected value must
// yield no href at all (rule 4: omit `web`).
web := &WebApp{Enabled: true, Port: 8080, URL: tc.raw}
if href, ok := ResolveWebHref(web, WebHrefSources{ServiceName: "keepnote"}, 0, nil); ok {
t.Fatalf("unsafe url %q resolved to %q, want rejection", tc.raw, href)
}
})
}
}
func TestResolveWebHrefRejectsBadPorts(t *testing.T) {
for _, port := range []int{0, -1, 65536, 100000} {
web := &WebApp{Enabled: true, Port: port, URL: "https://app.example.com"}
if href, ok := ResolveWebHref(web, WebHrefSources{ServiceName: "keepnote"}, 0, req("192.168.1.5:8080", false)); ok {
t.Fatalf("port %d resolved to %q, want rejection", port, href)
}
}
}
func TestResolveWebHrefDisabledOrAbsent(t *testing.T) {
r := req("192.168.1.5:8080", false)
if _, ok := ResolveWebHref(nil, WebHrefSources{}, 0, r); ok {
t.Fatal("nil web block must not resolve")
}
if _, ok := ResolveWebHref(&WebApp{Enabled: false, Port: 8080, URL: "https://app.example.com"}, WebHrefSources{}, 0, r); ok {
t.Fatal("disabled web block must not resolve")
}
}
// TestWebAppNonNumericPortFailsManifestDecode documents the wire behavior for a
// non-numeric port: the manifest decode fails, so the service is treated as
// having no manifest at all (and therefore no web block) rather than silently
// accepting a coerced value.
func TestWebAppNonNumericPortFailsManifestDecode(t *testing.T) {
var m Manifest
if err := json.Unmarshal([]byte(`{"web":{"enabled":true,"port":"8080"}}`), &m); err == nil {
t.Fatal("non-numeric port must fail the manifest decode")
}
}
func TestHostPortFor(t *testing.T) {
c := Container{Network: Network{Ports: []PortMapping{{Host: 8081, Guest: 8080}, {Host: 0, Guest: 9090}}}}
if got := c.HostPortFor(8080); got != 8081 {
t.Fatalf("HostPortFor(8080) = %d, want 8081", got)
}
if got := c.HostPortFor(9090); got != 0 {
t.Fatalf("HostPortFor(9090) = %d, want 0 (unpublished)", got)
}
if got := c.HostPortFor(1234); got != 0 {
t.Fatalf("HostPortFor(1234) = %d, want 0 (unknown)", got)
}
}
func TestValidWebBase(t *testing.T) {
valid := []string{"", "http://192.168.1.5:8081", "https://example.com"}
for _, raw := range valid {
if !ValidWebBase(raw) {
t.Errorf("ValidWebBase(%q) = false, want true", raw)
}
}
invalid := []string{"javascript:alert(1)", "file:///etc/passwd", "https://u:p@h/", "https://h/\x7f"}
for _, raw := range invalid {
if ValidWebBase(raw) {
t.Errorf("ValidWebBase(%q) = true, want false", raw)
}
}
// The global template is allowed to carry the <service> placeholder.
if !ValidWebTemplate("https://<service>.example.com") {
t.Error("ValidWebTemplate rejected the canonical base-domain template")
}
if ValidWebTemplate("javascript:<service>") {
t.Error("ValidWebTemplate accepted a non-http template")
}
if ValidWebBase("https://<service>.example.com") {
t.Error("ValidWebBase must not accept the unresolved placeholder (it is template-only)")
}
}
+98
View File
@@ -0,0 +1,98 @@
// 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 <https://www.gnu.org/licenses/>.
// Package webcfg stores the operator-controlled URL sources for the Web App
// capability (contract rev 8.2 §16.2 / S43). They sit below the manifest's own
// `web.url` in precedence but above the derived request-host fallback:
//
// manifest web.url → per-service operator override → global base-domain
// template → derived http://<request host>:<published port>
//
// The file lives at <dataRoot>/maestro/web-links.json (chmod 600). The global
// template can also come from DCNTRLIZED_WEB_BASE_DOMAIN, which wins over the
// file so an instance can be configured from the environment. Every source is
// still validated at resolution time, so a typo or hostile value is skipped.
package webcfg
import (
"encoding/json"
"os"
"path/filepath"
)
// Config is the persisted operator URL-source configuration.
type Config struct {
// BaseDomainTemplate is the global template, e.g. "https://<service>.<domain>".
BaseDomainTemplate string `json:"base_domain_template,omitempty"`
// Overrides maps a service name to its per-service base URL.
Overrides map[string]string `json:"overrides,omitempty"`
}
// Path returns the config file location for a data root.
func Path(dataRoot string) string {
return filepath.Join(dataRoot, "maestro", "web-links.json")
}
// Load reads the config; a missing or malformed file yields a zero Config
// (resolution then falls through to the derived source).
func Load(dataRoot string) Config {
var cfg Config
data, err := os.ReadFile(Path(dataRoot))
if err != nil {
return cfg
}
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}
}
return cfg
}
// Save writes the config atomically with chmod 600.
func Save(dataRoot string, cfg Config) error {
dir := filepath.Dir(Path(dataRoot))
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
path := Path(dataRoot)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
return err
}
if err := os.Chmod(tmp, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Template returns the effective global base-domain template: the environment
// variable wins over the persisted file.
func (c Config) Template() string {
if v := os.Getenv("DCNTRLIZED_WEB_BASE_DOMAIN"); v != "" {
return v
}
return c.BaseDomainTemplate
}
// OverrideFor returns the per-service operator override, or "".
func (c Config) OverrideFor(service string) string {
if c.Overrides == nil {
return ""
}
return c.Overrides[service]
}