local-development-gateway 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad3ccae73de13d5b8f94f5745116e2c87cb323421b0df17efa76376648c5c99f
4
- data.tar.gz: 628768727722325fcb3195d849de79e4c28e62f903618aaa50caf5b40e128500
3
+ metadata.gz: 31c635514c35283b8ebab124d6a9f9ac8e17e8de5faaeb8573b0c73153b3efd4
4
+ data.tar.gz: 946d3a12335cff8a00f10a5ac330de37b5f5ec509a33779e44cefc265b8c0554
5
5
  SHA512:
6
- metadata.gz: 29d3dc511c560f8df87b3cb960098084ba177d8b366bfb4cbea142dcaa59433431f9c9add4d823512440729150aec7442c4f1b9372e5ba5a77af1303b46598ac
7
- data.tar.gz: 2e808de3a602575bc91b2585372f240de10ee46f90ec562d3e49619cd8153e5eac463b55697a9cad43558e149426d95ab870a8816e083f321e97944cdcb2d7e6
6
+ metadata.gz: 04b07e9aeb612b723744759bebb4f8e19319ac885a9dff3163446eaebcaf9a424cb203c36c89649a2378d1db675c5a167aade5bc0f33e6c82ccb70c11d7bcec6
7
+ data.tar.gz: 28528704687762d7a053b62f7c1fc1e7f5af76700e4c332efadaef32cb5c594e420af8bbd53803a1e3d0cecdafa3d892ceeee5edbac100aa388ea72e98a80de1
data/README.md CHANGED
@@ -112,19 +112,102 @@ The gateway includes protocol drivers for encrypted SQL Server and PostgreSQL
112
112
  connections. No host agent, DNS server, administrator privileges, or
113
113
  `/etc/hosts` changes are required.
114
114
 
115
- A participating project only joins its existing database service to
116
- `local-gateway` and adds three labels:
115
+ ### Three connection paths
116
+
117
+ | Caller | Database hostname | Routing |
118
+ | --- | --- | --- |
119
+ | Application/API, background job, test container | `${COMPOSE_PROJECT_NAME}-<database-service>` | Qualified alias on that checkout's private `default` network; internal database port |
120
+ | Desktop client, such as DBeaver | `db.<worktree>.<project>.localhost` | Encrypted, hostname-labelled gateway endpoint on loopback |
121
+ | Database-local console, backup, clone command | `localhost` | Explicitly select the intended database container first |
122
+
123
+ Do not use bare `db` or `db_trav_com` for application connections in the current
124
+ gateway topology. An API attached to both `default` and `local-gateway` can see
125
+ other projects' database service aliases on the shared network. Docker DNS or
126
+ network ordering cannot reliably choose its own database. Separate database
127
+ volumes do not prevent connecting to the wrong server.
128
+
129
+ The qualified alias belongs **only on the private `default` network**. Compose
130
+ project names must be distinct for concurrent checkouts. `.localhost` desktop
131
+ names resolve to loopback, not to an internal database container; they are not
132
+ substitutes for private aliases. Database cloning is independent of this
133
+ hostname correction: it selects source/destination containers explicitly and
134
+ connects locally.
135
+
136
+ ### Canonical development and test Compose pattern
137
+
138
+ Merge these entries into the project's existing development base and gateway
139
+ overlay. Keep its database images, credentials, volumes, and application
140
+ commands. The base must not publish database host ports. Use the driver's
141
+ existing configuration keys: `AZURE_SQL_HOST` is a SQL Server application
142
+ example; `PGHOST` is the PostgreSQL/libpq equivalent. An application with a
143
+ different PostgreSQL configuration key must set that key instead.
144
+
145
+ For example, a wrapper-derived `COMPOSE_PROJECT_NAME=issue-123-example` gives
146
+ private hosts `issue-123-example-db` and `issue-123-example-db_trav_com`.
147
+ `GATEWAY_HOSTNAME=issue-123.example.localhost` separately identifies desktop
148
+ routes. This requires no new Ruby API, generic database environment variable,
149
+ or consumer runtime.
117
150
 
