From a4bb7ad5597ae38b07d54fb971dfe9c7c87bb0ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?=
<150935816+Dvorinka@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:47:25 +0200
Subject: [PATCH] feat(services): traefik routing overrides via allowlisted
middleware labels (#64)
services.traefik_labels (jsonb) holds operator-defined Traefik
middlewares keyed 'middlewares..[.]', validated
against an allowlist at write time. edgeLabels (extracted from
createReplica) scopes each definition to the service router as
- and auto-attaches it to the router's middleware chain
after builtins. Router/service/provider-level keys are rejected.
Surfaces: create/update API fields, services create/update
--traefik-label (repeatable, key=value), MCP arg docs, OpenAPI +
regenerated types, service-page Access editor (Routing overrides
card + textarea).
Also marks three stale ROADMAP2 items shipped: notification producers
round-out, build-on-node (PR #60), mobile env/domains/scaling (PR #61).
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
ROADMAP2.md | 34 ++---
app/backend/internal/api/runtime.go | 1 +
app/backend/internal/api/services.go | 116 ++++++++++++++++-
.../internal/api/services_routing_test.go | 64 ++++++++++
app/backend/internal/cli/commands/services.go | 34 +++++
.../internal/deployment/edge_labels_test.go | 60 +++++++++
app/backend/internal/deployment/runtime.go | 118 ++++++++++++------
app/backend/internal/mcpserver/server.go | 4 +-
.../20261113000000_service_traefik_labels.sql | 9 ++
app/backend/sqlc/schema.sql | 3 +-
.../service/pages/ServiceDetailPage.tsx | 39 +++++-
app/frontend/src/generated/api-types.ts | 12 ++
app/frontend/src/lib/api-client.ts | 2 +
docs/api/openapi.yaml | 12 ++
14 files changed, 445 insertions(+), 63 deletions(-)
create mode 100644 app/backend/internal/api/services_routing_test.go
create mode 100644 app/backend/internal/deployment/edge_labels_test.go
create mode 100644 app/backend/migrations_goose/20261113000000_service_traefik_labels.sql
diff --git a/ROADMAP2.md b/ROADMAP2.md
index 4795db3..8250069 100644
--- a/ROADMAP2.md
+++ b/ROADMAP2.md
@@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
compares vs edge IP → `ok|wrong-target|pending`.
- [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels;
no proxy code needed.
-- [ ] **Routing overrides** — `services.traefik_labels` jsonb with
- allowlist validation (middlewares, headers, redirects).
+- [x] **Routing overrides** — `services.traefik_labels` jsonb with
+ allowlist validation. Keys `middlewares..[.]` scope
+ to the service router (`svc--`), auto-attach after builtins;
+ router/service/provider-level keys rejected. API + CLI
+ (`--traefik-label`) + MCP + service page Access editor.
- [x] **Builder override** — `services.builder` enum
`auto|railpack|nixpacks|dockerfile|static`; static builder = build +
nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug.
@@ -341,14 +344,13 @@ and wired refs, one click or one CLI call.
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
button (online-gated). Ceilings: linux amd64/arm64 only; agents on
other platforms get a `NO_BINARY` 409.
-- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch
- payload carries the project owner's `registries` credentials for the
- image host; the agent `docker login`s for the pull and logs out after,
- and command payloads are scrubbed of passwords on completion. Agent
- also verifies the container is still running ~1.5s after `run` so
- crash-loops fail the deploy instead of reporting healthy. Remaining:
- real builds on the node (source checkout + build daemon) — pulls
- already work.
+- [x] **Build-on-node** — private registry pulls + real builds shipped.
+ Git sources materialize via `internal/source` checkout (branch/commit,
+ `GIT_ASKPASS` credential injection, dumb-transport fallback), package
+ through `BuildManager.PackageContext`, ship as agent-token artifacts,
+ and `docker build` on the node (`build_image` command). Remote rollbacks
+ `docker save` → artifact → `load_image` on the node. Verified end-to-end
+ on a live agent (PR #60).
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
@@ -426,8 +428,11 @@ and wired refs, one click or one CLI call.
filters. Applied: audit logs (server-backed), builds (server + client
date-range), activity (severity server + client category/search).
Notifications table has no list surface yet — apply when one lands.
-- [ ] **Notification producers round-out** — backups, agent offline,
- upgrade available, security findings.
+- [x] **Notification producers round-out** — all shipped: deployments
+ (deployments.go), backups (databases.go `createBackupProcess` → owner),
+ agent offline/online sweep (`reconcileAgentHealth` → admins),
+ upgrade-available (`checkForUpgrade`, tag-persisted) and security-scan
+ findings (`scanner.OnScanComplete` → project owner).
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
polls container NetworkIO deltas; idle past threshold → workload
@@ -492,8 +497,9 @@ and wired refs, one click or one CLI call.
a prior deployment, database start/stop/restart/backup-now.
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
deployment history, env-doctor issues surfaced on the service view.
- - [ ] **env var edit + apply-redeploy, domain management, replica
- scaling, cron execution history** — next iteration.
+ - [x] **env var edit + domain management + replica scaling** (PR #61 —
+ mobile v1.5: variables editor, domains screen, scale + sleep
+ settings, notifications tab). **cron execution history** still open.
- [ ] **Push notifications** — zero-infra path = self-hosted
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
- [ ] **Offline resilience** — cached last-known state + unreachable
diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go
index 4824a48..9652dfe 100644
--- a/app/backend/internal/api/runtime.go
+++ b/app/backend/internal/api/runtime.go
@@ -121,6 +121,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
+ spec.TraefikLabels = serviceTraefikLabels(db, service.ID)
if spec.Maintenance {
spec.MaintenanceURL = maintenanceURL(db)
}
diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go
index c3fdf10..773bc6c 100644
--- a/app/backend/internal/api/services.go
+++ b/app/backend/internal/api/services.go
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "regexp"
"strings"
"time"
@@ -58,9 +59,12 @@ type Service struct {
Spread bool `json:"spread" db:"-"`
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. An explicit pin overrides them — pin + tags is rejected.
- PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
- CreatedAt time.Time `json:"created_at" db:"created_at"`
- UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+ PlacementTags []string `json:"placement_tags,omitempty" db:"-"`
+ // TraefikLabels holds per-service middleware overrides —
+ // "middlewares..." → value. Lazy-read.
+ TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
+ UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// loadServiceSleep fills the sleep-mode columns, which live outside the
@@ -123,6 +127,78 @@ func normalizePlacementTags(tags []string) []string {
return out
}
+// traefikLabelKey matches "middlewares.." with an optional
+// "." tail — e.g. "middlewares.rl.ratelimit.average".
+var traefikLabelKey = regexp.MustCompile(`^middlewares\.([A-Za-z0-9][A-Za-z0-9_-]{0,39})\.([A-Za-z]+)(\.[A-Za-z0-9_.*+-]+)?$`)
+
+// traefikMiddlewareTypes are the Traefik middleware kinds a service may
+// define. Router/service/provider-level keys are never accepted — those
+// would let a tenant hijack someone else's routing.
+var traefikMiddlewareTypes = map[string]bool{
+ "addprefix": true, "basicauth": true, "buffering": true, "chain": true,
+ "circuitbreaker": true, "compress": true, "digestauth": true,
+ "errors": true, "forwardauth": true, "grpcweb": true, "headers": true,
+ "inflightreq": true, "ipallowlist": true, "ipwhitelist": true,
+ "passtlsclientcert": true, "ratelimit": true, "redirectregex": true,
+ "redirectscheme": true, "replacepath": true, "replacepathregex": true,
+ "retry": true, "stripprefix": true, "stripprefixregex": true,
+}
+
+// normalizeTraefikLabels validates a raw override map. Returns the cleaned
+// map (middleware type lowercased — Traefik label keys are canonical-lower)
+// or an error naming the offending key.
+func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
+ if len(raw) > 32 {
+ return nil, fmt.Errorf("traefik_labels: at most 32 entries (got %d)", len(raw))
+ }
+ out := make(map[string]string, len(raw))
+ names := map[string]bool{}
+ for k, v := range raw {
+ m := traefikLabelKey.FindStringSubmatch(k)
+ if m == nil {
+ return nil, fmt.Errorf("traefik_labels: invalid key %q — want middlewares..[.]", k)
+ }
+ mwType := strings.ToLower(m[2])
+ if !traefikMiddlewareTypes[mwType] {
+ return nil, fmt.Errorf("traefik_labels: unsupported middleware type %q", m[2])
+ }
+ if len(v) == 0 {
+ return nil, fmt.Errorf("traefik_labels: %q has an empty value", k)
+ }
+ if len(v) > 512 || strings.ContainsAny(v, "\r\n") {
+ return nil, fmt.Errorf("traefik_labels: %q value must be a single line under 512 chars", k)
+ }
+ out["middlewares."+m[1]+"."+mwType+m[3]] = v
+ names[m[1]] = true
+ }
+ if len(names) > 16 {
+ return nil, fmt.Errorf("traefik_labels: at most 16 middleware names")
+ }
+ return out, nil
+}
+
+// serviceTraefikLabels lazy-reads the traefik_labels column.
+func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
+ var raw []byte
+ if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
+ return nil
+ }
+ var labels map[string]string
+ if err := json.Unmarshal(raw, &labels); err != nil || len(labels) == 0 {
+ return nil
+ }
+ return labels
+}
+
+// traefikLabelsJSON marshals an override map for the jsonb column.
+func traefikLabelsJSON(labels map[string]string) []byte {
+ if len(labels) == 0 {
+ return []byte("{}")
+ }
+ raw, _ := json.Marshal(labels)
+ return raw
+}
+
// tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null.
func tagsJSON(tags []string) []byte {
if len(tags) == 0 {
@@ -139,6 +215,7 @@ func loadServiceNode(db *database.DB, s *Service) {
s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID)
+ s.TraefikLabels = serviceTraefikLabels(db, s.ID)
if s.NodeID == "" {
return
}
@@ -237,6 +314,10 @@ type CreateServiceRequest struct {
// PlacementTags restrict auto/spread candidates to agents carrying all
// listed tags. Mutually exclusive with an explicit node pin.
PlacementTags []string `json:"placement_tags"`
+ // TraefikLabels defines extra Traefik middlewares for this service's
+ // router — keys "middlewares..[.]", validated against
+ // an allowlist. Defined middlewares attach to the router automatically.
+ TraefikLabels map[string]string `json:"traefik_labels"`
}
// UpdateServiceRequest represents a request to update a service
@@ -275,6 +356,9 @@ type UpdateServiceRequest struct {
// listed tags; pointer so [] clears the requirement. Mutually exclusive
// with an explicit node pin.
PlacementTags *[]string `json:"placement_tags"`
+ // TraefikLabels replaces the service's middleware overrides; pointer so
+ // {} clears them and nil leaves them untouched.
+ TraefikLabels *map[string]string `json:"traefik_labels"`
}
// handleGetServices retrieves all services for a project
@@ -478,6 +562,14 @@ func handleCreateService(c *gin.Context) {
if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist"
}
+ if len(req.TraefikLabels) > 0 {
+ labels, err := normalizeTraefikLabels(req.TraefikLabels)
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
+ return
+ }
+ service.TraefikLabels = labels
+ }
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -557,9 +649,9 @@ func handleCreateService(c *gin.Context) {
healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes,
- created_at, updated_at)
+ traefik_labels, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
- $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)`,
+ $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command,
@@ -572,6 +664,7 @@ func handleCreateService(c *gin.Context) {
tagsJSON(service.PlacementTags),
service.SleepEnabled,
sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0},
+ traefikLabelsJSON(service.TraefikLabels),
service.CreatedAt, service.UpdatedAt,
)
@@ -848,6 +941,16 @@ func handleUpdateService(c *gin.Context) {
}
existingService.SleepIdleMinutes = *req.SleepIdleMinutes
}
+ if req.TraefikLabels != nil {
+ labels, err := normalizeTraefikLabels(*req.TraefikLabels)
+ if err != nil {
+ respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
+ return
+ }
+ existingService.TraefikLabels = labels
+ } else {
+ existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID)
+ }
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
@@ -924,7 +1027,7 @@ func handleUpdateService(c *gin.Context) {
builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23,
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27,
- placement_tags = $28::jsonb
+ placement_tags = $28::jsonb, traefik_labels = $29::jsonb
WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -938,6 +1041,7 @@ func handleUpdateService(c *gin.Context) {
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
existingService.Spread,
tagsJSON(existingService.PlacementTags),
+ traefikLabelsJSON(existingService.TraefikLabels),
)
if err != nil {
diff --git a/app/backend/internal/api/services_routing_test.go b/app/backend/internal/api/services_routing_test.go
new file mode 100644
index 0000000..99dc1a5
--- /dev/null
+++ b/app/backend/internal/api/services_routing_test.go
@@ -0,0 +1,64 @@
+package api
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNormalizeTraefikLabels(t *testing.T) {
+ valid := map[string]string{
+ "middlewares.rl.ratelimit.average": "100",
+ "middlewares.rl.ratelimit.period": "1m",
+ "middlewares.sec.headers.customrequestheaders.X-Frame-Options": "DENY",
+ "middlewares.gzip.compress": "true",
+ }
+ out, err := normalizeTraefikLabels(valid)
+ if err != nil {
+ t.Fatalf("valid labels rejected: %v", err)
+ }
+ if len(out) != len(valid) {
+ t.Fatalf("expected %d labels, got %d", len(valid), len(out))
+ }
+ if out["middlewares.gzip.compress"] != "true" {
+ t.Fatalf("flag-style middleware missing: %v", out)
+ }
+}
+
+func TestNormalizeTraefikLabelsLowercasesType(t *testing.T) {
+ out, err := normalizeTraefikLabels(map[string]string{
+ "middlewares.rl.RateLimit.average": "10",
+ })
+ if err != nil {
+ t.Fatalf("mixed-case type rejected: %v", err)
+ }
+ if out["middlewares.rl.ratelimit.average"] != "10" {
+ t.Fatalf("type not lowercased: %v", out)
+ }
+}
+
+func TestNormalizeTraefikLabelsRejects(t *testing.T) {
+ cases := map[string]string{
+ "traefik.enable": "true", // provider-level
+ "middlewares..ratelimit.average": "1", // empty name
+ "middlewares.x.evilinject.thing": "1", // unknown type
+ "routers.svc.rule": "x", // router hijack
+ "middlewares.x.ratelimit.average.bad\ninjection": "1", // control char in key fails regex
+ }
+ for k, v := range cases {
+ if _, err := normalizeTraefikLabels(map[string]string{k: v}); err == nil {
+ t.Fatalf("key %q should have been rejected", k)
+ }
+ }
+}
+
+func TestNormalizeTraefikLabelsRejectsBadValues(t *testing.T) {
+ if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": ""}); err == nil {
+ t.Fatal("empty value accepted")
+ }
+ if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": strings.Repeat("x", 600)}); err == nil {
+ t.Fatal("oversized value accepted")
+ }
+ if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": "1\nmalicious"}); err == nil {
+ t.Fatal("newline value accepted")
+ }
+}
diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go
index b0c0f7c..fa8c10e 100644
--- a/app/backend/internal/cli/commands/services.go
+++ b/app/backend/internal/cli/commands/services.go
@@ -134,6 +134,11 @@ var servicesCreateCmd = &cobra.Command{
}
body["placement_tags"] = tags
}
+ if labels, err := traefikLabelFlag(cmd); err != nil {
+ return err
+ } else if labels != nil {
+ body["traefik_labels"] = labels
+ }
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil {
return err
@@ -148,6 +153,28 @@ var servicesCreateCmd = &cobra.Command{
},
}
+// traefikLabelFlag parses --traefik-label key=value pairs into the API's
+// map shape. Returns nil when the flag was never passed; an empty map when
+// only empty values were given (clears overrides on update).
+func traefikLabelFlag(cmd *cobra.Command) (map[string]string, error) {
+ if !cmd.Flags().Changed("traefik-label") {
+ return nil, nil
+ }
+ pairs, _ := cmd.Flags().GetStringArray("traefik-label")
+ labels := map[string]string{}
+ for _, p := range pairs {
+ if strings.TrimSpace(p) == "" {
+ continue
+ }
+ k, v, ok := strings.Cut(p, "=")
+ if !ok || strings.TrimSpace(k) == "" {
+ return nil, &APIError{Message: fmt.Sprintf("invalid --traefik-label %q — want key=value", p), ExitCode: ExitError}
+ }
+ labels[strings.TrimSpace(k)] = v
+ }
+ return labels, nil
+}
+
var servicesDeleteCmd = &cobra.Command{
Use: "delete ",
Short: "Delete a service",
@@ -241,6 +268,11 @@ var servicesUpdateCmd = &cobra.Command{
v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v
}
+ if labels, err := traefikLabelFlag(cmd); err != nil {
+ return err
+ } else if labels != nil {
+ body["traefik_labels"] = labels
+ }
if cmd.Flags().Changed("maintenance") {
v, _ := cmd.Flags().GetString("maintenance")
body["maintenance_mode"] = v == "on" || v == "true"
@@ -533,6 +565,7 @@ func init() {
f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)")
+ f.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable")
uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name")
@@ -557,6 +590,7 @@ func init() {
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)")
+ uf.StringArray("traefik-label", nil, "Traefik middleware override, key=value; repeatable; empty list clears via --traefik-label ''")
uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear")
uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)")
uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)")
diff --git a/app/backend/internal/deployment/edge_labels_test.go b/app/backend/internal/deployment/edge_labels_test.go
new file mode 100644
index 0000000..cc4916c
--- /dev/null
+++ b/app/backend/internal/deployment/edge_labels_test.go
@@ -0,0 +1,60 @@
+package deployment
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestEdgeLabelsCustomMiddlewares(t *testing.T) {
+ spec := RuntimeSpec{
+ ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
+ Port: 8080,
+ Domains: []string{"app.example.com"},
+ TraefikLabels: map[string]string{
+ "middlewares.rl.ratelimit.average": "100",
+ "middlewares.rl.ratelimit.period": "1m",
+ "middlewares.sec.headers.customresponseheaders.X-Frame-Options": "DENY",
+ },
+ }
+ labels := edgeLabels(spec, "containr-edge")
+
+ if got := labels["traefik.http.middlewares.svc-01234567-rl.ratelimit.average"]; got != "100" {
+ t.Fatalf("ratelimit label missing: %v", labels)
+ }
+ if got := labels["traefik.http.middlewares.svc-01234567-sec.headers.customresponseheaders.X-Frame-Options"]; got != "DENY" {
+ t.Fatalf("headers label missing: %v", labels)
+ }
+ chain := labels["traefik.http.routers.svc-01234567.middlewares"]
+ if chain != "svc-01234567-rl,svc-01234567-sec" {
+ t.Fatalf("middleware chain wrong: %q", chain)
+ }
+}
+
+func TestEdgeLabelsCustomAfterBuiltins(t *testing.T) {
+ spec := RuntimeSpec{
+ ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
+ Port: 8080,
+ Domains: []string{"app.example.com"},
+ BasicAuthUsers: "u:$apr1$x",
+ TraefikLabels: map[string]string{
+ "middlewares.rl.ratelimit.average": "10",
+ },
+ }
+ labels := edgeLabels(spec, "containr-edge")
+ chain := labels["traefik.http.routers.svc-01234567.middlewares"]
+ if !strings.HasPrefix(chain, "svc-01234567-auth,") || !strings.HasSuffix(chain, "svc-01234567-rl") {
+ t.Fatalf("builtin should precede custom: %q", chain)
+ }
+}
+
+func TestEdgeLabelsNoCustom(t *testing.T) {
+ spec := RuntimeSpec{
+ ServiceID: "01234567-89ab-cdef-0123-456789abcdef",
+ Port: 8080,
+ Domains: []string{"app.example.com"},
+ }
+ labels := edgeLabels(spec, "containr-edge")
+ if _, ok := labels["traefik.http.routers.svc-01234567.middlewares"]; ok {
+ t.Fatal("middleware chain should be absent with no middlewares")
+ }
+}
diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go
index d9e6f92..98caf24 100644
--- a/app/backend/internal/deployment/runtime.go
+++ b/app/backend/internal/deployment/runtime.go
@@ -55,6 +55,10 @@ type RuntimeSpec struct {
Maintenance bool // redirect traffic to MaintenanceURL
MaintenanceURL string // absolute URL; empty = serve empty response
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
+ // TraefikLabels defines extra middlewares for this service's router —
+ // keys "middlewares..[.]" (API-validated against an
+ // allowlist). Defined middlewares attach to the router after builtins.
+ TraefikLabels map[string]string
// NodeID pins the service to a node agent; "" runs on the local host.
NodeID string
// Spread distributes replicas across all online, schedulable agents
@@ -274,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
projectNet: {Aliases: []string{spec.Name}},
}
if edgeNet != "" {
- router := "svc-" + spec.ServiceID[:8]
- domains := spec.Domains
- if len(domains) == 0 && spec.Domain != "" {
- domains = []string{spec.Domain}
- }
- sort.Strings(domains)
- rules := make([]string, 0, len(domains))
- for _, d := range domains {
- rules = append(rules, "Host(`"+d+"`)")
- }
endpoints[edgeNet] = &network.EndpointSettings{}
- labels["traefik.enable"] = "true"
- labels["traefik.docker.network"] = edgeNet
- labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ")
- labels["traefik.http.routers."+router+".entrypoints"] = "web"
- labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
-
- var middlewares []string
- if spec.Maintenance {
- if spec.MaintenanceURL != "" {
- mw := router + "-maint"
- labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
- labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
- labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
- middlewares = append(middlewares, mw)
- } else {
- // No redirect target configured — take the site offline with
- // empty responses instead of leaking traffic to the app.
- labels["traefik.http.routers."+router+".service"] = "noop@internal"
- }
- }
- if spec.BasicAuthUsers != "" {
- mw := router + "-auth"
- labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
- middlewares = append(middlewares, mw)
- }
- if len(middlewares) > 0 {
- labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
+ for k, v := range edgeLabels(spec, edgeNet) {
+ labels[k] = v
}
}
@@ -399,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
// vpnkit drops a conflicting binding).
var errPortNotBound = errors.New("requested host port not bound")
+// edgeLabels builds the Traefik router/service/middleware labels for a
+// replica on the edge network: host rules, load balancer port, builtin
+// maintenance/basic-auth middlewares, then operator-defined overrides.
+func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string {
+ router := "svc-" + spec.ServiceID[:8]
+ domains := spec.Domains
+ if len(domains) == 0 && spec.Domain != "" {
+ domains = []string{spec.Domain}
+ }
+ sort.Strings(domains)
+ rules := make([]string, 0, len(domains))
+ for _, d := range domains {
+ rules = append(rules, "Host(`"+d+"`)")
+ }
+
+ labels := map[string]string{
+ "traefik.enable": "true",
+ "traefik.docker.network": edgeNet,
+ "traefik.http.routers." + router + ".rule": strings.Join(rules, " || "),
+ "traefik.http.routers." + router + ".entrypoints": "web",
+ "traefik.http.services." + router + ".loadbalancer.server.port": fmt.Sprintf("%d", spec.Port),
+ }
+
+ var middlewares []string
+ if spec.Maintenance {
+ if spec.MaintenanceURL != "" {
+ mw := router + "-maint"
+ labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*"
+ labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL
+ labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false"
+ middlewares = append(middlewares, mw)
+ } else {
+ // No redirect target configured — take the site offline with
+ // empty responses instead of leaking traffic to the app.
+ labels["traefik.http.routers."+router+".service"] = "noop@internal"
+ }
+ }
+ if spec.BasicAuthUsers != "" {
+ mw := router + "-auth"
+ labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers
+ middlewares = append(middlewares, mw)
+ }
+ // Operator-defined middlewares: "middlewares.." becomes
+ // "-.", and every defined name joins the router's
+ // chain after builtins. Names sort for a deterministic chain order.
+ customMWs := map[string][]string{}
+ for k, v := range spec.TraefikLabels {
+ rest := strings.TrimPrefix(k, "middlewares.")
+ dot := strings.Index(rest, ".")
+ if dot <= 0 {
+ continue
+ }
+ customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v)
+ }
+ mwNames := make([]string, 0, len(customMWs))
+ for name := range customMWs {
+ mwNames = append(mwNames, name)
+ }
+ sort.Strings(mwNames)
+ for _, name := range mwNames {
+ mw := router + "-" + name
+ sort.Strings(customMWs[name])
+ for _, kv := range customMWs[name] {
+ if i := strings.Index(kv, "="); i > 0 {
+ labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:]
+ }
+ }
+ middlewares = append(middlewares, mw)
+ }
+ if len(middlewares) > 0 {
+ labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",")
+ }
+ return labels
+}
+
// isPortConflict reports whether a container create failed because the
// requested host port was already bound.
func isPortConflict(err error) bool {
diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go
index b37f2c8..f19d379 100644
--- a/app/backend/internal/mcpserver/server.go
+++ b/app/backend/internal/mcpserver/server.go
@@ -53,7 +53,7 @@ func init() {
// Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
- register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
+ register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares..[.]', allowlist-validated; defined middlewares attach to the router automatically).", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
@@ -66,7 +66,7 @@ func init() {
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
- register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first.", "PUT", "/services/{id}", []string{"id"}, nil, false})
+ register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares..[.]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false})
// Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
diff --git a/app/backend/migrations_goose/20261113000000_service_traefik_labels.sql b/app/backend/migrations_goose/20261113000000_service_traefik_labels.sql
new file mode 100644
index 0000000..e5ac943
--- /dev/null
+++ b/app/backend/migrations_goose/20261113000000_service_traefik_labels.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- Routing overrides: per-service Traefik middleware definitions stored as
+-- { "middlewares...": "value" }. Validated against an
+-- allowlist; applied to the service's router and auto-attached to its
+-- middleware chain at reconcile time.
+ALTER TABLE services ADD COLUMN IF NOT EXISTS traefik_labels jsonb NOT NULL DEFAULT '{}';
+
+-- +goose Down
+ALTER TABLE services DROP COLUMN IF EXISTS traefik_labels;
diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql
index 25c96ea..3118d21 100644
--- a/app/backend/sqlc/schema.sql
+++ b/app/backend/sqlc/schema.sql
@@ -69,7 +69,8 @@ CREATE TABLE services (
static_dir VARCHAR(255) NOT NULL DEFAULT '',
node_id VARCHAR(255),
spread BOOLEAN NOT NULL DEFAULT false,
- placement_tags JSONB NOT NULL DEFAULT '[]'
+ placement_tags JSONB NOT NULL DEFAULT '[]',
+ traefik_labels JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE registries (
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
index 86e0182..c97a734 100644
--- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
+++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
@@ -485,7 +485,7 @@ export function ServiceDetailPage() {
const [volumesForm, setVolumesForm] = useState(null);
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState | null>(null);
- const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
+ const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [nodeDraft, setNodeDraft] = useState(null);
const nodesQuery = useQuery({
@@ -2490,7 +2490,13 @@ export function ServiceDetailPage() {
+
+
Routing overrides
+
+ {Object.keys(service.traefikLabels ?? {}).length > 0
+ ? `${Object.keys(service.traefikLabels ?? {}).length} label(s)`
+ : 'off'}
+
+
) : (
@@ -2532,6 +2546,16 @@ export function ServiceDetailPage() {
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
+
+
+