Enable TLS for TCP services and display auto-assigned ports#365
Enable TLS for TCP services and display auto-assigned ports#365runleveldev wants to merge 9 commits into
Conversation
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/<domain>.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
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.
- 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
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
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).
There was a problem hiding this comment.
⚠️ Not ready to approve
There are backend/UI integration bugs around blank externalHostname handling for TLS-enabled TCP services and a missing transaction parameter in port allocation that can cause race-condition failures.
Pull request overview
This PR wires up end-to-end TLS termination for TCP transport services (using an existing external-domain certificate) and surfaces the already-auto-assigned external port for TCP/UDP services in the container edit UI. It also fixes the transport port allocator’s invalid upper bound (65565 → 65535) and updates related documentation and OpenAPI schema.
Changes:
- Add TLS termination support for TCP transport services (API serialization + persistence, NGINX
stream {}generation, UI toggle + domain selection). - Display auto-assigned external ports for TCP/UDP services in the container edit form.
- Correct transport port allocation upper bound to 65535 and update docs/OpenAPI accordingly.
File summaries
| File | Description |
|---|---|
| mie-opensource-landing/docs/users/creating-containers/web-gui.md | Documents external-port visibility and TCP TLS termination behavior in the GUI. |
| mie-opensource-landing/docs/users/creating-containers/command-line.md | Updates CLI/API examples (JSON body, service map) and documents TLS-terminated TCP + external port fields. |
| mie-opensource-landing/docs/developers/system-architecture.md | Updates architecture description to reflect TCP TLS termination support. |
| mie-opensource-landing/docs/admins/core-concepts/external-domains.md | Documents reuse of domain certificates for TCP TLS termination. |
| create-a-container/views/nginx-conf.ejs | Emits listen ... ssl and cert/key directives for TLS-enabled TCP stream servers. |
| create-a-container/routers/templates.js | Eager-loads externalDomain for transport services so TLS stream config can render. |
| create-a-container/routers/api/v1/containers.js | Serializes TLS/domain fields, validates TLS constraints, persists new TLS-related transport fields, fixes port-range upper bound. |
| create-a-container/openapi.v1.yaml | Expands schema/docs for containers/services including transport TLS fields and port constraints. |
| create-a-container/models/transport-service.js | Adds externalHostname/externalDomainId fields, association, and TLS validation rules. |
| create-a-container/migrations/20260617000000-add-transport-service-domain.js | Adds DB columns for TLS-related transport-domain linkage. |
| create-a-container/client/src/pages/containers/ContainerFormPage.tsx | Adds external-port read-only display and TLS toggle + domain inputs for TCP services. |
| create-a-container/client/src/lib/types.ts | Extends transport service typing to include TLS/domain fields. |
Copilot's findings
- Files reviewed: 12/12 changed files
- Comments generated: 4
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
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.
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.
There was a problem hiding this comment.
⚠️ Not ready to approve
There are validation/safety gaps in newly added TLS paths (notably transport TLS domain ID parsing and domain-name sanitization/validation for NGINX/cert paths) that should be addressed before approval.
Copilot's findings
- Files reviewed: 13/13 changed files
- Comments generated: 2
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
| 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; | ||
| } |
| # 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; |
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.
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.
Closes #360.
Two related TCP/UDP service features, plus a small port-range fix.
1. Display the auto-assigned external port
TCP/UDP services get an auto-assigned external port, but it was never surfaced in the UI. The port is now shown (read-only) in each TCP/UDP service row of the container edit form. New, unsaved services show "Auto-assigned on save". The value was already serialized by the API; this only adds the UI surface.
2. TLS termination for TCP services
Per #360, a TCP service can now enable TLS termination at the load balancer. The
tlscolumn already existed onTransportServicebut was a no-op; this wires it up end-to-end and adds a domain reference so NGINX knows which certificate to use./etc/ssl/certs/<domain>.crt, the same cert used by HTTP services for that domain) is used to terminate TLS on the service's auto-assigned external port; decrypted traffic is proxied to the container.ssl_prereadneeded.streamssldirective does not provide).Example generated
streamserver (TLS-enabled TCP)Changes
20260617000000-add-transport-service-domain.js: add nullableexternalHostname+externalDomainId(FK →ExternalDomains) toTransportServices.transport-service.js: add the two fields, theexternalDomainassociation, and validation (TLS requirestcp+ a domain).containers.js: serializetls/externalHostname/externalDomainId/domain; eager-loadexternalDomain; persist TLS fields on create/update; validate via a newparseTransportTls()helper.nginx-conf.ejs+templates.js: emitssl+ssl_certificate/ssl_certificate_key/ssl_protocolsfor TLS-enabled TCP stream servers (plain TCP and UDP unchanged); eager-load the domain for the config render.types.ts+ContainerFormPage.tsx: extendServiceTransport; add the TLS toggle, domain inputs, and read-only external-port display.Incidental fix
The transport port allocator used
65565as its upper bound, which exceeds the max valid port (65535) and the model's ownmax: 65535validator. Corrected both call sites (separate commit).Commits
Add auto-assigned port display and TLS termination for TCP servicesFix transport port allocation upper bound (65565 -> 65535)docs: document TCP TLS termination and auto-assigned port displayTesting notes
tsc -b --noEmitandvite buildpass.node --check.stream {}block for TLS-TCP / plain-TCP / UDP / TLS-without-domain; output is correct, and the no-domain case safely falls back to plaintext.parseTransportTls(TLS-on-UDP and TLS-without-domain rejected; string"true"coercion handled).sqlite3binary needsGLIBC_2.38, unavailable here). Please runnpx sequelize-cli db:migratebefore testing.Follow-up considerations (not in this PR)