118
151
  ```yaml
152
+ # docker-compose.development.yml: also safe without the gateway overlay.
119
153
  services:
154
+ api:
155
+ environment:
156
+ AZURE_SQL_HOST: "${COMPOSE_PROJECT_NAME}-db"
157
+ PGHOST: "${COMPOSE_PROJECT_NAME}-db_trav_com"
158
+ jobs:
159
+ environment:
160
+ AZURE_SQL_HOST: "${COMPOSE_PROJECT_NAME}-db"
161
+ PGHOST: "${COMPOSE_PROJECT_NAME}-db_trav_com"
162
+ test_api:
163
+ environment:
164
+ AZURE_SQL_HOST: "${COMPOSE_PROJECT_NAME}-db"
165
+ PGHOST: "${COMPOSE_PROJECT_NAME}-db_trav_com"
120
166
  db:
167
+ networks:
168
+ default:
169
+ aliases:
170
+ - "${COMPOSE_PROJECT_NAME}-db"
171
+ db_trav_com:
172
+ networks:
173
+ default:
174
+ aliases:
175
+ - "${COMPOSE_PROJECT_NAME}-db_trav_com"
176
+ ```
177
+
178
+ The example's driver assignments are illustrative. In
179
+ [Travel Authorization's overlay](https://github.com/icefoganalytics/travel-authorization/blob/main/docker-compose.development.gateway.yml),
180
+ `db` is PostgreSQL and `db_trav_com` is SQL Server: its PostgreSQL host setting
181
+ must use `${COMPOSE_PROJECT_NAME}-db`, and its SQL Server host setting must use
182
+ `${COMPOSE_PROJECT_NAME}-db_trav_com`, with labels/ports matching those drivers.
183
+ Apply the rule separately to **every** database service, even when two services
184
+ use the same driver. Preserve each context's existing credentials, internal
185
+ port, and database name; tests must still select the test database rather than
186
+ development data.
187
+
188
+ ```yaml
189
+ # docker-compose.development.gateway.yml
190
+ services:
191
+ api:
121
192
  networks:
122
193
  - default
123
194
  - local-gateway
195
+ db:
196
+ networks:
197
+ default: {}
198
+ local-gateway: {}
124
199
  labels:
125
200
  - "local-gateway.tcp.driver=sql_server"
126
- - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME:-wrap.localhost}"
201
+ - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME}"
127
202
  - "local-gateway.tcp.port=1433"
203
+ db_trav_com:
204
+ networks:
205
+ default: {}
206
+ local-gateway: {}
207
+ labels:
208
+ - "local-gateway.tcp.driver=postgresql"
209
+ - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME}"
210
+ - "local-gateway.tcp.port=5432"
128
211
 
129
212
  networks:
130
213
  local-gateway:
@@ -132,16 +215,64 @@ networks:
132
215
  name: local-gateway
133
216
  ```
134
217
 
135
- For a worktree whose `GATEWAY_HOSTNAME` is `issue-567.wrap.localhost`, DBeaver
136
- connects to `db.issue-567.wrap.localhost` on the driver's standard port:
218
+ Use **mapping-form** database networks in both files. Compose merges
219
+ `default: {}` with the base's `default.aliases`; replacing that mapping with a
220
+ network list can lose the private alias. Do not add qualified aliases to
221
+ `local-gateway`. Jobs/tests need only `default` unless they themselves expose a
222
+ routed service; qualified database names remain safe if an existing overlay
223
+ also attaches them to the shared network.
224
+
225
+ The shared router owns `127.0.0.1:1433` and `127.0.0.1:5432`. No participating
226
+ database publishes either port, so multiple development/test stacks can start
227
+ concurrently. Internal connections use each server's internal port and existing
228
+ encryption policy; they do not pass through the router or require a published
229
+ port.
230
+
231
+ ### Explicit non-gateway fallback
232
+
233
+ Container-only development/tests work with the base alone. If desktop access
234
+ without the gateway is required, select a separate, project-local publishing
235
+ overlay **instead of** the gateway overlay:
236
+
237
+ ```yaml
238
+ # docker-compose.development.ports.yml: opt-in, non-gateway mode only.
239
+ services:
240
+ db:
241
+ ports:
242
+ - "127.0.0.1:1433:1433"
243
+ db_trav_com:
244
+ ports:
245
+ - "127.0.0.1:5432:5432"
246
+ ```
247
+
248
+ These fallback ports must be free; this mode does not promise parallel desktop
249
+ access. Do not combine it with the gateway or leave these publications in the
250
+ base/test files. When migrating a project that retains base publications, its
251
+ gateway overlay must explicitly clear each database's ports with
252
+ `ports: !reset []` (Docker Compose 2.24.4 or newer); simply omitting `ports` or
253
+ setting `ports: []` does not remove inherited publications. Prefer the
254
+ port-free base plus explicit fallback above.
255
+
256
+ Use the project's documented development wrapper to select Compose modes and
257
+ start/test/stop services. Inspect its rendered `config`: gateway mode must have
258
+ no database host publications and must retain the private aliases. A base-only
259
+ test wrapper must use the same qualified hosts.
260
+
261
+ ### Desktop database clients
262
+
263
+ For `GATEWAY_HOSTNAME=issue-123.example.localhost`, connect to
264
+ `db.issue-123.example.localhost` on the driver's standard port:
137
265
 
138
266
  | Driver label | Host port | Client requirement |
139
267
  | --- | ---: | --- |
140
268
  | `sql_server` | `1433` | Encryption enabled and **Trust server certificate** selected |
141
- | `postgresql` | `5432` | SSL mode `require` |
269
+ | `postgresql` | `5432` | SSL mode `require`, SNI enabled, development certificate accepted |
142
270
 
143
271
  Use the database container's actual internal port in
144
- `local-gateway.tcp.port`; it does not need to match the host listener.
272
+ `local-gateway.tcp.port`; it need not match the host listener. Different drivers
273
+ can share a hostname because their listeners differ. Two databases using the
274
+ same driver need distinct labels, for example `db.${GATEWAY_HOSTNAME}` and
275
+ `db_trav_com.${GATEWAY_HOSTNAME}`.
145
276
 
146
277
  All `.localhost` names resolve to loopback. The gateway publishes loopback-only
147
278
  listeners, discovers labelled containers through Docker, and uses the database
@@ -149,12 +280,38 @@ protocol's encrypted handshake to select the hostname-labelled container.
149
280
  Container replacements and stopped routes require no consumer lifecycle code
150
281
  or cleanup.
151
282
 
152
- SQL Server encryption remains end to end through the router. The PostgreSQL
153
- driver terminates client TLS at the gateway and forwards the resulting
154
- PostgreSQL stream over the private Docker network because PostgreSQL sends its
155
- TLS hostname only after the gateway accepts its SSL request. See
283
+ SQL Server encryption remains end to end through the router. PostgreSQL TLS
284
+ terminates at the gateway; the backend stream is plaintext on the private Docker
285
+ network. New PostgreSQL sessions require TLS and SNI, but query cancellation
286
+ does not: the gateway substitutes a session-specific cancellation identity and
287
+ routes ordinary `psql` and JDBC cancellation packets to that session's backend.
288
+ Closed sessions lose their cancellation mapping. See
156
289
  [`docs/architecture/local-tcp-routing.md`](docs/architecture/local-tcp-routing.md)
157
- for the complete contract and security boundary.
290
+ for the complete contract and security boundary, including the reason for
291
+ retaining cancellation-aware PostgreSQL routing despite Traefik's STARTTLS support.
292
+
293
+ ### Downstream adoption checklist
294
+
295
+ 1. Give each concurrent checkout a distinct Compose project name and desktop
296
+ route hostname. Qualify every API/job/test database host with
297
+ `${COMPOSE_PROJECT_NAME}-<database-service>` using its existing driver setting.
298
+ 2. Advertise each qualified alias only on its private `default` network;
299
+ preserve it through mapping-form network merges in base-only and gateway modes.
300
+ 3. Remove fixed database publications from development/test stacks; retain
301
+ desktop fallback publications only in an explicitly selected non-gateway file.
302
+ 4. Recreate database and consumer services so changed aliases and environment
303
+ settings take effect. Inspect both rendered Compose configurations.
304
+ 5. Query real, read-only server/database identity from API, job, and test
305
+ contexts and compare it with the explicitly selected local database
306
+ container. Stop that checkout's database while another is available: its
307
+ consumers must fail, not select the other checkout. Record the actual DNS or
308
+ connection error; there is no universal platform error code.
309
+ 6. Verify both encrypted desktop routes, and restore normal gateway attachments
310
+ after test wrappers recreate services using base-only Compose.
311
+
312
+ Shared development networks are not a hostile-container security boundary.
313
+ Qualified private aliases prevent accidental cross-stack selection; they do not
314
+ prevent an attached, malicious container from connecting to another database.
158
315
 
159
316
  ## Development checks
160
317
 
@@ -169,6 +326,7 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
169
326
  lib/local_development_gateway/database_router.rb \
170
327
  lib/local_development_gateway/database_router/*.rb \
171
328
  lib/local_development_gateway/database_router/drivers/*.rb \
329
+ lib/local_development_gateway/database_router/postgre_sql/*.rb \
172
330
  lib/local_development_gateway/database_router/tds/*.rb \
173
331
  lib/local_development_gateway/version.rb \
174
332
  bin/dev \
@@ -176,6 +334,7 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
176
334
  test/*.rb \
177
335
  test/database_router/*.rb \
178
336
  test/database_router/drivers/*.rb \
337
+ test/database_router/postgre_sql/*.rb \
179
338
  test/database_router/tds/*.rb
180
339
  rake test
181
340
  ```
@@ -195,8 +354,8 @@ The gateway owns the single browser port on `127.0.0.1:80`. Participating projec
195
354
 
196
355
  The gateway:
197
356
 
198
- - listens only on `127.0.0.1:80`;
199
- - creates the external Docker network `local-gateway`;
357
+ - listens only on `127.0.0.1:80`, `127.0.0.1:1433`, and `127.0.0.1:5432`;
358
+ - creates the external Docker network `local-gateway` for HTTP and labelled database ingress;
200
359
  - discovers only services explicitly labelled for routing;
201
360
  - has the Docker label `local-gateway=true` so a project can detect it.
202
361
 
@@ -225,6 +384,14 @@ Use a project- and service-prefixed router/service identifier. Never derive a de
225
384
 
226
385
  Without the gateway, a participating project may use its own loopback port-publishing fallback. That fallback is project-specific and is not part of the shared gateway contract.
227
386
 
387
+ Application/job/test database connections follow the
388
+ [private-alias contract](#three-connection-paths), not desktop gateway hostnames
389
+ or bare database service names. Database services retain their private network
390
+ and add gateway labels without publishing host ports. The
391
+ [architecture decision](docs/architecture/local-tcp-routing.md#internal-database-isolation-decision)
392
+ explains why a future HTTP/database ingress split requires coordinated consumer
393
+ migration rather than a transparent gem update.
394
+
228
395
  ## Authentication callbacks
229
396
 
230
397
  A wildcard hostname lets an identity provider allow one local callback pattern instead of every generated port. For Traditional Knowledge, configure:
@@ -251,3 +418,29 @@ It mounts the Docker socket so it can discover labelled containers. A read-only
251
418
  | Generated hostname returns `404` | Confirm the web container has the route labels and is attached to `local-gateway`. |
252
419
  | A project uses normal ports unexpectedly | Confirm the gateway container is running and has `local-gateway=true`. |
253
420
  | Authentication rejects the callback | Confirm the generated hostname matches the allowed wildcard callback pattern. |
421
+
422
+ ## Release 0.2.2
423
+
424
+ - Canonical SQL Server/PostgreSQL Compose integration supports concurrent
425
+ development/test stacks without per-stack database host publications.
426
+ - Application, job, and test connections use checkout-qualified private aliases
427
+ instead of ambiguous shared-network service names. Base-only mode, explicit
428
+ non-gateway fallback, multiple databases, and adoption checks are documented.
429
+ - The packaged architecture reference records the current private-alias
430
+ decision, real-container Linux validation, and coordinated migration required
431
+ for a future HTTP/database ingress split.
432
+ - Gateway routing and the public Ruby API are unchanged. Consumers must adopt
433
+ the Compose contract; upgrading the gem does not rewrite their configuration.
434
+
435
+ ## Release 0.2.1
436
+
437
+ - PostgreSQL query cancellation works across concurrent worktrees, including
438
+ plaintext cancellation from `psql` and JDBC and encrypted cancellation.
439
+ Session-specific identities prevent backend key collisions and expire when
440
+ their session closes.
441
+ - Invalid database labels and duplicate hostname identities reject only the
442
+ affected routes, leaving unrelated worktrees and database drivers available.
443
+ - SQL Server negotiation can fall back from a stalled backend within the
444
+ shared handshake deadline.
445
+ - PostgreSQL STARTTLS support in Traefik is documented accurately. The gateway
446
+ retains cancellation-aware session routing without changing consumer labels.
@@ -0,0 +1,356 @@
1
+ # Local TCP Routing Architecture
2
+
3
+ Status: SQL Server and PostgreSQL protocol routing implemented in PR #22 and
4
+ validated on Linux. Windows Docker Desktop validation remains outstanding.
5
+
6
+ ## Request
7
+
8
+ Extend Local Development Gateway so a participating Docker Compose project can
9
+ expose hostname-routed database services with the same simple declarative
10
+ integration it already uses for HTTP services.
11
+
12
+ For WRAP, database routing should require only a small change to
13
+ `docker-compose.development.gateway.yml`. WRAP must not implement address
14
+ allocation, routing, DNS, host-file management, startup orchestration, or
15
+ cleanup.
16
+
17
+ The initial endpoints are:
18
+
19
+ ```text
20
+ db.<worktree>.wrap.localhost:1433 # SQL Server
21
+ db.<worktree>.wrap.localhost:5432 # PostgreSQL
22
+ ```
23
+
24
+ Each must connect from DBeaver to that worktree's labelled database container.
25
+
26
+ ## Consumer Contract
27
+
28
+ A participating project keeps its databases on its private `default` network
29
+ and adds the existing `local-gateway` network plus three gateway-owned labels:
30
+ driver, desktop hostname, and internal port. The copyable SQL Server and
31
+ PostgreSQL base/overlay examples live in the README's
32
+ [canonical Compose pattern](../../README.md#canonical-development-and-test-compose-pattern).
33
+
34
+ | Connection path | Destination selection |
35
+ | --- | --- |
36
+ | API, background job, test | `${COMPOSE_PROJECT_NAME}-<database-service>` on private Docker DNS, using the driver's existing host setting and internal port |
37
+ | Desktop database client | Encrypted `db.<worktree>.<project>.localhost` endpoint; driver/hostname labels select the backend |
38
+ | Database-local console, backup, clone | Explicitly selected container, then `localhost`; not gateway routing |
39
+
40
+ Each concurrent checkout has a distinct Compose project name. Each database
41
+ advertises its qualified alias only on that project's `default` network.
42
+ Use mapping-form database networks in the base and gateway overlay so Compose
43
+ preserves `default.aliases`. Configure all application/job/test contexts with
44
+ their appropriate aliases, existing credentials, and intended database names;
45
+ test contexts still select their own test database.
46
+
47
+ Gateway-mode development and test stacks publish no database host ports.
48
+ Container consumers work with the base alone. Desktop access without the
49
+ gateway is an explicit project-local port-publishing overlay, not an inherited
50
+ base default and not a parallel-stack guarantee. The README covers migration
51
+ of inherited port publications, fallback selection, and adoption checks.
52
+
53
+ Internal database hostnames and desktop `.localhost` hostnames are separate
54
+ contracts. Internal connections need neither a published host port nor the
55
+ gateway's encrypted-handshake protocol. Database cloning does not depend on
56
+ the internal hostname correction.
57
+
58
+ Requirements:
59
+
60
+ - No consumer Ruby API calls.
61
+ - No consumer-side address allocator.
62
+ - No per-worktree host-port selection.
63
+ - No bind-address environment variable passed from application code.
64
+ - No consumer startup or shutdown hook solely for gateway routing.
65
+ - No consumer cleanup call.
66
+ - No manually maintained DBeaver port per worktree.
67
+ - The consumer supplies only declarative service identity and internal-port metadata.
68
+
69
+ - No administrator or root installation.
70
+ - No host `/etc/hosts`, DNS, resolver, systemd, or Windows-service changes.
71
+ - The same gateway Compose stack must run through Docker on Linux and Windows.
72
+
73
+ ## Internal Database Isolation Decision
74
+
75
+ **Decision:** retain the current shared ingress topology and standardize
76
+ checkout-qualified private aliases. This resolves
77
+ [#23](https://github.com/icefoganalytics/local-development-gateway/issues/23)'s
78
+ parallel-stack contract and
79
+ [#26](https://github.com/icefoganalytics/local-development-gateway/issues/26)'s
80
+ internal database selection contract without new runtime machinery or public
81
+ Ruby lifecycle changes.
82
+
83
+ Bare `db` is not safe for an API attached to both its private bridge and
84
+ `local-gateway`: other attached databases advertise the same Compose service
85
+ alias on shared ingress. Docker DNS/network ordering, container IDs, separate
86
+ volumes, and gateway TLS/SNI cannot disambiguate an ordinary internal connection.
87
+ The qualified alias identifies only its own private endpoint. If that endpoint
88
+ is absent, its consumers must fail rather than connect to another checkout.
89
+ Do not advertise the qualified alias on shared ingress.
90
+
91
+ An HTTP/database ingress split is a possible future centralization, not a
92
+ transparent gem-only patch:
93
+
94
+ - HTTP services/APIs: private checkout network plus existing HTTP ingress.
95
+ - Databases: private checkout network plus a distinct gateway-owned database
96
+ ingress network. APIs/jobs/tests must not join database ingress.
97
+ - Traefik: HTTP ingress only. Database router: database ingress only.
98
+ - Route discovery must read backend addresses on database ingress instead of
99
+ `DockerRoutes::NETWORK_NAME`'s current `local-gateway`.
100
+ - Gateway readiness/discovery must check the appropriate networks and services.
101
+ Unused-gateway shutdown must retain running consumers on either ingress,
102
+ including database-only stacks, without counting exited containers.
103
+ - Consumer network membership, installed gem versions, and already-running
104
+ shared gateways require coordinated migration. Old consumers still
105
+ advertising database aliases on HTTP ingress remain ambiguous after a
106
+ gateway-only update.
107
+
108
+ That alternative requires packaged install/readiness, database-only lifecycle,
109
+ multi-project startup/stop/replacement, SQL Server routing, PostgreSQL routing
110
+ and cancellation, mixed-version migration, and unchanged HTTP-routing evidence
111
+ before adoption. No structural split is implemented in this release; downstream
112
+ qualified-alias mitigations must remain until a verified replacement is adopted.
113
+
114
+ These development networks are not hostile-container security boundaries.
115
+ Private qualified aliases prevent accidental selection, not deliberate
116
+ connections by another attached container.
117
+
118
+ ## Gateway Responsibilities
119
+
120
+ All implementation belongs in this repository. The gateway must:
121
+
122
+ 1. Discover participating containers and their routing metadata through Docker.
123
+ 2. Derive or read the requested hostname and target internal port.
124
+ 3. Distinguish concurrent worktrees even when they expose the same internal TCP port.
125
+ 4. Make `db.<worktree>.wrap.localhost` resolve or connect to the correct worktree route on the local machine.
126
+ 5. Forward the raw TCP stream to the labelled container.
127
+ 6. Add, update, and remove routes automatically as containers start, restart, and stop.
128
+ 7. Preserve stable routing for a worktree while it remains active.
129
+ 8. Keep all listeners and generated routes local to the developer machine.
130
+ 9. Preserve the existing HTTP routing behavior.
131
+
132
+ ## Implemented Routing Shape
133
+
134
+ Every `.localhost` name intentionally resolves to loopback on both Linux and
135
+ Windows. A shared port therefore requires a protocol-level hostname. The
136
+ gateway uses one Docker-label discovery path and a small driver for each
137
+ supported database handshake:
138
+
139
+ ```text
140
+ DBeaver
141
+ -> db.<worktree>.wrap.localhost:<driver port>
142
+ -> Docker publishes the database router on loopback
143
+ -> driver reads the encrypted handshake hostname
144
+ -> router selects the matching Docker label
145
+ -> router forwards the database stream to the labelled container
146
+ ```
147
+
148
+ The `sql_server` driver mediates Tabular Data Stream (TDS) PRELOGIN, reads SNI
149
+ from the wrapped TLS ClientHello, then preserves the encrypted client/backend
150
+ stream. The
151
+ `postgresql` driver accepts PostgreSQL's SSLRequest, terminates the client TLS
152
+ session to obtain SNI, and forwards the plaintext PostgreSQL stream on the
153
+ private Docker network.
154
+
155
+ Traefik remains responsible for HTTP. It cannot inspect TLS SNI wrapped inside
156
+ TDS PRELOGIN. It **does** support PostgreSQL SSLRequest/STARTTLS and subsequent
157
+ TLS-SNI routing, as documented in its
158
+ [TCP TLS reference](https://doc.traefik.io/traefik/reference/routing-configuration/tcp/tls/).
159
+
160
+ STARTTLS alone is therefore not a reason to implement PostgreSQL routing here.
161
+ The gateway also owns PostgreSQL query cancellation: `psql` and JDBC can open
162
+ a separate plaintext CancelRequest connection containing only a backend PID and
163
+ secret, with no hostname. A stateless SNI router cannot select that backend.
164
+ Keeping negotiation and cancellation-aware session forwarding together preserves
165
+ the existing three-label consumer contract without per-route internal listeners,
166
+ generated Traefik configuration, or another discovery/control plane.
167
+
168
+ Normal database sessions require encrypted handshakes so the requested hostname
169
+ is present as TLS SNI. PostgreSQL cancellation is a bounded control-message
170
+ exception, not support for plaintext database sessions. The router parses
171
+ PostgreSQL backend messages only until BackendKeyData, replaces that key, then
172
+ forwards the remainder without interpreting query results.
173
+
174
+ ## Code Organization
175
+
176
+ `DatabaseRouter` owns listener concurrency and backend connection lifecycle.
177
+ `Wire` owns bounded handshake I/O and bidirectional forwarding, including
178
+ shutdown that wakes both relay directions. `DockerApi` and `DockerRoutes` form
179
+ the external Docker boundary and produce validated `Route` values.
180
+ Behavior-bearing classes each have one file, and directories map to Ruby
181
+ namespaces: drivers live under `DatabaseRouter::Drivers`, TDS framing and TLS
182
+ parsing under `DatabaseRouter::Tds`, and PostgreSQL session-key forwarding and
183
+ cancellation lookup under `DatabaseRouter::PostgreSql`. Tiny immutable `Route`,
184
+ `Connection`, cancellation destination, and TDS `Packet` records stay with
185
+ their owners.
186
+
187
+ Source and test imports resolve from the gem's `lib` load path and start at
188
+ `local_development_gateway`; domain files never traverse sibling paths with
189
+ `require_relative`.
190
+
191
+ The focused tests mirror those boundaries under `test/database_router/`.
192
+ Protocol scenarios keep their literal setup beside the behavior they verify
193
+ instead of sharing a generic fixture layer.
194
+
195
+ ## Hostname Contract
196
+
197
+ - Keep the established WRAP hostname family.
198
+ - HTTP remains:
199
+ - `<worktree>.wrap.localhost`
200
+ - `api.<worktree>.wrap.localhost`
201
+ - `mail.<worktree>.wrap.localhost`
202
+ - SQL Server is:
203
+ - `db.<worktree>.wrap.localhost`
204
+ - PostgreSQL uses the same hostname on port `5432`.
205
+ - Do not migrate consumers to `.test`, `.alt`, `.local`, `home.arpa`, or an externally registered domain as part of this issue.
206
+ - Do not require host resolver configuration; `.localhost` must retain its native loopback behavior.
207
+
208
+ ## Security Boundary
209
+
210
+ - No routed service may be reachable from another machine.
211
+ - Bind host listeners only to loopback addresses.
212
+ - Do not publish generated names through public DNS.
213
+ - Do not expose SQL Server on `0.0.0.0` or a LAN interface.
214
+ - Do not require privileged host integration.
215
+ - Do not grant a long-running container unrestricted write access to arbitrary host files.
216
+ - The database router receives the same read-only Docker socket already required by Traefik so it can resolve labels to current container addresses.
217
+ - SQL Server remains encrypted end to end. PostgreSQL is plaintext only inside the private Docker network after the gateway terminates client TLS.
218
+
219
+ ## Lifecycle
220
+
221
+ - Normal session selection reads current Docker metadata; starting, restarting,
222
+ or stopping a labelled container requires no stored route configuration or
223
+ consumer cleanup.
224
+ - Containers outside `local-gateway` are excluded before label validation.
225
+ Invalid participating routes are warned about and quarantined individually.
226
+ - Duplicate active driver/hostname identities reject every route for that
227
+ identity, not unrelated identities or database drivers.
228
+ - A connection snapshot never changes backend after selection.
229
+ - PostgreSQL rewrites BackendKeyData with a random, collision-checked virtual
230
+ identity whose PID is positive for older client compatibility. The active
231
+ mapping retains the exact backend address, port, and original cancellation key.
232
+ - Cancellation uses that mapping without Docker discovery or hostname
233
+ negotiation. Unknown and closed-session identities fail closed, and malformed
234
+ cancellation lengths never fall through to normal backend forwarding.
235
+ - Backend EOF, client disconnect, or relay failure removes the session mapping.
236
+ Gateway restart closes sessions and clears their cancellation identities;
237
+ normal route configuration is reconstructed from Docker metadata.
238
+ - Handshakes and active sessions have separate bounded admission pools.
239
+ Cancellation and closed health probes do not consume an active-session slot,
240
+ so cancellation remains available when the normal session limit is reached.
241
+ - SQL Server provisional attempts have a bounded sub-deadline within the shared
242
+ handshake deadline. Connection establishment uses only the remaining budget;
243
+ stalled provisional sockets are closed before a healthy alternative is tried.
244
+
245
+ ## Compatibility
246
+
247
+ - DBeaver connects to `db.<worktree>.wrap.localhost` on `1433` for SQL Server
248
+ or `5432` for PostgreSQL.
249
+ - SQL Server clients must enable encryption and trust the development server
250
+ certificate.
251
+ - PostgreSQL clients must use SSL mode `require`, send SNI, and permit the
252
+ gateway's generated development certificate.
253
+ - Concurrent SQL Server routes must use compatible PRELOGIN encryption
254
+ settings because the client receives a provisional backend's negotiation
255
+ response before SNI identifies the final backend.
256
+ - PostgreSQL backends must accept a plaintext connection from the private
257
+ `local-gateway` Docker network.
258
+ - PostgreSQL cancellation PIDs reported by client APIs are virtual. Use
259
+ `SELECT pg_backend_pid()` when inspecting the physical backend in server views.
260
+ - Existing browser routes through Traefik on `127.0.0.1:80` remain unchanged.
261
+ - Multiple active worktrees can use each database driver's standard port
262
+ simultaneously.
263
+ - Adding another protocol requires another explicit driver; ports are never
264
+ inferred from labels.
265
+
266
+ ## Acceptance Scenarios
267
+
268
+ 1. Start worktree A and worktree B with the same database driver and internal
269
+ port.
270
+ 2. Connect a DBeaver-equivalent client to
271
+ `db.<A>.wrap.localhost:<driver port>`; observe bytes reaching only A.
272
+ 3. Connect to `db.<B>.wrap.localhost:<driver port>`; observe bytes reaching
273
+ only B.
274
+ 4. Repeat the routing check for both `sql_server` and `postgresql`.
275
+ 5. Keep both connections possible concurrently without alternate host ports.
276
+ 6. Recreate A's database container; the same hostname routes to its replacement.
277
+ 7. Stop A; A's route disappears while B remains available.
278
+ 8. Verify existing HTTP hostnames still use Traefik.
279
+ 9. Verify every host listener is loopback-only.
280
+ 10. Demonstrate consumer integration as a Compose-only diff.
281
+
282
+ ## Non-Goals
283
+
284
+ - Changing consumer application code, adding new development wrapper runtimes,
285
+ or automatically mutating consumer networks. Existing Compose connection
286
+ settings and file selection must adopt the documented contract.
287
+ - Requiring consumers to run DNS, a proxy, or host installation.
288
+ - Assigning a different database port to each worktree.
289
+ - Publishing development routes outside the machine.
290
+ - Replacing existing HTTP routing.
291
+ - Building a generic service mesh or full database proxy.
292
+ - Supporting plaintext database sessions, PostgreSQL direct TLS negotiation, or
293
+ database protocols without an implemented driver. Plaintext PostgreSQL
294
+ cancellation control packets are supported.
295
+
296
+ ## Verification Evidence
297
+
298
+ PR #22 demonstrated:
299
+
300
+ 1. Two exact `db.<worktree>.wrap.localhost` names selected different labelled
301
+ containers for SQL Server and PostgreSQL.
302
+ 2. Docker-label discovery updated routes without consumer lifecycle code;
303
+ stopped routes were rejected while surviving routes remained reachable.
304
+ 3. Fragmented and oversized handshakes, stalled clients, duplicate routes,
305
+ unsupported drivers, and unreachable provisional backends failed safely.
306
+ 4. Linux validation required no host resolver, administrator/root, or
307
+ operating-system service setup. Windows Docker Desktop was unavailable for
308
+ equivalent validation.
309
+ 5. Existing HTTP routes remained unchanged and every published listener was
310
+ loopback-only.
311
+
312
+ Issue #24's corrective release additionally demonstrated:
313
+
314
+ 1. PostgreSQL 14.4 `psql` cancellation stopped A's query while B stayed active.
315
+ 2. DBeaver's Java runtime with PostgreSQL JDBC 42.7.13 and 42.2.27 returned
316
+ SQLSTATE `57014` for A while B completed its query normally.
317
+ 3. Two SQL Server 2022 backends remained reachable with encrypted `sqlcmd`
318
+ connections despite a first labelled backend that accepted TCP but never
319
+ answered PRELOGIN.
320
+ 4. Focused regressions cover invalid-route isolation, cancellation identity
321
+ collisions after PID normalization, variable backend keys, session cleanup,
322
+ malformed cancellation packets, and cancellation at full session capacity.
323
+
324
+ Issues #23 and #26's documentation release demonstrated on Linux with Docker
325
+ Compose 5.6.0:
326
+
327
+ 1. Two isolated Compose projects, each with SQL Server 2022 `db` and PostgreSQL
328
+ 17 `db_trav_com`, started concurrently without database host publications.
329
+ The repository's `bin/dev` Compose passthrough selected the temporary
330
+ consumer fixtures; the existing shared gateway was reused.
331
+ 2. Real SQL/PG client containers representing API, job, and test contexts joined
332
+ both private and shared networks. `getent hosts` selected only their private
333
+ qualified aliases. Read-only SQL Server `@@SERVERNAME` / `DB_NAME()` and
334
+ PostgreSQL `cluster_name` / `current_database()` / `inet_server_addr()`
335
+ identified their own servers. Test contexts selected `qa_test`; explicit
336
+ database-container `localhost` queries matched the consumer identities.
337
+ 3. Stopping A's two databases made every A context fail while B remained
338
+ usable. `getent hosts` returned exit 1; SQL Server reported login timeout,
339
+ TCP Provider `0x2AFA`, and server-not-found/not-accessible; PostgreSQL reported
340
+ `could not translate host name ... to address: Try again`. These are observed
341
+ platform errors, not required portable error codes.
342
+ 4. Both projects were recreated using base-only Compose. All consumer identity
343
+ checks passed with private-only attachments. Recreating with the gateway
344
+ overlay restored shared attachments and preserved private aliases and test
345
+ database selection.
346
+ 5. Desktop-equivalent host-network clients selected both labelled worktrees:
347
+ `sqlcmd` 18.6 with `-Nm -C` (mandatory encryption, trusted development
348
+ certificate), and PostgreSQL 17 `psql` with `sslmode=require`, SNI, hostname,
349
+ and explicit loopback `hostaddr`. SQL Server strict/direct TLS is outside the
350
+ supported TDS PRELOGIN contract.
351
+ 6. Docker inspection confirmed the shared gateway publishes only
352
+ `127.0.0.1:80`, `127.0.0.1:1433`, and `127.0.0.1:5432`.
353
+
354
+ This validates real database client contexts, not an automatic downstream
355
+ application migration. Consumers still need to adopt the README checklist.
356
+ Windows Docker Desktop validation remains outstanding.
@@ -18,18 +18,35 @@ module LocalDevelopmentGateway
18
18
  routes =
19
19
  @client
20
20
  .get("/containers/json")
21
- .filter_map { |container| route(container) }
22
- identities = routes.map { |route| [route.driver, route.hostname] }
23
- unless identities.uniq.length == identities.length
24
- raise Error, "Duplicate labelled database hostname"
21
+ .filter_map do |container|
22
+ begin
23
+ route(container)
24
+ rescue Error => error
25
+ warn error.message
26
+ nil
27
+ end
28
+ end
29
+ identities = routes.group_by { |route| [route.driver, route.hostname] }
30
+ identities.each do |identity, matches|
31
+ next if matches.length == 1
32
+
33
+ warn "Duplicate labelled database hostname: #{identity.join(" ")}"
25
34
  end
26
35
 
27
- routes.sort_by { |route| [route.driver, route.hostname] }
36
+ routes
37
+ .reject do |route|
38
+ identities.fetch([route.driver, route.hostname]).length > 1
39
+ end
40
+ .sort_by { |route| [route.driver, route.hostname] }
28
41
  end
29
42
 
30
43
  private
31
44
 
32
45
  def route(container)
46
+ target_address =
47
+ container.dig("NetworkSettings", "Networks", NETWORK_NAME, "IPAddress")
48
+ return if target_address.nil? || target_address.empty?
49
+
33
50
  labels = container.fetch("Labels")
34
51
  driver = labels[DRIVER_LABEL]
35
52
  hostname = labels[HOSTNAME_LABEL]
@@ -52,16 +69,14 @@ module LocalDevelopmentGateway
52
69
  raise Error, "Invalid labelled database port: #{port}"
53
70
  end
54
71
 
55
- target_address =
56
- container.dig("NetworkSettings", "Networks", NETWORK_NAME, "IPAddress")
57
- return if target_address.nil? || target_address.empty?
58
-
59
72
  DatabaseRouter::Route.new(
60
73
  driver: driver,
61
74
  hostname: hostname,
62
75
  port: port,
63
76
  target_address: target_address
64
77
  )
78
+ rescue KeyError
79
+ raise Error, "Incomplete labelled database route"
65
80
  rescue ArgumentError, TypeError
66
81
  raise Error, "Invalid labelled database port: #{port}"
67
82
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openssl"
4
+
4
5
  require "local_development_gateway/database_router/wire"
5
6
  require "local_development_gateway/database_router/drivers/postgre_sql_certificate"
7
+ require "local_development_gateway/database_router/postgre_sql/session"
6
8
 
7
9
  module LocalDevelopmentGateway
8
10
  module DatabaseRouter::Drivers
@@ -10,9 +12,14 @@ module LocalDevelopmentGateway
10
12
  NAME = "postgresql"
11
13
  LISTEN_PORT = 5432
12
14
  SSL_REQUEST = [8, 80_877_103].pack("NN").freeze
15
+ CANCEL_REQUEST = [16, 80_877_102].pack("NN").freeze
13
16
 
14
- def initialize(certificate: PostgreSqlCertificate.new)
17
+ def initialize(
18
+ certificate: PostgreSqlCertificate.new,
19
+ cancellations: DatabaseRouter::PostgreSql::CancellationRoutes.new
20
+ )
15
21
  @certificate = certificate
22
+ @cancellations = cancellations
16
23
  end
17
24
 
18
25
  def name
@@ -23,18 +30,15 @@ module LocalDevelopmentGateway
23
30
  LISTEN_PORT
24
31
  end
25
32
 
26
- def connect(client, routes, connector:)
33
+ def connect(client, routes:, connector:)
27
34
  deadline = DatabaseRouter::Wire.deadline
28
35
  request =
29
- DatabaseRouter::Wire.read_exactly(
30
- client,
31
- SSL_REQUEST.bytesize,
32
- deadline: deadline
33
- )
34
- raise Error, "PostgreSQL SSL is required" unless request == SSL_REQUEST
35
- if routes.empty?
36
- raise Error, "No labelled postgresql routes are available"
36
+ DatabaseRouter::Wire.read_exactly(client, 8, deadline: deadline)
37
+ if cancel_request?(request)
38
+ cancel(client, connector, deadline)
39
+ return
37
40
  end
41
+ raise Error, "PostgreSQL SSL is required" unless request == SSL_REQUEST
38
42
 
39
43
  client.write("S")
40
44
  hostname = nil
@@ -44,16 +48,78 @@ module LocalDevelopmentGateway
44
48
  nil
45
49
  end
46
50
  tls = OpenSSL::SSL::SSLSocket.new(client, context)
47
- tls.sync_close = false
51
+ tls.sync_close = true
48
52
  DatabaseRouter::Wire.accept_tls(tls, deadline: deadline)
53
+
54
+ startup = DatabaseRouter::Wire.read_exactly(tls, 8, deadline: deadline)
55
+ if cancel_request?(startup)
56
+ cancel(tls, connector, deadline)
57
+ tls.close
58
+ return
59
+ end
60
+
61
+ length, version = startup.unpack("NN")
62
+ unless length >= 9 && (version >> 16) == 3
63
+ raise Error, "Unsupported PostgreSQL startup request"
64
+ end
65
+
49
66
  unless hostname
50
67
  raise Error, "PostgreSQL TLS ClientHello does not contain SNI"
51
68
  end
52
69
 
53
- selected = routes.find { |route| route.hostname == hostname }
70
+ selected = routes.call.find { |route| route.hostname == hostname }
54
71
  raise Error, "No PostgreSQL route for #{hostname}" unless selected
55
72
 
56
- [tls, connector.call(selected)]
73
+ target = connector.call(selected, deadline: deadline)
74
+ target.write(startup)
75
+ DatabaseRouter::Connection.new(
76
+ source: tls,
77
+ target: target,
78
+ route: selected
79
+ )
80
+ rescue StandardError
81
+ tls&.close
82
+ target&.close
83
+ raise
84
+ end
85
+
86
+ def forward(connection)
87
+ session = DatabaseRouter::PostgreSql::Session.new(@cancellations)
88
+ DatabaseRouter::Wire.proxy(
89
+ connection.source,
90
+ connection.target
91
+ ) do |backend, client|
92
+ session.forward_response(backend, client, connection.route)
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def cancel_request?(header)
99
+ return false unless header.byteslice(4, 4).unpack1("N") == 80_877_102
100
+ unless header == CANCEL_REQUEST
101
+ raise Error, "Invalid PostgreSQL cancellation length"
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ def cancel(client, connector, deadline)
108
+ identity =
109
+ DatabaseRouter::Wire.read_exactly(client, 8, deadline: deadline)
110
+ destination = @cancellations.resolve(identity)
111
+ return unless destination
112
+
113
+ target = connector.call(destination.route, deadline: deadline)
114
+ packet = [destination.backend_key.bytesize + 8, 80_877_102].pack("NN")
115
+ target.write(packet + destination.backend_key)
116
+ DatabaseRouter::Wire.read_until_eof(
117
+ target,
118
+ max_bytes: 0,
119
+ deadline: deadline
120
+ )
121
+ ensure
122
+ target&.close
57
123
  end
58
124
  end
59
125
  end
@@ -6,6 +6,8 @@ require "local_development_gateway/database_router/tds/tls_client_hello"
6
6
  module LocalDevelopmentGateway
7
7
  module DatabaseRouter::Drivers
8
8
  class SqlServerDriver
9
+ NEGOTIATION_ATTEMPT_TIMEOUT = 1
10
+
9
11
  NAME = "sql_server"
10
12
  LISTEN_PORT = 1433
11
13
 
@@ -17,9 +19,10 @@ module LocalDevelopmentGateway
17
19
  LISTEN_PORT
18
20
  end
19
21
 
20
- def connect(client, routes, connector:)
22
+ def connect(client, routes:, connector:)
21
23
  deadline = DatabaseRouter::Wire.deadline
22
24
  prelogin = DatabaseRouter::Tds::Message.read(client, deadline: deadline)
25
+ routes = routes.call
23
26
  if routes.empty?
24
27
  raise Error, "No labelled sql_server routes are available"
25
28
  end
@@ -33,7 +36,7 @@ module LocalDevelopmentGateway
33
36
 
34
37
  if selected != provisional
35
38
  target.close
36
- target = connector.call(selected)
39
+ target = connector.call(selected, deadline: deadline)
37
40
  DatabaseRouter::Tds::Message.write(target, prelogin)
38
41
  DatabaseRouter::Tds::Message.read(target, deadline: deadline)
39
42
  end
@@ -41,22 +44,41 @@ module LocalDevelopmentGateway
41
44
  messages.each do |message|
42
45
  DatabaseRouter::Tds::Message.write(target, message)
43
46
  end
44
- [client, target]
47
+ DatabaseRouter::Connection.new(
48
+ source: client,
49
+ target: target,
50
+ route: selected
51
+ )
45
52
  rescue StandardError
46
53
  target&.close
47
54
  raise
48
55
  end
49
56
 
57
+ def forward(connection)
58
+ DatabaseRouter::Wire.proxy(connection.source, connection.target)
59
+ end
60
+
50
61
  private
51
62
 
52
63
  def negotiate(routes, prelogin, connector, deadline)
53
64
  routes.each do |route|
54
- target = connector.call(route)
65
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ break unless remaining.positive?
67
+
68
+ attempt_deadline = [
69
+ deadline,
70
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) +
71
+ NEGOTIATION_ATTEMPT_TIMEOUT
72
+ ].min
73
+ target = connector.call(route, deadline: attempt_deadline)
55
74
  DatabaseRouter::Tds::Message.write(target, prelogin)
56
75
  return [
57
76
  route,
58
77
  target,
59
- DatabaseRouter::Tds::Message.read(target, deadline: deadline)
78
+ DatabaseRouter::Tds::Message.read(
79
+ target,
80
+ deadline: attempt_deadline
81
+ )
60
82
  ]
61
83
  rescue Error, EOFError, IOError, SystemCallError
62
84
  target&.close
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LocalDevelopmentGateway
6
+ module DatabaseRouter::PostgreSql
7
+ class CancellationRoutes
8
+ Destination = Data.define(:route, :backend_key)
9
+
10
+ def initialize
11
+ @destinations = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def register(route, backend_key)
16
+ @mutex.synchronize do
17
+ identity = new_identity
18
+ identity = new_identity while @destinations.key?(identity)
19
+
20
+ identity.freeze
21
+ @destinations[identity] = Destination.new(
22
+ route: route,
23
+ backend_key: backend_key.dup.freeze
24
+ )
25
+ identity
26
+ end
27
+ end
28
+
29
+ def resolve(identity)
30
+ @mutex.synchronize { @destinations[identity] }
31
+ end
32
+
33
+ def remove(identity)
34
+ @mutex.synchronize { @destinations.delete(identity) }
35
+ end
36
+
37
+ private
38
+
39
+ def new_identity
40
+ identity = SecureRandom.random_bytes(8)
41
+ identity.setbyte(0, identity.getbyte(0) & 0x7f)
42
+ identity.setbyte(3, 1) if identity.unpack1("N").zero?
43
+ identity
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "local_development_gateway/database_router/postgre_sql/cancellation_routes"
4
+
5
+ module LocalDevelopmentGateway
6
+ class DatabaseRouter::PostgreSql::Session
7
+ MAX_BACKEND_KEY_BYTES = 260
8
+
9
+ def initialize(cancellations)
10
+ @cancellations = cancellations
11
+ end
12
+
13
+ def forward_response(backend, client, route)
14
+ identity = nil
15
+ loop do
16
+ header = backend.read(5)
17
+ raise EOFError unless header&.bytesize == 5
18
+
19
+ payload_length = header.byteslice(1, 4).unpack1("N") - 4
20
+ if payload_length.negative?
21
+ raise Error, "Invalid PostgreSQL message length"
22
+ end
23
+
24
+ if header.getbyte(0) == "K".ord
25
+ unless (8..MAX_BACKEND_KEY_BYTES).cover?(payload_length)
26
+ raise Error, "Invalid PostgreSQL backend key length"
27
+ end
28
+
29
+ backend_key = backend.read(payload_length)
30
+ raise EOFError unless backend_key&.bytesize == payload_length
31
+
32
+ identity = @cancellations.register(route, backend_key)
33
+ client.write("K" + [12].pack("N") + identity)
34
+ IO.copy_stream(backend, client)
35
+ return
36
+ end
37
+
38
+ client.write(header)
39
+ copied = IO.copy_stream(backend, client, payload_length)
40
+ raise EOFError unless copied == payload_length
41
+ end
42
+ ensure
43
+ @cancellations.remove(identity) if identity
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openssl"
4
+ require "socket"
5
+
3
6
  module LocalDevelopmentGateway
4
7
  module DatabaseRouter::Wire
5
8
  HANDSHAKE_TIMEOUT = 5
@@ -13,12 +16,70 @@ module LocalDevelopmentGateway
13
16
  def read_exactly(io, length, deadline:)
14
17
  bytes = +""
15
18
  while bytes.bytesize < length
16
- wait(io, readable: true, deadline: deadline)
17
- bytes << io.readpartial(length - bytes.bytesize)
19
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ raise Error, "Database handshake timed out" unless remaining.positive?
21
+
22
+ chunk = io.read_nonblock(length - bytes.bytesize, exception: false)
23
+ case chunk
24
+ when :wait_readable
25
+ wait(io, readable: true, deadline: deadline)
26
+ when :wait_writable
27
+ wait(io, readable: false, deadline: deadline)
28
+ when nil
29
+ raise EOFError
30
+ else
31
+ bytes << chunk
32
+ end
18
33
  end
19
34
  bytes
20
35
  end
21
36
 
37
+ def proxy(client, target, &response)
38
+ upstream =
39
+ Thread.new do
40
+ IO.copy_stream(client, target)
41
+ rescue IOError, EOFError, OpenSSL::SSL::SSLError, SystemCallError
42
+ nil
43
+ ensure
44
+ close_write(target)
45
+ end
46
+ downstream =
47
+ Thread.new do
48
+ if response
49
+ response.call(target, client)
50
+ else
51
+ IO.copy_stream(target, client)
52
+ end
53
+ rescue IOError, EOFError, OpenSSL::SSL::SSLError, SystemCallError
54
+ nil
55
+ ensure
56
+ close_connection(client)
57
+ close_connection(target)
58
+ end
59
+ threads = [upstream, downstream]
60
+ threads.each { |thread| thread.report_on_exception = false }
61
+ threads.each(&:join)
62
+ ensure
63
+ close_connection(client)
64
+ close_connection(target)
65
+ threads&.each(&:join)
66
+ end
67
+
68
+ def close_write(io)
69
+ io.close_write unless io.closed?
70
+ rescue IOError, SystemCallError
71
+ nil
72
+ end
73
+
74
+ def close_connection(io)
75
+ socket = io.to_io
76
+ socket.shutdown(Socket::SHUT_RDWR) unless socket.closed?
77
+ rescue IOError, SystemCallError
78
+ nil
79
+ ensure
80
+ io.close unless io.closed?
81
+ end
82
+
22
83
  def read_until_eof(io, max_bytes:, deadline:)
23
84
  bytes = +""
24
85
  loop do
@@ -7,7 +7,9 @@ module LocalDevelopmentGateway
7
7
  class DatabaseRouter
8
8
  CONNECT_TIMEOUT = 3
9
9
  MAX_CONNECTIONS = 128
10
+ MAX_HANDSHAKES = 128
10
11
  Route = Data.define(:driver, :hostname, :port, :target_address)
12
+ Connection = Data.define(:source, :target, :route)
11
13
 
12
14
  def self.run
13
15
  new.run
@@ -23,10 +25,13 @@ module LocalDevelopmentGateway
23
25
  def initialize(
24
26
  routes: DockerRoutes.new,
25
27
  drivers: self.class.drivers,
26
- servers: nil
28
+ servers: nil,
29
+ max_connections: MAX_CONNECTIONS
27
30
  )
28
31
  @routes = routes
29
32
  @drivers = drivers
33
+ @session_slots = SizedQueue.new(max_connections)
34
+ max_connections.times { @session_slots << true }
30
35
  @servers =
31
36
  servers ||
32
37
  drivers.to_h do |driver|
@@ -35,8 +40,8 @@ module LocalDevelopmentGateway
35
40
  end
36
41
 
37
42
  def run
38
- slots = SizedQueue.new(MAX_CONNECTIONS)
39
- MAX_CONNECTIONS.times { slots << true }
43
+ slots = SizedQueue.new(MAX_HANDSHAKES)
44
+ MAX_HANDSHAKES.times { slots << true }
40
45
  @drivers
41
46
  .map do |driver|
42
47
  Thread.new do
@@ -45,9 +50,7 @@ module LocalDevelopmentGateway
45
50
  client = server.accept
46
51
  slots.pop
47
52
  Thread.new(client) do |connection|
48
- route(connection, driver)
49
- ensure
50
- slots << true
53
+ route(connection, driver, handshake_slots: slots)
51
54
  end
52
55
  end
53
56
  end
@@ -55,48 +58,46 @@ module LocalDevelopmentGateway
55
58
  .each(&:join)
56
59
  end
57
60
 
58
- def route(client, driver)
59
- routes = @routes.call.select { |route| route.driver == driver.name }
61
+ def route(client, driver, handshake_slots: nil)
62
+ routes = -> do
63
+ @routes.call.select { |route| route.driver == driver.name }
64
+ end
65
+ connection =
66
+ driver.connect(client, routes: routes, connector: method(:connect))
67
+ handshake_slots&.push(true)
68
+ handshake_slots = nil
69
+ return unless connection
60
70
 
61
- source, target =
62
- driver.connect(client, routes, connector: method(:connect))
63
- proxy(source, target)
71
+ session_slot = @session_slots.pop(true)
72
+ driver.forward(connection)
64
73
  rescue EOFError
65
74
  nil
75
+ rescue ThreadError
76
+ warn "Database session limit reached"
66
77
  rescue Error => error
67
78
  warn error.message
68
79
  rescue StandardError => error
69
80
  warn error.full_message
70
81
  ensure
71
- source&.close unless source.equal?(client)
82
+ handshake_slots&.push(true)
83
+ @session_slots << true if session_slot
84
+ connection&.source&.close
72
85
  client&.close
73
- target&.close
86
+ connection&.target&.close
74
87
  end
75
88
 
76
89
  private
77
90
 
78
- def connect(route)
91
+ def connect(route, deadline:)
92
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ raise Error, "Database handshake timed out" unless remaining.positive?
94
+
79
95
  Socket.tcp(
80
96
  route.target_address,
81
97
  route.port,
82
- connect_timeout: CONNECT_TIMEOUT
98
+ connect_timeout: [CONNECT_TIMEOUT, remaining].min
83
99
  )
84
100
  end
85
-
86
- def proxy(client, target)
87
- [[client, target], [target, client]].map do |source, destination|
88
- Thread.new do
89
- IO.copy_stream(source, destination)
90
- rescue IOError, OpenSSL::SSL::SSLError, SystemCallError
91
- nil
92
- ensure
93
- if destination.respond_to?(:close_write) && !destination.closed?
94
- destination.close_write
95
- end
96
- end
97
- end
98
- .each(&:join)
99
- end
100
101
  end
101
102
  end
102
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocalDevelopmentGateway
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: local-development-gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Ruby API and CLI for starting, monitoring, and conditionally stopping
14
14
  the shared Docker gateway.
@@ -23,6 +23,7 @@ files:
23
23
  - bin/local-development-gateway
24
24
  - config/traefik.yml
25
25
  - docker-compose.yml
26
+ - docs/architecture/local-tcp-routing.md
26
27
  - lib/local_development_gateway.rb
27
28
  - lib/local_development_gateway/database_router.rb
28
29
  - lib/local_development_gateway/database_router/docker_api.rb
@@ -30,6 +31,8 @@ files:
30
31
  - lib/local_development_gateway/database_router/drivers/postgre_sql_certificate.rb
31
32
  - lib/local_development_gateway/database_router/drivers/postgre_sql_driver.rb
32
33
  - lib/local_development_gateway/database_router/drivers/sql_server_driver.rb
34
+ - lib/local_development_gateway/database_router/postgre_sql/cancellation_routes.rb
35
+ - lib/local_development_gateway/database_router/postgre_sql/session.rb
33
36
  - lib/local_development_gateway/database_router/tds/message.rb
34
37
  - lib/local_development_gateway/database_router/tds/tls_byte_reader.rb
35
38
  - lib/local_development_gateway/database_router/tds/tls_client_hello.rb