feat(services): multi-domain, DNS check, maintenance + basic-auth gates (C2)

service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.

- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
  (ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
  (base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
  responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
  --maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
  bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast

Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 16:51:03 +02:00
1 parent 2f8f595d6e
commit e995c98d05
19 files changed
+1565 -42

No files matched your search

+230
View File
@@ -667,6 +667,163 @@ paths:
'200':
description: Reconciled runtime state
/services/{id}/domains:
get:
tags:
- Services
summary: List service domains
description: All hostnames attached to the service; the default is mirrored to services.domain
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Domain list
content:
application/json:
schema:
type: object
properties:
domains:
type: array
items:
$ref: '#/components/schemas/ServiceDomain'
post:
tags:
- Services
summary: Attach a domain
description: Adds a hostname routed to the service via Traefik. The first domain becomes default automatically.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- domain
properties:
domain:
type: string
is_default:
type: boolean
responses:
'201':
description: Domain attached
content:
application/json:
schema:
type: object
properties:
domain:
$ref: '#/components/schemas/ServiceDomain'
'409':
description: Domain already attached
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/services/{id}/domains/check:
get:
tags:
- Services
summary: DNS preflight
description: Resolves every attached domain and compares it to the expected target (app_settings.public_ip / PUBLIC_IP, or base_url host)
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Per-domain DNS status
content:
application/json:
schema:
type: object
properties:
domains:
type: array
items:
$ref: '#/components/schemas/DomainCheckResult'
/services/{id}/domains/{domain_id}:
delete:
tags:
- Services
summary: Detach a domain
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: domain_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Domain removed
/services/{id}/domains/{domain_id}/default:
post:
tags:
- Services
summary: Set the default domain
description: Marks the domain default and mirrors it to services.domain
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: domain_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Updated domain list
content:
application/json:
schema:
type: object
properties:
domains:
type: array
items:
$ref: '#/components/schemas/ServiceDomain'
/maintenance:
get:
tags:
- Services
summary: Maintenance page
description: Public HTML page Traefik redirects to while a service is in maintenance mode
/services/{id}/deployments:
get:
tags:
@@ -5003,6 +5160,19 @@ components:
items:
$ref: '#/components/schemas/ServiceVolume'
description: Volume/bind mounts applied to every replica
domains:
type: array
items:
$ref: '#/components/schemas/ServiceDomain'
description: All attached hostnames; `domain` is the derived default
maintenance_mode:
type: boolean
description: Redirect all traffic to the platform maintenance page
basic_auth:
type: array
items:
type: string
description: Basic-auth usernames gating the route (hashes never returned)
public_url:
type: string
description: Computed public URL (published port or domain)
@@ -5134,6 +5304,66 @@ components:
items:
$ref: '#/components/schemas/ServiceVolume'
description: Replaces the entire mount list when present; [] clears all mounts. Takes effect on next deploy/reconcile.
maintenance_mode:
type: boolean
description: Redirect all traffic to the platform maintenance page
basic_auth:
type: array
items:
$ref: '#/components/schemas/BasicAuthCred'
description: Replaces all basic-auth credentials; [] clears the gate
ServiceDomain:
type: object
properties:
id:
type: string
format: uuid
service_id:
type: string
format: uuid
domain:
type: string
is_default:
type: boolean
cert_type:
type: string
cert_status:
type: string
enum: [pending, issued, failed]
last_checked_at:
type: string
format: date-time
created_at:
type: string
format: date-time
DomainCheckResult:
type: object
properties:
domain:
type: string
status:
type: string
enum: [ok, wrong-target, pending]
description: ok = resolves to expected target (or no expectation configured), wrong-target = resolves elsewhere, pending = no DNS records
resolved:
type: array
items:
type: string
description: A/AAAA records resolved
BasicAuthCred:
type: object
required:
- username
- password
properties:
username:
type: string
password:
type: string
description: Plaintext on write — stored as bcrypt htpasswd; never returned
ServiceVolume:
type: object
+28
View File
@@ -38,6 +38,7 @@ auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens
projects list|get|create|delete
services list|get|create|update|delete|start|stop|restart|redeploy
services domains list|add|remove|default|check multi-domain + DNS preflight
volumes list|delete docker volume inventory + cleanup (admin)
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
deployments list|get|logs|rollback|cancel
@@ -87,6 +88,33 @@ redeploy — `containr deploy <id>` to apply immediately.
`volumes list` shows the node's docker volume inventory with in-use
flags; `volumes delete <name>` removes an unused volume (admin scope).
## Service domains and access
A service can carry multiple hostnames; the default is mirrored to
`services.domain`:
```bash
containr services domains add <id> app.example.com --default
containr services domains check <id> # DNS preflight per domain
containr services domains remove <id> <domain-id>
```
DNS check compares each domain to the expected target — set
`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is
meaningful; without it any resolvable domain reports `ok`.
Access gates apply via Traefik middlewares (no extra proxy):
```bash
containr services update <id> --maintenance on
containr services update <id> --basic-auth admin:secret --basic-auth ops:pw2
```
Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
— set `app_settings.base_url` or `BASE_URL`; without it the service is
taken offline with empty responses. Basic-auth credentials are stored
bcrypt-hashed (htpasswd) and gate every request.
## MCP server
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools