Skip to content

Enable TLS for TCP services and display auto-assigned ports#365

Open
runleveldev wants to merge 9 commits into
mainfrom
360-enable-tls-for-tcp-services
Open

Enable TLS for TCP services and display auto-assigned ports#365
runleveldev wants to merge 9 commits into
mainfrom
360-enable-tls-for-tcp-services

Conversation

@runleveldev

Copy link
Copy Markdown
Collaborator

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 tls column already existed on TransportService but was a no-op; this wires it up end-to-end and adds a domain reference so NGINX knows which certificate to use.

  • The form shows an Enable TLS toggle for TCP services. Enabling it reveals External hostname + External domain inputs (mirroring HTTP services).
  • The selected domain's existing certificate (/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.
  • Because each TCP service already has a unique external port, termination is per-port — no SNI/ssl_preread needed.
  • TLS is restricted to TCP; enabling it on UDP is rejected (UDP would require DTLS, which the NGINX stream ssl directive does not provide).

Example generated stream server (TLS-enabled TCP)

server {
    listen 2000 ssl;
    ssl_certificate /etc/ssl/certs/example.com.crt;
    ssl_certificate_key /etc/ssl/private/example.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    proxy_pass 10.0.0.5:5432;
}

Changes

  • migration 20260617000000-add-transport-service-domain.js: add nullable externalHostname + externalDomainId (FK → ExternalDomains) to TransportServices.
  • model transport-service.js: add the two fields, the externalDomain association, and validation (TLS requires tcp + a domain).
  • api containers.js: serialize tls/externalHostname/externalDomainId/domain; eager-load externalDomain; persist TLS fields on create/update; validate via a new parseTransportTls() helper.
  • template nginx-conf.ejs + templates.js: emit ssl + ssl_certificate/ssl_certificate_key/ssl_protocols for TLS-enabled TCP stream servers (plain TCP and UDP unchanged); eager-load the domain for the config render.
  • client types.ts + ContainerFormPage.tsx: extend ServiceTransport; add the TLS toggle, domain inputs, and read-only external-port display.
  • docs: web-gui, command-line, external-domains, and system-architecture updated.

Incidental fix

The transport port allocator used 65565 as its upper bound, which exceeds the max valid port (65535) and the model's own max: 65535 validator. Corrected both call sites (separate commit).

Commits

  1. Add auto-assigned port display and TLS termination for TCP services
  2. Fix transport port allocation upper bound (65565 -> 65535)
  3. docs: document TCP TLS termination and auto-assigned port display

Testing notes

  • Client tsc -b --noEmit and vite build pass.
  • Backend files pass node --check.
  • Rendered the stream {} block for TLS-TCP / plain-TCP / UDP / TLS-without-domain; output is correct, and the no-domain case safely falls back to plaintext.
  • Unit-checked the model validators and parseTransportTls (TLS-on-UDP and TLS-without-domain rejected; string "true" coercion handled).
  • ⚠️ The DB migration was not run in my environment (the prebuilt sqlite3 binary needs GLIBC_2.38, unavailable here). Please run npx sequelize-cli db:migrate before testing.

Follow-up considerations (not in this PR)

  • Existing TCP services are immutable in the edit form (consistent with current behavior); enabling TLS requires delete + re-add. Could be relaxed later.
  • Certs are still provisioned manually via acme.sh; this reuses the per-domain cert and adds no new cert automation.

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
@runleveldev runleveldev linked an issue Jun 17, 2026 that may be closed by this pull request
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).
@runleveldev runleveldev marked this pull request as ready for review June 17, 2026 14:54
@runleveldev runleveldev requested a review from Copilot June 17, 2026 14:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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.

Comment thread create-a-container/routers/api/v1/containers.js
Comment thread create-a-container/routers/api/v1/containers.js
Comment thread create-a-container/routers/api/v1/containers.js
@runleveldev runleveldev linked an issue Jun 17, 2026 that may be closed by this pull request
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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.

Comment on lines +162 to +172
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;
}
Comment on lines +469 to +472
# 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable TLS for TCP services [Bug]: No way to find non-ssh TCP port

2 participants