mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
2f8f595d6e
commit
e995c98d05
19 files changed
+1565
-42
No files matched your search
@@ -55,9 +55,29 @@ export type ServiceEntity = {
|
||||
healthcheckPath?: string;
|
||||
restartPolicy?: string;
|
||||
volumes?: ServiceVolume[];
|
||||
domains?: ServiceDomain[];
|
||||
maintenanceMode?: boolean;
|
||||
basicAuth?: string[];
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
export type ServiceDomain = {
|
||||
id: string;
|
||||
service_id: string;
|
||||
domain: string;
|
||||
is_default: boolean;
|
||||
cert_type?: string;
|
||||
cert_status?: string;
|
||||
last_checked_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type DomainCheckResult = {
|
||||
domain: string;
|
||||
status: 'ok' | 'wrong-target' | 'pending';
|
||||
resolved?: string[];
|
||||
};
|
||||
|
||||
export type ServiceVolume = {
|
||||
type?: 'volume' | 'bind';
|
||||
source: string;
|
||||
@@ -561,6 +581,9 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
healthcheckPath: service.healthcheck_path,
|
||||
restartPolicy: service.restart_policy,
|
||||
volumes: service.volumes as ServiceVolume[] | undefined,
|
||||
domains: service.domains as ServiceDomain[] | undefined,
|
||||
maintenanceMode: service.maintenance_mode,
|
||||
basicAuth: service.basic_auth as string[] | undefined,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
@@ -1100,6 +1123,36 @@ export async function createService(projectId: string, input: CreateServiceInput
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function listServiceDomains(serviceId: string): Promise<ServiceDomain[]> {
|
||||
const payload = await requestJson<{ domains?: ServiceDomain[] }>(`/services/${serviceId}/domains`);
|
||||
return payload.domains ?? [];
|
||||
}
|
||||
|
||||
export async function addServiceDomain(serviceId: string, domain: string, isDefault = false): Promise<ServiceDomain> {
|
||||
const payload = await requestJson<{ domain: ServiceDomain }>(`/services/${serviceId}/domains`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain, is_default: isDefault }),
|
||||
});
|
||||
return payload.domain;
|
||||
}
|
||||
|
||||
export async function removeServiceDomain(serviceId: string, domainId: string): Promise<void> {
|
||||
await requestJson(`/services/${serviceId}/domains/${domainId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function setDefaultServiceDomain(serviceId: string, domainId: string): Promise<ServiceDomain[]> {
|
||||
const payload = await requestJson<{ domains?: ServiceDomain[] }>(
|
||||
`/services/${serviceId}/domains/${domainId}/default`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return payload.domains ?? [];
|
||||
}
|
||||
|
||||
export async function checkServiceDomains(serviceId: string): Promise<DomainCheckResult[]> {
|
||||
const payload = await requestJson<{ domains?: DomainCheckResult[] }>(`/services/${serviceId}/domains/check`);
|
||||
return payload.domains ?? [];
|
||||
}
|
||||
|
||||
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
||||
method: 'PUT',
|
||||
|
||||
Reference in new issue
Block a user