feat(resources): route TCP services by SNI #18

Merged
ryan.mcgee merged 42 commits from feat/tcp-sni into main 2026-10-15 18:08:45 +00:00
Owner

Summary

  • Several TCP services can share one port, routed by the TLS server name (SNI) the client asks for. Second of four roadmap items (next: domain ownership and wildcard certificates, country and ASN rules).
  • Stacked on #17 (feat/email-otp).

Changes

  • A TCP resource can be routed by SNI (sni_routing, needs a hostname). Its router is HostSNI(name) with tls.passthrough: the TLS connection reaches the target unchanged, so the target terminates TLS (Traefik Control does not terminate TLS for raw resources).
  • Any number of such resources share a port; the name is unique per port. One resource that is not routed by SNI can remain as the port's default. Partial unique indexes enforce both (portable on SQLite and PostgreSQL); the renderer applies the same rule per node (lowest id wins a contested slot) and never puts a name that is not a plain hostname into a rule.
  • Found with a real Traefik: a plain catch-all HostSNI(*) does not take TLS connections once a port has SNI routers (Traefik answers them with its own default certificate). The default resource of such a port therefore also gets a TLS-passthrough catch-all, so unclaimed names and clients without SNI reach it.
  • Form checkbox (TCP only), API field sni_routing (OpenAPI document), blueprints (sni_routing: true; matched by port and hostname; export and round trip covered). The node page lists a shared port's entrypoint once.
  • README updated.

Verification

  • mix format --check-formatted, mix compile --warnings-as-errors, mix credo --strict, mix dialyzer: clean.
  • mix test: 1406 passed on SQLite and PostgreSQL.
  • Migration round trip with existing TCP and HTTP resources on both backends; the new indexes allow shared ports and refuse a repeated name or a second default; rollback and re-apply.
  • Smoke test with Traefik latest and three TLS backends with different certificates, resources created through the context: a.example.com, b.example.com and an unclaimed name each reached their own backend (certificate and greeting checked with openssl s_client), as did a client that sends no name.

Notes

  • Rolling the migration back re-creates the unconditional port index and fails while resources routed by SNI share a port (documented in the migration).
  • Plain (non-TLS) TCP to a shared port goes to the default resource; that path is the unchanged catch-all and was not exercised against a real client in this smoke test.
## Summary - Several TCP services can share one port, routed by the TLS server name (SNI) the client asks for. Second of four roadmap items (next: domain ownership and wildcard certificates, country and ASN rules). - Stacked on #17 (`feat/email-otp`). ## Changes - A TCP resource can be routed by SNI (`sni_routing`, needs a hostname). Its router is `HostSNI(`name`)` with `tls.passthrough`: the TLS connection reaches the target unchanged, so the target terminates TLS (Traefik Control does not terminate TLS for raw resources). - Any number of such resources share a port; the name is unique per port. One resource that is not routed by SNI can remain as the port's default. Partial unique indexes enforce both (portable on SQLite and PostgreSQL); the renderer applies the same rule per node (lowest id wins a contested slot) and never puts a name that is not a plain hostname into a rule. - Found with a real Traefik: a plain catch-all `HostSNI(`*`)` does not take TLS connections once a port has SNI routers (Traefik answers them with its own default certificate). The default resource of such a port therefore also gets a TLS-passthrough catch-all, so unclaimed names and clients without SNI reach it. - Form checkbox (TCP only), API field `sni_routing` (OpenAPI document), blueprints (`sni_routing: true`; matched by port and hostname; export and round trip covered). The node page lists a shared port's entrypoint once. - README updated. ## Verification - `mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict`, `mix dialyzer`: clean. - `mix test`: 1406 passed on SQLite and PostgreSQL. - Migration round trip with existing TCP and HTTP resources on both backends; the new indexes allow shared ports and refuse a repeated name or a second default; rollback and re-apply. - Smoke test with Traefik `latest` and three TLS backends with different certificates, resources created through the context: `a.example.com`, `b.example.com` and an unclaimed name each reached their own backend (certificate and greeting checked with `openssl s_client`), as did a client that sends no name. ## Notes - Rolling the migration back re-creates the unconditional port index and fails while resources routed by SNI share a port (documented in the migration). - Plain (non-TLS) TCP to a shared port goes to the default resource; that path is the unchanged catch-all and was not exercised against a real client in this smoke test.
- TCP resources can share a port, each receiving the TLS connections
  whose server name is its hostname (HostSNI rule, TLS passthrough)
- only resources not routed by SNI own a port; one may stay as the
  default for plain TCP, unclaimed names and clients without a name
  (given a TLS passthrough catch-all, since Traefik would otherwise
  answer those itself)
- partial unique indexes: one default per port, one resource per name
- form option, API (sni_routing) and blueprints (matched by port and
  hostname); the node page lists each port's entrypoint once
ryan.mcgee changed target branch from feat/email-otp to main 2026-10-15 18:08:39 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
White_Ghost_Cyber_Systems/traefik-control!18
No description provided.