[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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "[]")
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user