From 8c1c91a45869b0c6b47c2ea260f54597d0d9e0f3 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 10:09:14 -0400 Subject: [PATCH 1/9] 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 | 83 ++++++++++++++++++- ...0617000000-add-transport-service-domain.js | 34 ++++++++ .../models/transport-service.js | 35 ++++++++ .../routers/api/v1/containers.js | 49 +++++++++-- create-a-container/routers/templates.js | 6 +- create-a-container/views/nginx-conf.ejs | 14 +++- 7 files changed, 215 insertions(+), 10 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 85701ab3..6ca934c0 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 87ec7ebe..bf515903 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -50,10 +50,12 @@ 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(), authRequired: z.boolean().optional(), + tls: z.boolean().optional(), deleted: z.boolean().optional(), }) .refine( @@ -66,6 +68,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() }); @@ -163,10 +177,17 @@ export function ContainerFormPage() { : 'http' : (s.transportService?.protocol ?? 'tcp'), internalPort: String(s.internalPort), - externalHostname: s.httpService?.externalHostname || '', - externalDomainId: s.httpService ? String(s.httpService.externalDomainId) : '', + externalPort: s.transportService?.externalPort, + 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]) => ({ @@ -215,6 +236,7 @@ export function ContainerFormPage() { externalDomainId: '', dnsName: '', authRequired: false, + tls: false, deleted: false, }); added += 1; @@ -260,6 +282,7 @@ export function ContainerFormPage() { externalDomainId: s.externalDomainId ? parseInt(s.externalDomainId, 10) : undefined, dnsName: s.dnsName, authRequired: s.authRequired, + tls: s.tls, }; }); const payload = { @@ -502,6 +525,7 @@ export function ContainerFormPage() { externalDomainId: defaultExternalDomainId, dnsName: '', authRequired: false, + tls: false, deleted: false, }) } @@ -594,6 +618,61 @@ export function ContainerFormPage() { /> )} + {(svc.type === 'tcp' || svc.type === 'udp') && ( +
+
+

+ 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 && ( +
+ + .crt) is used + // to terminate TLS at the load balancer. Mirrors HTTPService.externalDomainId. + await queryInterface.addColumn('TransportServices', 'externalDomainId', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'ExternalDomains', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + comment: 'External domain providing the TLS certificate for TCP TLS termination' + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('TransportServices', 'externalDomainId'); + await queryInterface.removeColumn('TransportServices', 'externalHostname'); + } +}; diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js index 10397805..a49e02fd 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -5,6 +5,7 @@ module.exports = (sequelize, DataTypes) => { class TransportService extends Model { static associate(models) { TransportService.belongsTo(models.Service, { foreignKey: 'serviceId', as: 'service' }); + TransportService.belongsTo(models.ExternalDomain, { foreignKey: 'externalDomainId', as: 'externalDomain' }); } // Find the next available external port for the given protocol in the specified range @@ -68,10 +69,44 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.BOOLEAN, allowNull: true, comment: 'Whether to use TLS for TCP connections' + }, + externalHostname: { + type: DataTypes.STRING(255), + allowNull: true, + validate: { + is: { + args: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, + msg: 'Hostname must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit' + } + } + }, + externalDomainId: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: 'ExternalDomains', + key: 'id' + } } }, { sequelize, modelName: 'TransportService', + validate: { + // TLS termination is only supported for TCP (nginx stream `ssl`). + // UDP would require DTLS, which this load balancer does not provide. + tlsRequiresTcp() { + if (this.tls && this.protocol !== 'tcp') { + throw new Error('TLS can only be enabled for TCP services'); + } + }, + // A TLS-enabled TCP service must reference an external domain so the + // load balancer knows which certificate to terminate with. + tlsRequiresDomain() { + if (this.tls && !this.externalDomainId) { + throw new Error('A TLS-enabled TCP service must have an externalDomainId'); + } + } + }, indexes: [ { name: 'transport_services_unique_protocol_port', diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 2ca64da8..e20367de 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -121,6 +121,10 @@ function serializeContainer(c, site) { id: s.transportService.id, protocol: s.transportService.protocol, externalPort: s.transportService.externalPort, + tls: !!s.transportService.tls, + externalHostname: s.transportService.externalHostname, + externalDomainId: s.transportService.externalDomainId, + domain: s.transportService.externalDomain?.name, } : null, dnsService: s.dnsService @@ -131,6 +135,23 @@ function serializeContainer(c, site) { }; } +// Validate and normalize the TLS flag for a transport (TCP/UDP) service. +// Returns a boolean. Throws ApiError(400) when the request is inconsistent: +// - TLS is only supported for TCP (nginx stream `ssl`; UDP would need DTLS). +// - A TLS-enabled TCP service must reference an external domain so the load +// balancer knows which certificate to terminate with. +function parseTransportTls(tls, protocol, externalDomainId) { + const tlsEnabled = tls === true || tls === 'true'; + if (!tlsEnabled) return false; + if (protocol !== 'tcp') { + throw new ApiError(400, 'invalid_service', 'TLS can only be enabled for TCP services'); + } + if (!externalDomainId) { + throw new ApiError(400, 'invalid_service', 'TLS-enabled TCP services must have an externalDomainId'); + } + return true; +} + // GET /containers/metadata?image=... router.get( '/metadata', @@ -179,7 +200,7 @@ router.get( association: 'services', include: [ { association: 'httpService', include: [{ association: 'externalDomain' }] }, - { association: 'transportService' }, + { association: 'transportService', include: [{ association: 'externalDomain' }] }, { association: 'dnsService' }, ], }, @@ -209,7 +230,7 @@ router.get( association: 'services', include: [ { association: 'httpService', include: [{ association: 'externalDomain' }] }, - { association: 'transportService' }, + { association: 'transportService', include: [{ association: 'externalDomain' }] }, { association: 'dnsService' }, ], }, @@ -305,7 +326,7 @@ router.post( if (services && typeof services === 'object') { for (const key in services) { const svc = services[key]; - const { type, internalPort, externalHostname, externalDomainId, dnsName, authRequired } = svc; + const { type, internalPort, externalHostname, externalDomainId, dnsName, authRequired, tls } = svc; if (!type || !internalPort) continue; let serviceType; let protocol = null; @@ -340,9 +361,17 @@ router.post( { transaction: t }, ); } else { + const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId); const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565, t); await TransportService.create( - { serviceId: createdService.id, protocol, externalPort }, + { + serviceId: createdService.id, + protocol, + externalPort, + tls: tlsEnabled, + externalHostname: tlsEnabled ? externalHostname : null, + externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null, + }, { transaction: t }, ); } @@ -475,7 +504,7 @@ router.put( } const newHttp = []; for (const key in services) { - const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName, authRequired } = + const { id, deleted, type, internalPort, externalHostname, externalDomainId, dnsName, authRequired, tls } = services[key]; if (deleted === true || deleted === 'true' || id || !type || !internalPort) continue; const serviceType = @@ -504,9 +533,17 @@ router.put( { transaction: t }, ); } else { + const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId); const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565); await TransportService.create( - { serviceId: createdService.id, protocol, externalPort }, + { + serviceId: createdService.id, + protocol, + externalPort, + tls: tlsEnabled, + externalHostname: tlsEnabled ? externalHostname : null, + externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null, + }, { transaction: t }, ); } diff --git a/create-a-container/routers/templates.js b/create-a-container/routers/templates.js index 4793ba3a..d1da2b3d 100644 --- a/create-a-container/routers/templates.js +++ b/create-a-container/routers/templates.js @@ -51,7 +51,11 @@ router.get('/sites/:siteId/nginx', requireLocalhostOrAdmin, async (req, res) => as: 'httpService', include: [{ model: ExternalDomain, as: 'externalDomain' }], }, - { model: TransportService, as: 'transportService' }, + { + model: TransportService, + as: 'transportService', + include: [{ model: ExternalDomain, as: 'externalDomain' }], + }, ], }], }], diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index a638e4fe..30833e8f 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -459,8 +459,20 @@ stream { access_log /var/log/nginx/stream-access.log main; <%_ streamServices.forEach((service, index) => { _%> + <%_ const transport = service.transportService; _%> + <%_ const tlsEnabled = transport.protocol === 'tcp' && transport.tls && transport.externalDomain; _%> server { - listen <%= service.transportService.externalPort %><%= service.transportService.protocol === 'udp' ? ' udp' : '' %>; + <%_ if (tlsEnabled) { _%> + listen <%= transport.externalPort %> ssl; + + # TLS termination at the load balancer using the external domain's + # certificate (shared with HTTP services for the same domain). + ssl_certificate /etc/ssl/certs/<%= transport.externalDomain.name %>.crt; + ssl_certificate_key /etc/ssl/private/<%= transport.externalDomain.name %>.key; + ssl_protocols TLSv1.2 TLSv1.3; + <%_ } else { _%> + listen <%= transport.externalPort %><%= transport.protocol === 'udp' ? ' udp' : '' %>; + <%_ } _%> proxy_pass <%= service.Container.ipv4Address %>:<%= service.internalPort %>; } <%_ }) _%> From 95eb4bb9ff3289cedfacf6dcdcbda5d651b19f02 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 10:09:32 -0400 Subject: [PATCH 2/9] Fix transport port allocation upper bound (65565 -> 65535) The auto-assigned external port range used 65565 as its upper bound, which exceeds the maximum valid TCP/UDP port (65535) and the TransportService model's own validator (max: 65535). Correct both allocation call sites to 65535. --- create-a-container/routers/api/v1/containers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index e20367de..486b4f08 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -362,7 +362,7 @@ router.post( ); } else { const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId); - const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565, t); + const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, t); await TransportService.create( { serviceId: createdService.id, @@ -534,7 +534,7 @@ router.put( ); } else { const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId); - const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65565); + const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535); await TransportService.create( { serviceId: createdService.id, From d4603799b1b6b90c73c6c19ac0eba704b4d00580 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 10:09:40 -0400 Subject: [PATCH 3/9] docs: document TCP TLS termination and auto-assigned port display - web-gui: note that TCP/UDP auto-assigned ports are shown in the edit form; add a "TLS for TCP services" section - command-line: document tls/externalHostname/externalDomainId fields for TCP services with an example; fix port range typo (65565 -> 65535) - external-domains: explain that a domain's certificate is reused for TLS-terminated TCP services - system-architecture: note optional TLS termination for TCP at L4 --- .../admins/core-concepts/external-domains.md | 2 ++ .../docs/developers/system-architecture.md | 2 +- .../users/creating-containers/command-line.md | 21 +++++++++++++++++-- .../docs/users/creating-containers/web-gui.md | 8 ++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md index 256d753a..8d3dedb7 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/external-domains.md +++ b/mie-opensource-landing/docs/admins/core-concepts/external-domains.md @@ -103,6 +103,8 @@ On container or service deletion, cross-site A records are cleaned up automatica When creating a container service, users select an external domain and specify a subdomain (e.g., `app` for `app.example.com`). All external domains are available regardless of which site the container is on. See the [Web GUI guide](../../users/creating-containers/web-gui.md) for details. +A domain's certificate is also used to terminate TLS for **TLS-enabled TCP services**. When a TCP service has TLS enabled and references an external domain, NGINX terminates TLS on that service's auto-assigned external port using the domain's `/etc/ssl/certs/.crt` (the same certificate as HTTP services), then proxies the decrypted stream to the container. No additional certificate setup is required beyond the domain cert described above. TLS termination is not supported for UDP services. + ## Security - Issue Cloudflare User API Tokens with minimum scope (`Zone:DNS:Edit` for the target zone only) diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index c9b2ffd5..455dd62a 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -54,7 +54,7 @@ graph TB |-----------|------| | **Proxmox VE 13+** | Hypervisor — manages LXC containers via REST API. [Nodes](../admins/core-concepts/nodes.md) are registered Proxmox servers. | | **DNSMasq** | DHCP + DNS. Auto-assigns IPs to containers, provides internal name resolution (`container.cluster.internal`). | -| **NGINX** | Reverse proxy — L7 (HTTP/HTTPS with auto TLS via ACME) and L4 (TCP port mapping). Config auto-generated from container services. | +| **NGINX** | Reverse proxy — L7 (HTTP/HTTPS with auto TLS via ACME) and L4 (TCP/UDP port mapping, with optional TLS termination for TCP). Config auto-generated from container services. | | **LDAP Gateway** | Node.js LDAP server ([source](https://github.com/mieweb/LDAPServer)). Reads users/groups from the DB; containers authenticate via PAM/SSSD. | | **Push Notification Service** | 2FA via push notifications ([source](https://github.com/mieweb/mieweb_auth_app)). Configured in [Settings](../admins/settings.md). Used by LDAP gateway when `AUTH_BACKENDS` includes `notification`. | | **Database** | PostgreSQL via Sequelize ORM. Stores users, groups, sites, nodes, containers, and service config. | diff --git a/mie-opensource-landing/docs/users/creating-containers/command-line.md b/mie-opensource-landing/docs/users/creating-containers/command-line.md index 06abb421..43606538 100644 --- a/mie-opensource-landing/docs/users/creating-containers/command-line.md +++ b/mie-opensource-landing/docs/users/creating-containers/command-line.md @@ -52,11 +52,28 @@ Services use a zero-indexed array (`services[0]`, `services[1]`, etc.): | Type | Required Fields | Notes | |------|----------------|-------| -| `tcp` | `internalPort` | External port auto-assigned | -| `udp` | `internalPort` | External port auto-assigned (range 2000–65565) | +| `tcp` | `internalPort` | External port auto-assigned. For TLS termination, also send `tls=true` with `externalHostname` + `externalDomainId` (see below). | +| `udp` | `internalPort` | External port auto-assigned (range 2000–65535) | | `http` | `internalPort`, `externalHostname`, `externalDomainId` | Subdomain + domain | | `srv` | `internalPort`, `dnsName` | e.g., `_ldap._tcp` | +#### TLS-Terminated TCP Service + +To have the load balancer terminate TLS for a TCP service (using the same certificate as the matching HTTP domain), set `tls=true` and provide a domain. TLS is only supported for `tcp` (not `udp`). + +```bash +curl -X POST 'https://create-a-container.opensource.mieweb.org/sites/1/containers' \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'hostname=my-db' \ + --data-urlencode 'template=pve1,100' \ + --data-urlencode 'services[0][type]=tcp' \ + --data-urlencode 'services[0][internalPort]=5432' \ + --data-urlencode 'services[0][tls]=true' \ + --data-urlencode 'services[0][externalHostname]=db' \ + --data-urlencode 'services[0][externalDomainId]=1' +``` + ## 4. Complete Example ```bash diff --git a/mie-opensource-landing/docs/users/creating-containers/web-gui.md b/mie-opensource-landing/docs/users/creating-containers/web-gui.md index 9aaef8c1..70ccac51 100644 --- a/mie-opensource-landing/docs/users/creating-containers/web-gui.md +++ b/mie-opensource-landing/docs/users/creating-containers/web-gui.md @@ -47,7 +47,13 @@ Expose container ports. Click **Add Service**, select a type, and enter the inte Both HTTP and HTTPS services are served to the public over HTTPS with automatic TLS certificates — the type controls the protocol used between the reverse proxy and your container. -HTTP and HTTPS services require selecting an external domain; the hostname defaults to the container hostname. TCP/UDP services are auto-assigned an external port. +HTTP and HTTPS services require selecting an external domain; the hostname defaults to the container hostname. TCP/UDP services are auto-assigned an external port — once the container is created, the assigned port is shown (read-only) in the service row when you edit the container. + +#### TLS for TCP services + +TCP services can optionally enable **TLS termination at the load balancer**. Toggle **Enable TLS** on a TCP service and select an external domain (and optional hostname, just like an HTTP service). The load balancer then terminates TLS on the assigned external port using that domain's certificate — the same certificate used by HTTP services — and forwards the decrypted traffic to your container. This lets clients connect to a plaintext backend over an encrypted connection without the container managing certificates. + +TLS is not available for UDP services. !!! note From dd1e6dcc587d87ced0d1eeeb669100f34fefc276 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 10:47:16 -0400 Subject: [PATCH 4/9] docs: rewrite curl API guide for the JSON /api/v1 API The command-line guide predated the JSON API rewrite and described form-encoded requests, a non-existent /api/templates endpoint, the old nodeName,templateVmid template format, and 302-redirect responses. Rewrite it against the actual /api/v1 JSON API: - Bearer auth and the { data } / { error } response envelope - JSON create with services as an object map (not a form array) - template is now a Docker image reference; node is auto-selected - accurate endpoints: external-domains, container metadata, list/get, async job tracking (status + SSE stream), update/delete - TLS-terminated TCP example in JSON form - use the {{ manager_url }} macro instead of a hardcoded host --- .../users/creating-containers/command-line.md | 211 ++++++++++++------ 1 file changed, 141 insertions(+), 70 deletions(-) diff --git a/mie-opensource-landing/docs/users/creating-containers/command-line.md b/mie-opensource-landing/docs/users/creating-containers/command-line.md index 43606538..2f156ae6 100644 --- a/mie-opensource-landing/docs/users/creating-containers/command-line.md +++ b/mie-opensource-landing/docs/users/creating-containers/command-line.md @@ -2,117 +2,188 @@ !!! tip "Interactive API Docs" - Your server hosts interactive Swagger documentation at `/api` with a "Try it out" console, full request/response schemas, and authentication support. + Your server hosts interactive Swagger documentation at [`{{ manager_url }}/api`]({{ manager_url }}/api) with a "Try it out" console and full request/response schemas. The OpenAPI spec is served at `/api/v1/openapi.yaml`. -!!! caution "Outdated Documentation" +The API is a JSON-only REST API served under the **`/api/v1`** base path. - This page predates the last major rewrite and may not be accurate. Check back weekly for updates. +**Prerequisites:** -API endpoint: {{ manager_url }} +* An active account, an [API key](./api-keys.md), `curl`, and your **site ID** (visible in the web interface URL, e.g. `/sites/1/...`). -**Prerequisites:** +## Authentication + +Pass your API key as a Bearer token. All examples below set a reusable variable: + +```bash +API="{{ manager_url }}/api/v1" +KEY="your_api_key_here" +``` + +Every request sends `Authorization: Bearer $KEY`. CSRF tokens are **not** required for Bearer-authenticated requests (they apply only to browser session auth). + +## Response Envelope + +Successful responses wrap the payload in a `data` key; errors use an `error` object: -* Active account, an [API key](./api-keys.md), `curl`, and your site ID (from the web interface). +```json +{ "data": { "id": 1, "hostname": "my-app" } } +``` + +```json +{ "error": { "code": "invalid_request", "message": "hostname is required" } } +``` + +`curl ... | jq .data` is a convenient way to read results. + +## 1. List External Domains -## 1. List Available Templates +You need an external domain's `id` to expose HTTP services (and TLS-terminated TCP services). List the domains available to you: ```bash -curl -X GET 'https://create-a-container.opensource.mieweb.org/api/templates' \ - -H "Authorization: Bearer YOUR_API_KEY" +curl -s "$API/external-domains" -H "Authorization: Bearer $KEY" | jq '.data' ``` -## 2. Get External Domain IDs +## 2. Discover Image Metadata (optional) + +Inspect a Docker image's exposed ports, entrypoint, and environment before creating a container: ```bash -curl -X GET 'https://create-a-container.opensource.mieweb.org/api/external-domains' \ - -H "Authorization: Bearer YOUR_API_KEY" +curl -s -G "$API/sites/1/containers/metadata" \ + -H "Authorization: Bearer $KEY" \ + --data-urlencode 'image=ghcr.io/mieweb/opensource-server/base:latest' | jq '.data' ``` ## 3. Create a Container +Send a JSON body to `POST /sites/{siteId}/containers`. A least-loaded node in the site is selected automatically — you do not choose a node. + ```bash -curl -X POST 'https://create-a-container.opensource.mieweb.org/sites/1/containers' \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'hostname=my-app' \ - --data-urlencode 'template=pve1,100' \ - --data-urlencode 'services[0][type]=tcp' \ - --data-urlencode 'services[0][internalPort]=22' \ - --data-urlencode 'services[1][type]=http' \ - --data-urlencode 'services[1][internalPort]=3000' \ - --data-urlencode 'services[1][externalHostname]=my-app' \ - --data-urlencode 'services[1][externalDomainId]=1' +curl -s -X POST "$API/sites/1/containers" \ + -H "Authorization: Bearer $KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "hostname": "my-app", + "template": "ghcr.io/mieweb/opensource-server/base:latest", + "services": { + "ssh": { "type": "tcp", "internalPort": 22 }, + "web": { "type": "http", "internalPort": 3000, "externalHostname": "my-app", "externalDomainId": 1 } + } + }' | jq '.data' ``` -**Required:** `hostname` (letters/numbers/hyphens), `template` (`nodeName,templateVmid`). +On success this returns `201` with the new container ID and the creation job ID: + +```json +{ "data": { "containerId": 42, "jobId": 100, "hostname": "my-app", "status": "pending" } } +``` -### Service Types +Watch the job to completion — see [Track the Creation Job](#5-track-the-creation-job). -Services use a zero-indexed array (`services[0]`, `services[1]`, etc.): +### Request Fields -| Type | Required Fields | Notes | -|------|----------------|-------| -| `tcp` | `internalPort` | External port auto-assigned. For TLS termination, also send `tls=true` with `externalHostname` + `externalDomainId` (see below). | -| `udp` | `internalPort` | External port auto-assigned (range 2000–65535) | -| `http` | `internalPort`, `externalHostname`, `externalDomainId` | Subdomain + domain | -| `srv` | `internalPort`, `dnsName` | e.g., `_ldap._tcp` | +| Field | Required | Description | +|-------|----------|-------------| +| `hostname` | yes | Lowercase letters, digits, hyphens. | +| `template` | yes | A Docker image reference (e.g. `ghcr.io/mieweb/opensource-server/base:latest`, `docker.io/library/nginx:latest`). Use the literal string `custom` together with `customTemplate` to supply an image not in the standard list. | +| `customTemplate` | no | Image reference used when `template` is `"custom"`. | +| `entrypoint` | no | Override the container entrypoint. | +| `nvidiaRequested` | no | `true` to schedule on an NVIDIA-capable node and inject GPU env vars. | +| `environmentVars` | no | Array of `{ "key": "...", "value": "..." }` objects. | +| `services` | no | An **object** whose keys are arbitrary labels and whose values are service definitions (see below). | + +### Services + +`services` is a JSON object (a map), not an array. The keys (`"ssh"`, `"web"`, … above) are arbitrary labels you choose; only the values matter: + +| Type | Required fields | Notes | +|------|-----------------|-------| +| `tcp` | `internalPort` | External port auto-assigned. For TLS termination, also send `tls: true` with `externalHostname` + `externalDomainId` (see below). | +| `udp` | `internalPort` | External port auto-assigned (range 2000–65535). | +| `http` | `internalPort`, `externalHostname`, `externalDomainId` | Public side is always HTTPS. | +| `https` | `internalPort`, `externalHostname`, `externalDomainId` | Same as `http` but the proxy talks HTTPS to your backend. | +| `srv` | `internalPort`, `dnsName` | DNS SRV record, e.g. `_ldap._tcp`. | + +!!! note + + Add a `tcp` service with `internalPort: 22` to expose SSH. Its external port is auto-assigned; read it back from the container detail (`sshPort`) once created. #### TLS-Terminated TCP Service -To have the load balancer terminate TLS for a TCP service (using the same certificate as the matching HTTP domain), set `tls=true` and provide a domain. TLS is only supported for `tcp` (not `udp`). +To have the load balancer terminate TLS for a TCP service (using the same certificate as the matching HTTP domain), set `tls: true` and provide an external domain. TLS is supported for `tcp` only, not `udp`. ```bash -curl -X POST 'https://create-a-container.opensource.mieweb.org/sites/1/containers' \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'hostname=my-db' \ - --data-urlencode 'template=pve1,100' \ - --data-urlencode 'services[0][type]=tcp' \ - --data-urlencode 'services[0][internalPort]=5432' \ - --data-urlencode 'services[0][tls]=true' \ - --data-urlencode 'services[0][externalHostname]=db' \ - --data-urlencode 'services[0][externalDomainId]=1' +curl -s -X POST "$API/sites/1/containers" \ + -H "Authorization: Bearer $KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "hostname": "my-db", + "template": "docker.io/library/postgres:16", + "services": { + "pg": { + "type": "tcp", + "internalPort": 5432, + "tls": true, + "externalHostname": "db", + "externalDomainId": 1 + } + } + }' | jq '.data' ``` -## 4. Complete Example +## 4. List Your Containers ```bash -API_KEY="your_api_key_here" - -curl -X POST 'https://create-a-container.opensource.mieweb.org/sites/1/containers' \ - -H "Authorization: Bearer $API_KEY" \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'hostname=my-web-app' \ - --data-urlencode 'template=pve1,100' \ - --data-urlencode 'services[0][type]=tcp' \ - --data-urlencode 'services[0][internalPort]=22' \ - --data-urlencode 'services[1][type]=http' \ - --data-urlencode 'services[1][internalPort]=8080' \ - --data-urlencode 'services[1][externalHostname]=my-web-app' \ - --data-urlencode 'services[1][externalDomainId]=1' \ - --data-urlencode 'services[2][type]=tcp' \ - --data-urlencode 'services[2][internalPort]=5432' +# All containers in a site +curl -s "$API/sites/1/containers" -H "Authorization: Bearer $KEY" | jq '.data' + +# A single container (note the auto-assigned sshPort and service externalPorts) +curl -s "$API/sites/1/containers/42" -H "Authorization: Bearer $KEY" | jq '.data' ``` -Returns 302 redirect on success, error message on failure. +The container payload includes `status`, `ipv4Address`, `sshPort`, and a `services` array where each transport service reports its auto-assigned `externalPort`. -## 5. List Your Containers +## 5. Track the Creation Job + +Container creation is asynchronous. Use the `jobId` from the create response: ```bash -curl -X GET 'https://create-a-container.opensource.mieweb.org/api/containers' \ - -H "Authorization: Bearer YOUR_API_KEY" +# Poll job metadata (status: pending | running | completed | failed) +curl -s "$API/jobs/100" -H "Authorization: Bearer $KEY" | jq '.data.status' + +# Read job log lines +curl -s "$API/jobs/100/status" -H "Authorization: Bearer $KEY" | jq '.data' + +# Or follow the live Server-Sent Events stream +curl -N "$API/jobs/100/stream" -H "Authorization: Bearer $KEY" ``` -## 6. Access Your Container +## 6. Update or Delete a Container -**SSH:** `ssh -p @.` +```bash +# Update services, environment variables, or entrypoint. Existing services are +# immutable (except auth) — delete and re-add to change them. Applying changes +# may enqueue a restart job. +curl -s -X PUT "$API/sites/1/containers/42" \ + -H "Authorization: Bearer $KEY" \ + -H 'Content-Type: application/json' \ + -d '{ "services": { "extra": { "type": "tcp", "internalPort": 8080 } } }' | jq '.data' + +# Restart only (no other changes) +curl -s -X PUT "$API/sites/1/containers/42" \ + -H "Authorization: Bearer $KEY" \ + -H 'Content-Type: application/json' \ + -d '{ "restart": true }' | jq '.data' + +# Delete (also cleans up DNS; returns any warnings) +curl -s -X DELETE "$API/sites/1/containers/42" -H "Authorization: Bearer $KEY" | jq '.data' +``` -**HTTP:** `https://.` +## 7. Access Your Container -**Proxmox:** Navigate to [{{ proxmox_url }}]({{ proxmox_url }}) — your container is listed with your username in the tags field. +**SSH:** `ssh -p @.` -![Hostname](img/proxmox-lxc.jpg) +**HTTP:** `https://.` -!!! note +**Proxmox console:** [{{ proxmox_url }}]({{ proxmox_url }}) — your container is listed with your username in the tags field. - You can start, stop, and reboot through Proxmox. To delete, contact an administrator. +![Hostname](img/proxmox-lxc.jpg) From 3a3d81be4fd7a24b4ebf3a3deaa5f36508b54ef7 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 10:51:09 -0400 Subject: [PATCH 5/9] docs(openapi): detail service schemas incl. TCP TLS fields Flesh out the v1 OpenAPI spec, which previously modeled services and container responses as untyped objects. - add Service / HttpService / TransportService / DnsService component schemas matching the serialized container payload, including the transport tls / externalHostname / externalDomainId / domain fields - add a ServiceInput schema for create/update request bodies documenting per-type required fields (tls is tcp-only and needs a domain) - expand the Container schema (sshHost, httpEntries, nodeApiUrl, createdAt) - document the container list/get/create/update/delete request and response shapes with the { data } envelope and error cases Validated with the Redocly OpenAPI linter (0 errors, 0 warnings). --- create-a-container/openapi.v1.yaml | 295 +++++++++++++++++++++++++++-- 1 file changed, 280 insertions(+), 15 deletions(-) diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index e049dd16..dc3d83c9 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -87,23 +87,176 @@ components: type: object properties: id: { type: integer } + containerId: + type: integer + nullable: true + description: Proxmox LXC VMID. Null until the creation job provisions the container. hostname: { type: string } - containerId: { type: integer, nullable: true } - status: { type: string } - template: { type: string } ipv4Address: { type: string, nullable: true } macAddress: { type: string, nullable: true } + status: + type: string + description: Lifecycle status (e.g. `pending`, `running`, `stopped`). + template: + type: string + description: Docker image reference the container was created from. creationJobId: { type: integer, nullable: true } entrypoint: { type: string, nullable: true } environmentVars: type: object additionalProperties: { type: string } nvidiaRequested: { type: boolean } - sshPort: { type: integer, nullable: true } + sshPort: + type: integer + nullable: true + description: External port of the TCP service whose internal port is 22, if present. + sshHost: + type: string + nullable: true + description: Hostname clients should use for SSH — the primary HTTP service's hostname, or the site's external IP. + httpEntries: + type: array + description: Convenience list of the container's HTTP services and their public URLs. + items: + type: object + properties: + port: { type: integer, description: Internal (container) port. } + externalUrl: { type: string, nullable: true, description: Public `https://` URL, or null if no domain is set. } nodeName: { type: string, nullable: true } + nodeApiUrl: { type: string, nullable: true } services: type: array - items: { type: object } + items: { $ref: '#/components/schemas/Service' } + createdAt: { type: string, format: date-time } + Service: + type: object + description: | + A container service. Exactly one of `httpService`, `transportService`, + or `dnsService` is populated, matching `type` (`http`→httpService, + `transport`→transportService, `dns`→dnsService). + properties: + id: { type: integer } + type: + type: string + enum: [http, transport, dns] + internalPort: + type: integer + description: Port the service listens on inside the container. + httpService: + allOf: [{ $ref: '#/components/schemas/HttpService' }] + nullable: true + transportService: + allOf: [{ $ref: '#/components/schemas/TransportService' }] + nullable: true + dnsService: + allOf: [{ $ref: '#/components/schemas/DnsService' }] + nullable: true + HttpService: + type: object + description: HTTP/HTTPS reverse-proxy service. The public side is always HTTPS; `backendProtocol` selects the proxy→container protocol. + properties: + id: { type: integer } + externalHostname: + type: string + description: Subdomain label (e.g. `app` for `app.example.com`). + externalDomainId: + type: integer + description: ID of the external domain (see GET /external-domains). + backendProtocol: + type: string + enum: [http, https] + description: Protocol the reverse proxy uses to reach the container. + authRequired: + type: boolean + description: When true, NGINX enforces `auth_request` against the domain's auth server. + domain: + type: string + nullable: true + description: Resolved external domain name (e.g. `example.com`). + TransportService: + type: object + description: Layer-4 TCP/UDP port mapping. TCP services may optionally terminate TLS at the load balancer. + properties: + id: { type: integer } + protocol: + type: string + enum: [tcp, udp] + externalPort: + type: integer + minimum: 1 + maximum: 65535 + description: Publicly exposed port, auto-assigned from the range 2000–65535. + tls: + type: boolean + description: >- + Whether the load balancer terminates TLS on `externalPort` for this + service. Only valid for `tcp`; always false for `udp`. + externalHostname: + type: string + nullable: true + description: Subdomain label used when `tls` is true (otherwise null). + externalDomainId: + type: integer + nullable: true + description: >- + External domain whose certificate terminates TLS when `tls` is true + (otherwise null). Uses the same certificate as HTTP services for that domain. + domain: + type: string + nullable: true + description: Resolved external domain name when `tls` is true (otherwise null). + DnsService: + type: object + description: DNS SRV record for service discovery. + properties: + id: { type: integer } + recordType: + type: string + enum: [SRV] + dnsName: + type: string + description: SRV record name (e.g. `_ldap._tcp`). + ServiceInput: + type: object + description: | + A service definition in a container create/update request. `type` selects + the kind of service; supply the fields relevant to that type. On update, + include `id` to reference an existing service and `deleted: true` to remove it. + required: [type, internalPort] + properties: + id: + type: integer + description: Existing service ID (update only). Omit when adding a new service. + deleted: + type: boolean + description: Update only — set true (with `id`) to remove the service. + type: + type: string + enum: [http, https, tcp, udp, srv] + description: >- + Service type. `http`/`https` create an HTTP service (the value sets + backendProtocol); `tcp`/`udp` create a transport service; `srv` + creates a DNS service. + internalPort: + type: integer + description: Port the service listens on inside the container. + externalHostname: + type: string + description: Subdomain label. Required for `http`/`https`; used for `tcp` when `tls` is true. + externalDomainId: + type: integer + description: External domain ID. Required for `http`/`https`; required for `tcp` when `tls` is true. + authRequired: + type: boolean + description: HTTP only — require authentication via the domain's auth server. + tls: + type: boolean + description: >- + `tcp` only — terminate TLS at the load balancer using the selected + domain's certificate. Requires `externalDomainId`. Rejected for `udp`. + dnsName: + type: string + description: Required for `srv` — the SRV record name (e.g. `_ldap._tcp`). Node: type: object properties: @@ -289,32 +442,76 @@ paths: /sites/{siteId}/containers: get: tags: [Containers] + summary: List containers in a site parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: query, name: hostname, schema: { type: string } } - responses: { '200': { description: Array of containers } } + responses: + '200': + description: Array of containers + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Container' } post: tags: [Containers] + summary: Create a container (enqueues a provisioning job) parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] requestBody: + required: true content: application/json: schema: type: object required: [hostname, template] properties: - hostname: { type: string } - template: { type: string } - customTemplate: { type: string } + hostname: + type: string + description: Lowercase letters, digits, and hyphens. + template: + type: string + description: >- + Docker image reference (e.g. + `ghcr.io/mieweb/opensource-server/base:latest`). Use the + literal string `custom` with `customTemplate` for an + arbitrary image. The provisioning node is auto-selected. + customTemplate: + type: string + description: Image reference used when `template` is `"custom"`. entrypoint: { type: string } - nvidiaRequested: { type: boolean } + nvidiaRequested: + type: boolean + description: Schedule on an NVIDIA-capable node and inject GPU env vars. environmentVars: type: array items: { type: object, properties: { key: { type: string }, value: { type: string } } } services: type: object - additionalProperties: { type: object } - responses: { '201': { description: Creation job enqueued } } + description: >- + Map of arbitrary label → service definition. The keys are + ignored by the server; only the values matter. + additionalProperties: { $ref: '#/components/schemas/ServiceInput' } + responses: + '201': + description: Creation job enqueued + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + containerId: { type: integer } + jobId: { type: integer } + hostname: { type: string } + status: { type: string, example: pending } + '400': { description: Invalid request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '409': { description: 'No eligible node available (code: no_node / no_nvidia_node)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/new: get: tags: [Containers] @@ -335,9 +532,77 @@ paths: parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } - get: { tags: [Containers], responses: { '200': { description: Container } } } - put: { tags: [Containers], responses: { '200': { description: Updated, optional restart job } } } - delete: { tags: [Containers], responses: { '200': { description: Deleted, with DNS cleanup warnings } } } + get: + tags: [Containers] + summary: Get a single container + responses: + '200': + description: Container + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/Container' } + '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + put: + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + entrypoint: { type: string } + restart: + type: boolean + description: Force a restart. When sent alone, only restarts (no other changes). + environmentVars: + type: array + items: { type: object, properties: { key: { type: string }, value: { type: string } } } + services: + type: object + description: 'Map of label → service definition. Include `id` to reference existing services and `deleted: true` to remove them.' + additionalProperties: { $ref: '#/components/schemas/ServiceInput' } + responses: + '200': + description: Updated; optional restart job + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + containerId: { type: integer } + jobId: { type: integer, nullable: true } + message: { type: string } + dnsWarnings: { type: array, items: { type: string } } + '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + delete: + tags: [Containers] + summary: Delete a container (also cleans up cross-site DNS) + responses: + '200': + description: Deleted, with DNS cleanup warnings + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + deleted: { type: boolean, example: true } + dnsWarnings: { type: array, items: { type: string } } + '404': { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '409': { description: 'Hostname mismatch with Proxmox (code: hostname_mismatch)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/nodes: parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] From 7620e380ecb4a8da345f78f42376494e14099b63 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 11:05:24 -0400 Subject: [PATCH 6/9] Add TLS service type (backend re-encryption via proxy_ssl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "TLS" transport service type: like TCP, but the load balancer connects to the backend over TLS (nginx stream `proxy_ssl on`) — the stream analog of the HTTPS service's backendProtocol=https. The backend certificate is not verified, so self-signed backends work. It is stored as protocol=tcp plus a new `backendTls` boolean on TransportService (mirroring HTTPService.backendProtocol), so it composes with the existing client-side TLS termination flag: a TLS service may terminate client TLS and/or re-encrypt to the backend. - migration: add backendTls boolean to TransportServices - model: add backendTls field + validator (proxy_ssl is TCP-only) - api: map the `tls` request type to { protocol: tcp, backendTls: true } via parseTransportType(), persist and serialize backendTls - nginx template: emit `proxy_ssl on` for backendTls stream servers, independently of TLS termination - client: add the TLS service type, derive it from backendTls on load, reuse the TCP UI (port + termination toggle); relabel the toggle to "Terminate TLS at load balancer" to distinguish the two TLS concepts - openapi + docs: document the tls type and backendTls field Validated: client tsc + build, Redocly lint (0/0), docs build, nginx template render for all flag combinations, and the model/type-mapping logic. --- create-a-container/client/src/lib/types.ts | 1 + .../pages/containers/ContainerFormPage.tsx | 17 ++++++++------ ...10000-add-transport-service-backend-tls.js | 21 +++++++++++++++++ .../models/transport-service.js | 13 +++++++++++ create-a-container/openapi.v1.yaml | 23 +++++++++++++------ .../routers/api/v1/containers.js | 18 +++++++++++++-- create-a-container/views/nginx-conf.ejs | 8 +++++++ .../docs/developers/system-architecture.md | 2 +- .../users/creating-containers/command-line.md | 20 ++++++++++++++-- .../docs/users/creating-containers/web-gui.md | 11 ++++++--- 10 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 6ca934c0..9258582d 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -55,6 +55,7 @@ export interface ServiceTransport { protocol: 'tcp' | 'udp'; externalPort: number; tls: boolean; + backendTls: boolean; externalHostname: string | null; externalDomainId: number | null; domain?: string; diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index bf515903..b86a5164 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -41,6 +41,7 @@ const SERVICE_TYPES = [ { value: 'http', label: 'HTTP' }, { value: 'https', label: 'HTTPS (backend TLS)' }, { value: 'tcp', label: 'TCP' }, + { value: 'tls', label: 'TLS (backend TLS)' }, { value: 'udp', label: 'UDP' }, { value: 'srv', label: 'DNS (SRV record)' }, ]; @@ -48,7 +49,7 @@ const SERVICE_TYPES = [ const serviceSchema = z .object({ id: z.number().optional(), - type: z.enum(['http', 'https', 'tcp', 'udp', 'srv']), + type: z.enum(['http', 'https', 'tcp', 'tls', 'udp', 'srv']), internalPort: z.string(), externalPort: z.number().optional(), externalHostname: z.string().optional(), @@ -73,11 +74,11 @@ const serviceSchema = z (s) => s.deleted || s.id !== undefined || // existing services are immutable here - s.type !== 'tcp' || + (s.type !== 'tcp' && s.type !== 'tls') || !s.tls || !!s.externalDomainId, { - message: 'An external domain is required for TLS-enabled TCP services', + message: 'An external domain is required when TLS termination is enabled', path: ['externalDomainId'], }, ); @@ -175,7 +176,9 @@ export function ContainerFormPage() { ? s.httpService?.backendProtocol === 'https' ? 'https' : 'http' - : (s.transportService?.protocol ?? 'tcp'), + : s.transportService?.backendTls + ? 'tls' + : (s.transportService?.protocol ?? 'tcp'), internalPort: String(s.internalPort), externalPort: s.transportService?.externalPort, externalHostname: @@ -618,7 +621,7 @@ export function ContainerFormPage() { />
)} - {(svc.type === 'tcp' || svc.type === 'udp') && ( + {(svc.type === 'tcp' || svc.type === 'tls' || svc.type === 'udp') && (

@@ -632,10 +635,10 @@ export function ContainerFormPage() { )}

- {svc.type === 'tcp' && ( + {(svc.type === 'tcp' || svc.type === 'tls') && ( <> { diff --git a/create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js b/create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js new file mode 100644 index 00000000..70ddafd7 --- /dev/null +++ b/create-a-container/migrations/20260617010000-add-transport-service-backend-tls.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Whether the load balancer connects to the backend over TLS (nginx + // stream `proxy_ssl on`). This is the transport analog of an HTTPS + // service's backendProtocol=https. Independent of the client-side `tls` + // termination flag; a TCP service may do either or both. + await queryInterface.addColumn('TransportServices', 'backendTls', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether the load balancer re-encrypts to the backend via proxy_ssl' + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('TransportServices', 'backendTls'); + } +}; diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js index a49e02fd..66be7109 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -70,6 +70,12 @@ module.exports = (sequelize, DataTypes) => { allowNull: true, comment: 'Whether to use TLS for TCP connections' }, + backendTls: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether the load balancer re-encrypts to the backend via proxy_ssl' + }, externalHostname: { type: DataTypes.STRING(255), allowNull: true, @@ -105,6 +111,13 @@ module.exports = (sequelize, DataTypes) => { if (this.tls && !this.externalDomainId) { throw new Error('A TLS-enabled TCP service must have an externalDomainId'); } + }, + // Backend TLS re-encryption (nginx stream `proxy_ssl`) is only valid for + // TCP. UDP cannot use proxy_ssl. + backendTlsRequiresTcp() { + if (this.backendTls && this.protocol !== 'tcp') { + throw new Error('Backend TLS can only be enabled for TCP services'); + } } }, indexes: [ diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index dc3d83c9..14c9e0b4 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -191,6 +191,12 @@ components: description: >- Whether the load balancer terminates TLS on `externalPort` for this service. Only valid for `tcp`; always false for `udp`. + backendTls: + type: boolean + description: >- + Whether the load balancer re-encrypts the connection to the backend + (nginx stream `proxy_ssl`). Set for services created with the `tls` + type. The backend certificate is not verified. TCP only. externalHostname: type: string nullable: true @@ -232,28 +238,31 @@ components: description: Update only — set true (with `id`) to remove the service. type: type: string - enum: [http, https, tcp, udp, srv] + enum: [http, https, tcp, tls, udp, srv] description: >- Service type. `http`/`https` create an HTTP service (the value sets - backendProtocol); `tcp`/`udp` create a transport service; `srv` - creates a DNS service. + backendProtocol); `tcp`/`udp` create a transport service; `tls` + creates a TCP transport service that re-encrypts to the backend + (proxy_ssl); `srv` creates a DNS service. internalPort: type: integer description: Port the service listens on inside the container. externalHostname: type: string - description: Subdomain label. Required for `http`/`https`; used for `tcp` when `tls` is true. + description: Subdomain label. Required for `http`/`https`; used for `tcp`/`tls` when `tls` is true. externalDomainId: type: integer - description: External domain ID. Required for `http`/`https`; required for `tcp` when `tls` is true. + description: External domain ID. Required for `http`/`https`; required for `tcp`/`tls` when `tls` is true. authRequired: type: boolean description: HTTP only — require authentication via the domain's auth server. tls: type: boolean description: >- - `tcp` only — terminate TLS at the load balancer using the selected - domain's certificate. Requires `externalDomainId`. Rejected for `udp`. + `tcp`/`tls` only — terminate client TLS at the load balancer using + the selected domain's certificate. Requires `externalDomainId`. + Rejected for `udp`. Independent of the `tls` service type + (backend re-encryption), so a `tls` service may also terminate. dnsName: type: string description: Required for `srv` — the SRV record name (e.g. `_ldap._tcp`). diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 486b4f08..8ab66f4f 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -122,6 +122,7 @@ function serializeContainer(c, site) { protocol: s.transportService.protocol, externalPort: s.transportService.externalPort, tls: !!s.transportService.tls, + backendTls: !!s.transportService.backendTls, externalHostname: s.transportService.externalHostname, externalDomainId: s.transportService.externalDomainId, domain: s.transportService.externalDomain?.name, @@ -135,6 +136,15 @@ function serializeContainer(c, site) { }; } +// Map an incoming transport service `type` to its persisted shape. +// The API exposes `tcp`, `udp`, and `tls` types, but the DB only stores +// protocol `tcp`/`udp`; a `tls` type is a TCP service with `backendTls` set +// (the load balancer re-encrypts to the backend via `proxy_ssl`). +function parseTransportType(type) { + if (type === 'tls') return { protocol: 'tcp', backendTls: true }; + return { protocol: type, backendTls: false }; +} + // Validate and normalize the TLS flag for a transport (TCP/UDP) service. // Returns a boolean. Throws ApiError(400) when the request is inconsistent: // - TLS is only supported for TCP (nginx stream `ssl`; UDP would need DTLS). @@ -330,11 +340,12 @@ router.post( if (!type || !internalPort) continue; let serviceType; let protocol = null; + let backendTls = false; if (type === 'http' || type === 'https') serviceType = 'http'; else if (type === 'srv') serviceType = 'dns'; else { serviceType = 'transport'; - protocol = type; + ({ protocol, backendTls } = parseTransportType(type)); } const createdService = await Service.create( { containerId: container.id, type: serviceType, internalPort: parseInt(internalPort, 10) }, @@ -369,6 +380,7 @@ router.post( protocol, externalPort, tls: tlsEnabled, + backendTls, externalHostname: tlsEnabled ? externalHostname : null, externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null, }, @@ -509,7 +521,8 @@ router.put( if (deleted === true || deleted === 'true' || id || !type || !internalPort) continue; const serviceType = type === 'srv' ? 'dns' : type === 'http' || type === 'https' ? 'http' : 'transport'; - const protocol = serviceType === 'transport' ? type : null; + const { protocol, backendTls } = + serviceType === 'transport' ? parseTransportType(type) : { protocol: null, backendTls: false }; const createdService = await Service.create( { containerId: container.id, type: serviceType, internalPort: parseInt(internalPort, 10) }, { transaction: t }, @@ -541,6 +554,7 @@ router.put( protocol, externalPort, tls: tlsEnabled, + backendTls, externalHostname: tlsEnabled ? externalHostname : null, externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null, }, diff --git a/create-a-container/views/nginx-conf.ejs b/create-a-container/views/nginx-conf.ejs index 30833e8f..d9636f4c 100644 --- a/create-a-container/views/nginx-conf.ejs +++ b/create-a-container/views/nginx-conf.ejs @@ -461,6 +461,7 @@ stream { <%_ streamServices.forEach((service, index) => { _%> <%_ const transport = service.transportService; _%> <%_ const tlsEnabled = transport.protocol === 'tcp' && transport.tls && transport.externalDomain; _%> + <%_ const backendTls = transport.protocol === 'tcp' && transport.backendTls; _%> server { <%_ if (tlsEnabled) { _%> listen <%= transport.externalPort %> ssl; @@ -473,6 +474,13 @@ stream { <%_ } else { _%> listen <%= transport.externalPort %><%= transport.protocol === 'udp' ? ' udp' : '' %>; <%_ } _%> + <%_ if (backendTls) { _%> + + # Re-encrypt the connection to the backend (proxy_ssl). The backend + # certificate is not verified (backends often use self-signed certs). + proxy_ssl on; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + <%_ } _%> proxy_pass <%= service.Container.ipv4Address %>:<%= service.internalPort %>; } <%_ }) _%> diff --git a/mie-opensource-landing/docs/developers/system-architecture.md b/mie-opensource-landing/docs/developers/system-architecture.md index 455dd62a..24e9ede9 100644 --- a/mie-opensource-landing/docs/developers/system-architecture.md +++ b/mie-opensource-landing/docs/developers/system-architecture.md @@ -54,7 +54,7 @@ graph TB |-----------|------| | **Proxmox VE 13+** | Hypervisor — manages LXC containers via REST API. [Nodes](../admins/core-concepts/nodes.md) are registered Proxmox servers. | | **DNSMasq** | DHCP + DNS. Auto-assigns IPs to containers, provides internal name resolution (`container.cluster.internal`). | -| **NGINX** | Reverse proxy — L7 (HTTP/HTTPS with auto TLS via ACME) and L4 (TCP/UDP port mapping, with optional TLS termination for TCP). Config auto-generated from container services. | +| **NGINX** | Reverse proxy — L7 (HTTP/HTTPS with auto TLS via ACME) and L4 (TCP/UDP port mapping, with optional TLS termination and backend re-encryption for TCP). Config auto-generated from container services. | | **LDAP Gateway** | Node.js LDAP server ([source](https://github.com/mieweb/LDAPServer)). Reads users/groups from the DB; containers authenticate via PAM/SSSD. | | **Push Notification Service** | 2FA via push notifications ([source](https://github.com/mieweb/mieweb_auth_app)). Configured in [Settings](../admins/settings.md). Used by LDAP gateway when `AUTH_BACKENDS` includes `notification`. | | **Database** | PostgreSQL via Sequelize ORM. Stores users, groups, sites, nodes, containers, and service config. | diff --git a/mie-opensource-landing/docs/users/creating-containers/command-line.md b/mie-opensource-landing/docs/users/creating-containers/command-line.md index 2f156ae6..7f7ae74a 100644 --- a/mie-opensource-landing/docs/users/creating-containers/command-line.md +++ b/mie-opensource-landing/docs/users/creating-containers/command-line.md @@ -98,6 +98,7 @@ Watch the job to completion — see [Track the Creation Job](#5-track-the-creati | Type | Required fields | Notes | |------|-----------------|-------| | `tcp` | `internalPort` | External port auto-assigned. For TLS termination, also send `tls: true` with `externalHostname` + `externalDomainId` (see below). | +| `tls` | `internalPort` | Like `tcp`, but the load balancer re-encrypts to the backend (`proxy_ssl`). May also terminate client TLS (`tls: true` + domain). | | `udp` | `internalPort` | External port auto-assigned (range 2000–65535). | | `http` | `internalPort`, `externalHostname`, `externalDomainId` | Public side is always HTTPS. | | `https` | `internalPort`, `externalHostname`, `externalDomainId` | Same as `http` but the proxy talks HTTPS to your backend. | @@ -107,9 +108,24 @@ Watch the job to completion — see [Track the Creation Job](#5-track-the-creati Add a `tcp` service with `internalPort: 22` to expose SSH. Its external port is auto-assigned; read it back from the container detail (`sshPort`) once created. -#### TLS-Terminated TCP Service +#### TLS Service (backend re-encryption) -To have the load balancer terminate TLS for a TCP service (using the same certificate as the matching HTTP domain), set `tls: true` and provide an external domain. TLS is supported for `tcp` only, not `udp`. +Use the `tls` type when your container's service speaks TLS — the load balancer connects to it over TLS (`proxy_ssl`; the backend certificate is not verified). The client still connects to the auto-assigned port over plain TCP unless you also enable termination. + +```bash +curl -s -X POST "$API/sites/1/containers" \ + -H "Authorization: Bearer $KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "hostname": "my-svc", + "template": "docker.io/library/nginx:latest", + "services": { "secure": { "type": "tls", "internalPort": 8443 } } + }' | jq '.data' +``` + +#### TLS Termination at the Load Balancer + +To have the load balancer terminate client TLS (using the same certificate as the matching HTTP domain), set `tls: true` and provide an external domain. This works for `tcp` and `tls` services (not `udp`); a `tls` service can both terminate client TLS and re-encrypt to the backend. ```bash curl -s -X POST "$API/sites/1/containers" \ diff --git a/mie-opensource-landing/docs/users/creating-containers/web-gui.md b/mie-opensource-landing/docs/users/creating-containers/web-gui.md index 70ccac51..55f02a6e 100644 --- a/mie-opensource-landing/docs/users/creating-containers/web-gui.md +++ b/mie-opensource-landing/docs/users/creating-containers/web-gui.md @@ -43,15 +43,20 @@ Expose container ports. Click **Add Service**, select a type, and enter the inte | **HTTP** | Reverse-proxies to the container over plain HTTP. Use when the backend listens on an unencrypted port. | | **HTTPS** | Reverse-proxies to the container over HTTPS. Use when the backend terminates TLS itself. | | **TCP / UDP** | Maps an auto-assigned external port to the container's internal port. | +| **TLS** | Like TCP, but the load balancer connects to your container over TLS (re-encrypts to the backend). Use when the backend listens on a TLS port. | | **SRV** | Creates a DNS SRV record for service discovery. | Both HTTP and HTTPS services are served to the public over HTTPS with automatic TLS certificates — the type controls the protocol used between the reverse proxy and your container. -HTTP and HTTPS services require selecting an external domain; the hostname defaults to the container hostname. TCP/UDP services are auto-assigned an external port — once the container is created, the assigned port is shown (read-only) in the service row when you edit the container. +HTTP and HTTPS services require selecting an external domain; the hostname defaults to the container hostname. TCP/UDP/TLS services are auto-assigned an external port — once the container is created, the assigned port is shown (read-only) in the service row when you edit the container. -#### TLS for TCP services +#### TLS service type (backend re-encryption) -TCP services can optionally enable **TLS termination at the load balancer**. Toggle **Enable TLS** on a TCP service and select an external domain (and optional hostname, just like an HTTP service). The load balancer then terminates TLS on the assigned external port using that domain's certificate — the same certificate used by HTTP services — and forwards the decrypted traffic to your container. This lets clients connect to a plaintext backend over an encrypted connection without the container managing certificates. +The **TLS** type is the layer-4 equivalent of HTTPS: the client connects to the auto-assigned port over plain TCP, and the load balancer opens a TLS connection to your container (NGINX `proxy_ssl`). The backend certificate is not verified, so self-signed certificates work. Use this when your container's service speaks TLS. + +#### Terminating TLS at the load balancer + +TCP and TLS services can optionally enable **TLS termination at the load balancer**. Toggle **Terminate TLS at load balancer** and select an external domain (and optional hostname, just like an HTTP service). The load balancer then terminates TLS on the assigned external port using that domain's certificate — the same certificate used by HTTP services. This lets clients connect over an encrypted connection without the container managing certificates. A TLS service can both terminate client TLS and re-encrypt to the backend. TLS is not available for UDP services. From 07726bef83d86ef7e0b8b10273c6f6c20fa7b39f Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 17 Jun 2026 11:08:55 -0400 Subject: [PATCH 7/9] Fix transport TLS hostname handling and PUT port-allocation race Address PR review findings: - Normalize the optional externalHostname for TLS-enabled transport services: trim and treat blank as null. The client defaulted it to '' which failed the model's hostname regex. Add a normalizeHostname helper used by both the create and update paths, and stop sending an empty externalHostname from the client (omit when blank). - Pass the transaction to TransportService.nextAvailablePortInRange in the PUT handler. Without it the SELECT ... FOR UPDATE row lock was skipped, allowing concurrent creates to race and fail the unique (protocol, externalPort) constraint. --- .../src/pages/containers/ContainerFormPage.tsx | 3 ++- create-a-container/routers/api/v1/containers.js | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index b86a5164..f7d139cc 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -277,11 +277,12 @@ export function ContainerFormPage() { return; } if (s.deleted) return; + const externalHostname = s.externalHostname?.trim(); servicesObj[`s${idx}`] = { id: s.id, type: s.type, internalPort: s.internalPort ? parseInt(s.internalPort, 10) : undefined, - externalHostname: s.externalHostname, + externalHostname: externalHostname ? externalHostname : undefined, externalDomainId: s.externalDomainId ? parseInt(s.externalDomainId, 10) : undefined, dnsName: s.dnsName, authRequired: s.authRequired, diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 8ab66f4f..9677312b 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -136,6 +136,15 @@ function serializeContainer(c, site) { }; } +// Normalize an optional hostname: trim whitespace and treat blank as null. +// The client sends `externalHostname` as a string (defaulting to ''), but the +// model's hostname regex rejects empty strings — so blank means "unset". +function normalizeHostname(hostname) { + if (typeof hostname !== 'string') return hostname ?? null; + const trimmed = hostname.trim(); + return trimmed === '' ? null : trimmed; +} + // Map an incoming transport service `type` to its persisted shape. // The API exposes `tcp`, `udp`, and `tls` types, but the DB only stores // protocol `tcp`/`udp`; a `tls` type is a TCP service with `backendTls` set @@ -381,7 +390,7 @@ router.post( externalPort, tls: tlsEnabled, backendTls, - externalHostname: tlsEnabled ? externalHostname : null, + externalHostname: tlsEnabled ? normalizeHostname(externalHostname) : null, externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null, }, { transaction: t }, @@ -547,7 +556,7 @@ router.put( ); } else { const tlsEnabled = parseTransportTls(tls, protocol, externalDomainId); - const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535); + const externalPort = await TransportService.nextAvailablePortInRange(protocol, 2000, 65535, t); await TransportService.create( { serviceId: createdService.id, @@ -555,7 +564,7 @@ router.put( externalPort, tls: tlsEnabled, backendTls, - externalHostname: tlsEnabled ? externalHostname : null, + externalHostname: tlsEnabled ? normalizeHostname(externalHostname) : null, externalDomainId: tlsEnabled ? parseInt(externalDomainId, 10) : null, }, { transaction: t }, From 24536d480a23acba472bd13ba9da68f525fee06b Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 18 Jun 2026 11:50:25 -0400 Subject: [PATCH 8/9] Fix immutable-service hint wording for non-HTTP services The hint shown on existing services referenced "Require authentication", an HTTP-only setting, even for TCP/UDP/TLS/SRV services that have no editable fields. Show the auth-specific message only for HTTP/HTTPS services and a generic "fields cannot be changed" message otherwise. --- .../client/src/pages/containers/ContainerFormPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index f7d139cc..2376ecc4 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -552,7 +552,9 @@ export function ContainerFormPage() {
{isExisting && (

- Existing service — only Require authentication can be changed. Delete and re-add to modify other fields. + {svc.type === 'http' || svc.type === 'https' + ? 'Existing service — only Require authentication can be changed. Delete and re-add to modify other fields.' + : 'Existing service — fields cannot be changed. Delete and re-add to modify.'}

)}
From 084735055ebd1c0517f0aaa1a2dc74a28272463d Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 18 Jun 2026 11:57:58 -0400 Subject: [PATCH 9/9] Allow editing existing service fields in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously existing services were immutable (only an HTTP service's authRequired could change); any other change required delete + re-add. Services only drive nginx config and DNS records — never the LXC container — so in-place edits are safe. The PUT handler now updates existing services in place: - HTTP: externalHostname, externalDomainId, backendProtocol (http/https), authRequired; a hostname/domain change rewrites the cross-site DNS record (delete old + create new) - transport: internalPort, TLS termination (tls + hostname/domain), backend TLS (tcp<->tls) - DNS: dnsName - internalPort is editable for all types The service `type` (and a transport's protocol / auto-assigned external port) remain fixed — changing those still requires delete + re-add, and is rejected with a clear error. The UI unlocks the editable fields (keeping Type and the external port read-only) and updates the hint. OpenAPI PUT description updated accordingly. --- .../pages/containers/ContainerFormPage.tsx | 11 +-- create-a-container/openapi.v1.yaml | 9 +- .../routers/api/v1/containers.js | 93 +++++++++++++++++-- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 2376ecc4..85b7f81b 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -552,9 +552,7 @@ export function ContainerFormPage() {
{isExisting && (

- {svc.type === 'http' || svc.type === 'https' - ? 'Existing service — only Require authentication can be changed. Delete and re-add to modify other fields.' - : 'Existing service — fields cannot be changed. Delete and re-add to modify.'} + Existing service — the type cannot be changed. Delete and re-add to change it.

)}
@@ -582,7 +580,6 @@ export function ContainerFormPage() { label="Internal port" type="number" inputMode="numeric" - readOnly={isExisting} {...register(`services.${idx}.internalPort`)} />