From 1e26a00b4b7b8e50869d1b99f38ff06097e9e4d5 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 9 Jul 2026 11:22:31 -0400 Subject: [PATCH 01/10] Display auto-assigned external port for TCP/UDP services The external port for TCP/UDP transport services is auto-assigned by the backend and already serialized by the API, but it was never shown in the UI. Surface it (read-only) in each TCP/UDP service row of the container form; new, unsaved services show "Auto-assigned on save". --- .../src/pages/containers/ContainerFormPage.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 93792134..ad7079de 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -52,6 +52,7 @@ const serviceSchema = z id: z.number().optional(), type: z.enum(['http', 'https', 'tcp', 'udp', 'srv']), internalPort: z.string(), + externalPort: z.number().optional(), externalHostname: z.string().optional(), externalDomainId: z.string().optional(), dnsName: z.string().optional(), @@ -183,6 +184,7 @@ export function ContainerFormPage() { : 'http' : (s.transportService?.protocol ?? 'tcp'), internalPort: String(s.internalPort), + externalPort: s.transportService?.externalPort, externalHostname: s.httpService?.externalHostname || '', externalDomainId: s.httpService ? String(s.httpService.externalDomainId) : '', dnsName: s.dnsService?.dnsName || '', @@ -627,6 +629,20 @@ export function ContainerFormPage() { /> )} + {(svc.type === 'tcp' || svc.type === 'udp') && ( +
+

+ External port +

+

+ {svc.externalPort ?? ( + + Auto-assigned on save + + )} +

+
+ )} {svc.type === 'srv' && ( Date: Wed, 17 Jun 2026 10:09:14 -0400 Subject: [PATCH 02/10] Add auto-assigned port display and TLS termination for TCP services Two related service features: 1. Display the auto-assigned external port for TCP/UDP services (read-only) in the container edit form. The port was already serialized by the API; this surfaces it in the UI. 2. Allow TCP services to enable TLS termination at the load balancer. A TLS-enabled TCP service references an external domain (and optional hostname, like HTTP services), and NGINX terminates TLS on the service's auto-assigned port using that domain's existing certificate (/etc/ssl/certs/.crt), then proxies the decrypted stream to the container. TLS is restricted to TCP; UDP is rejected. Changes: - migration: add nullable externalHostname + externalDomainId to TransportServices (tls column already existed) - model: add fields, externalDomain association, and validation (TLS requires tcp + a domain) - api: serialize tls/hostname/domain, eager-load externalDomain, persist TLS fields on create/update, validate via parseTransportTls() - nginx template: emit ssl + ssl_certificate for TLS-enabled TCP stream servers; plain TCP and UDP unchanged - client: extend ServiceTransport type; add TLS toggle + domain inputs and read-only external port to the service form --- create-a-container/client/src/lib/types.ts | 4 + .../pages/containers/ContainerFormPage.tsx | 89 ++++++++++++++++--- ...0617000000-add-transport-service-domain.js | 34 +++++++ .../models/transport-service.js | 35 ++++++++ .../routers/api/v1/containers.js | 47 ++++++++-- create-a-container/routers/templates.js | 6 +- create-a-container/views/nginx-conf.ejs | 14 ++- 7 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 create-a-container/migrations/20260617000000-add-transport-service-domain.js diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 357da690..19840a27 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -54,6 +54,10 @@ export interface ServiceTransport { id: number; protocol: 'tcp' | 'udp'; externalPort: number; + tls: boolean; + externalHostname: string | null; + externalDomainId: number | null; + domain?: string; } export interface ServiceDns { id: number; diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index ad7079de..93dc40bc 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -57,6 +57,7 @@ const serviceSchema = z externalDomainId: z.string().optional(), dnsName: z.string().optional(), authRequired: z.boolean().optional(), + tls: z.boolean().optional(), deleted: z.boolean().optional(), }) .refine( @@ -69,6 +70,18 @@ const serviceSchema = z message: 'An external domain is required for HTTP services', path: ['externalDomainId'], }, + ) + .refine( + (s) => + s.deleted || + s.id !== undefined || // existing services are immutable here + s.type !== 'tcp' || + !s.tls || + !!s.externalDomainId, + { + message: 'An external domain is required for TLS-enabled TCP services', + path: ['externalDomainId'], + }, ); const envVarSchema = z.object({ key: z.string(), value: z.string() }); @@ -185,10 +198,16 @@ export function ContainerFormPage() { : (s.transportService?.protocol ?? 'tcp'), internalPort: String(s.internalPort), externalPort: s.transportService?.externalPort, - externalHostname: s.httpService?.externalHostname || '', - externalDomainId: s.httpService ? String(s.httpService.externalDomainId) : '', + externalHostname: + s.httpService?.externalHostname || s.transportService?.externalHostname || '', + externalDomainId: s.httpService + ? String(s.httpService.externalDomainId) + : s.transportService?.externalDomainId + ? String(s.transportService.externalDomainId) + : '', dnsName: s.dnsService?.dnsName || '', authRequired: !!s.httpService?.authRequired, + tls: !!s.transportService?.tls, deleted: false, })), environmentVars: Object.entries(container.environmentVars || {}).map(([key, value]) => ({ @@ -238,6 +257,7 @@ export function ContainerFormPage() { externalDomainId: '', dnsName: '', authRequired: false, + tls: false, deleted: false, }); added += 1; @@ -284,6 +304,7 @@ export function ContainerFormPage() { externalDomainId: s.externalDomainId ? parseInt(s.externalDomainId, 10) : undefined, dnsName: s.dnsName, authRequired: s.authRequired, + tls: s.tls, }; }); const payload = { @@ -537,6 +558,7 @@ export function ContainerFormPage() { externalDomainId: defaultExternalDomainId, dnsName: '', authRequired: false, + tls: false, deleted: false, }) } @@ -630,17 +652,58 @@ export function ContainerFormPage() { )} {(svc.type === 'tcp' || svc.type === 'udp') && ( -
-

- External port -

-

- {svc.externalPort ?? ( - - Auto-assigned on save - - )} -

+
+
+

+ External port +

+

+ {svc.externalPort ?? ( + + Auto-assigned on save + + )} +

+
+ {svc.type === 'tcp' && ( + <> + { + setValue(`services.${idx}.tls`, c); + // Ensure a domain is selected when enabling TLS so + // the load balancer has a certificate to use. + if (c && !svc.externalDomainId && defaultExternalDomainId) { + setValue( + `services.${idx}.externalDomainId`, + defaultExternalDomainId, + ); + } + }} + /> + {svc.tls && ( +
+ + { setValue(`services.${idx}.tls`, c); // Ensure a domain is selected when enabling TLS so @@ -694,7 +688,6 @@ export function ContainerFormPage() { )} diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 66e1c49c..1da737bc 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -594,9 +594,12 @@ paths: tags: [Containers] summary: Update a container's services, env vars, or entrypoint (may enqueue a restart job) description: >- - Accepts a partial body. Existing services are immutable except for - `authRequired` — to change other fields, delete and re-add the service. - `hostname` and `template` cannot be changed here. + Accepts a partial body. Existing services can be edited in place by + including their `id` — all fields are updatable except the service + `type` and a transport service's protocol/auto-assigned external port + (delete and re-add to change those). Changing an HTTP service's + hostname/domain rewrites its cross-site DNS record. `hostname` and + `template` cannot be changed here. requestBody: content: application/json: diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index bf809626..c117d296 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -679,6 +679,7 @@ router.put( if (services && typeof services === 'object') { const deletedHttp = []; + const newHttp = []; for (const key in services) { const { id, deleted } = services[key]; if ((deleted === true || deleted === 'true') && id) { @@ -704,21 +705,97 @@ router.put( }); } } + // Update existing services in place. The service `type` (HTTP vs + // transport vs DNS) and a transport's protocol/externalPort are fixed + // for the life of the service; everything else is editable. HTTP + // hostname/domain changes also rewrite the cross-site DNS record. for (const key in services) { - const { id, deleted, authRequired } = services[key]; + const svc = services[key]; + const { id, deleted, type, internalPort } = svc; if (deleted === true || deleted === 'true' || !id) continue; - const svc = await Service.findByPk(parseInt(id, 10), { - include: [{ model: HTTPService, as: 'httpService' }], + const existing = await Service.findByPk(parseInt(id, 10), { + include: [ + { model: HTTPService, as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }] }, + { model: TransportService, as: 'transportService', include: [{ model: ExternalDomain, as: 'externalDomain' }] }, + { model: DnsService, as: 'dnsService' }, + ], transaction: t, }); - if (svc?.httpService) { - const next = authRequired === true || authRequired === 'true'; - if (svc.httpService.authRequired !== next) { - await svc.httpService.update({ authRequired: next }, { transaction: t }); + // Only touch services that belong to this container. + if (!existing || existing.containerId !== container.id) continue; + + // internalPort is common to all service types and lives on the base row. + if (internalPort !== undefined && internalPort !== null && String(internalPort).trim() !== '') { + const port = parseInt(internalPort, 10); + if (existing.internalPort !== port) { + await existing.update({ internalPort: port }, { transaction: t }); + } + } + + if (existing.httpService) { + if (type && type !== 'http' && type !== 'https') { + throw new ApiError(400, 'invalid_service', 'Cannot change the type of an existing service'); + } + const { externalHostname, externalDomainId, authRequired } = svc; + const newHostname = normalizeHostname(externalHostname); + if (!newHostname || !externalDomainId) { + throw new ApiError(400, 'invalid_service', 'HTTP services must have an externalHostname and externalDomainId'); + } + const newDomainId = parseInt(externalDomainId, 10); + const prevHostname = existing.httpService.externalHostname; + const prevDomain = existing.httpService.externalDomain; + const hostOrDomainChanged = + prevHostname !== newHostname || existing.httpService.externalDomainId !== newDomainId; + await existing.httpService.update( + { + externalHostname: newHostname, + externalDomainId: newDomainId, + backendProtocol: type === 'https' ? 'https' : 'http', + authRequired: authRequired === true || authRequired === 'true', + }, + { transaction: t }, + ); + // Rewrite cross-site DNS when the public name changed: remove the + // old record, add the new one. Both are non-fatal (warnings only). + if (hostOrDomainChanged) { + if (prevDomain) { + deletedHttp.push({ externalHostname: prevHostname, ExternalDomain: prevDomain }); + } + const newDomain = await ExternalDomain.findByPk(newDomainId, { transaction: t }); + if (newDomain) newHttp.push({ externalHostname: newHostname, ExternalDomain: newDomain }); + } + } else if (existing.transportService) { + // Protocol (and therefore the auto-assigned externalPort) is fixed. + // Allow tcp<->tls (backendTls toggle); reject changing protocol or + // crossing to a non-transport type. + const incomingProtocol = + type === undefined ? existing.transportService.protocol : parseTransportType(type).protocol; + if (type && (type === 'http' || type === 'https' || type === 'srv')) { + throw new ApiError(400, 'invalid_service', 'Cannot change the type of an existing service'); + } + if (incomingProtocol !== existing.transportService.protocol) { + throw new ApiError(400, 'invalid_service', 'Cannot change the protocol of an existing service'); + } + const backendTls = type === undefined ? existing.transportService.backendTls : parseTransportType(type).backendTls; + const tlsEnabled = parseTransportTls(svc.tls, existing.transportService.protocol, svc.externalDomainId); + await existing.transportService.update( + { + tls: tlsEnabled, + backendTls, + externalHostname: tlsEnabled ? normalizeHostname(svc.externalHostname) : null, + externalDomainId: tlsEnabled ? parseInt(svc.externalDomainId, 10) : null, + }, + { transaction: t }, + ); + } else if (existing.dnsService) { + if (type && type !== 'srv') { + throw new ApiError(400, 'invalid_service', 'Cannot change the type of an existing service'); + } + if (svc.dnsName && svc.dnsName !== existing.dnsService.dnsName) { + await existing.dnsService.update({ dnsName: svc.dnsName }, { transaction: t }); } } } - const newHttp = []; for (const key in services) { const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName, authRequired, tls } = services[key];