local-development-gateway 0.1.3 → 0.2.1

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: 10a939f848cae2b9e243772bb0b71e55f9f4ef7db51df904dbc44dceda981b0f
4
- data.tar.gz: 0fe2303e0a7452356971343a371a73d897550d2461502bafba85ba81beb8bcba
3
+ metadata.gz: 8baffa29acc52adfcc3112cacc9be936b24528915f86fbc94c323df081e05fd5
4
+ data.tar.gz: 4ab5156e3dc8dae143345b3c58271be2afd4b3485fb92c6bfd18153dd1958734
5
5
  SHA512:
6
- metadata.gz: 9a5b33aceca3f70ae6d50fe53dd6a398badbde7b2f7869a2099507bd361308843cc8189924cae4adb0c8203afe189c62a002ed694d73cb0efe9bc8089edab92e
7
- data.tar.gz: '086a30b8f018bf0fd4ce44d410ba5dc23cbb4923d28afd225df171a1c9453a2a3f4cf2a24f8c2a4ed75fb3587f00aa9022cf03c4556a1e73ffc98f269936e95b'
6
+ metadata.gz: 8ed0938cfb210bda2c320f6c481af5ddabf9f32d8f04fcac7ac94f60bbc9df8da98fe71f7929581d54ab5a20b648c339b92cd78f799b50fb74f3300e64997e0c
7
+ data.tar.gz: b1acdaefc1c2b69b1e101fb5516ee8de7de866e9d9b1d11bd2390d14a860204a869ca8d90a10123a9b304b954fb03297c26b80482743105bee5a53f3bad06c22
data/README.md CHANGED
@@ -66,7 +66,7 @@ Consuming projects do not need a gateway checkout. Add the released gem to
66
66
  their bundle and pin the compatible minor version:
67
67
 
68
68
  ```ruby
69
- gem "local-development-gateway", "~> 0.1"
69
+ gem "local-development-gateway", "~> 0.2"
70
70
  ```
71
71
 
72
72
  Published gem: [`local-development-gateway`](https://rubygems.org/gems/local-development-gateway)
@@ -83,7 +83,7 @@ bundle exec local-development-gateway down
83
83
 
84
84
  `local-development-gateway` uses the installed asset path, the
85
85
  `local-gateway` Compose project and network labels, and the pinned minimum Ruby
86
- version declared by the gem. The `~> 0.1` constraint follows the consuming
86
+ version declared by the gem. The `~> 0.2` constraint follows the consuming
87
87
  project's published Gemfile.
88
88
  The repository's `bin/dev` is only a thin wrapper around this executable API.
89
89
 
@@ -106,6 +106,65 @@ command that must not start a missing gateway, pass
106
106
  `ensure_running: false`; the block still runs and unused-gateway cleanup is
107
107
  attempted.
108
108
 
109
+ ## Worktree database services
110
+
111
+ The gateway includes protocol drivers for encrypted SQL Server and PostgreSQL
112
+ connections. No host agent, DNS server, administrator privileges, or
113
+ `/etc/hosts` changes are required.
114
+
115
+ A participating project only joins its existing database service to
116
+ `local-gateway` and adds three labels:
117
+
118
+ ```yaml
119
+ services:
120
+ db:
121
+ networks:
122
+ - default
123
+ - local-gateway
124
+ labels:
125
+ - "local-gateway.tcp.driver=sql_server"
126
+ - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME:-wrap.localhost}"
127
+ - "local-gateway.tcp.port=1433"
128
+
129
+ networks:
130
+ local-gateway:
131
+ external: true
132
+ name: local-gateway
133
+ ```
134
+
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:
137
+
138
+ | Driver label | Host port | Client requirement |
139
+ | --- | ---: | --- |
140
+ | `sql_server` | `1433` | Encryption enabled and **Trust server certificate** selected |
141
+ | `postgresql` | `5432` | SSL mode `require` |
142
+
143
+ Use the database container's actual internal port in
144
+ `local-gateway.tcp.port`; it does not need to match the host listener.
145
+
146
+ Remove the database's host `ports` publication when using the gateway; the shared
147
+ router owns `127.0.0.1:1433` and `127.0.0.1:5432`. Keep the `default` network so
148
+ application and test containers still connect directly to `db` on its internal
149
+ port, without TLS or a host-published port. A project not using the gateway must
150
+ select its own explicit port-publishing fallback.
151
+
152
+ All `.localhost` names resolve to loopback. The gateway publishes loopback-only
153
+ listeners, discovers labelled containers through Docker, and uses the database
154
+ protocol's encrypted handshake to select the hostname-labelled container.
155
+ Container replacements and stopped routes require no consumer lifecycle code
156
+ or cleanup.
157
+
158
+ SQL Server encryption remains end to end through the router. PostgreSQL TLS
159
+ terminates at the gateway; the backend stream is plaintext on the private Docker
160
+ network. New PostgreSQL sessions require TLS and SNI, but query cancellation
161
+ does not: the gateway substitutes a session-specific cancellation identity and
162
+ routes ordinary `psql` and JDBC cancellation packets to that session's backend.
163
+ Closed sessions lose their cancellation mapping. See
164
+ [`docs/architecture/local-tcp-routing.md`](docs/architecture/local-tcp-routing.md)
165
+ for the complete contract and security boundary, including the reason for
166
+ retaining cancellation-aware PostgreSQL routing despite Traefik's STARTTLS support.
167
+
109
168
  ## Development checks
110
169
 
111
170
  Install the locked development tools with `bundle install`. Run the Ruby
@@ -116,10 +175,19 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
116
175
  Gemfile \
117
176
  Rakefile \
118
177
  lib/local_development_gateway.rb \
178
+ lib/local_development_gateway/database_router.rb \
179
+ lib/local_development_gateway/database_router/*.rb \
180
+ lib/local_development_gateway/database_router/drivers/*.rb \
181
+ lib/local_development_gateway/database_router/postgre_sql/*.rb \
182
+ lib/local_development_gateway/database_router/tds/*.rb \
119
183
  lib/local_development_gateway/version.rb \
120
184
  bin/dev \
121
185
  bin/local-development-gateway \
122
- test/local_development_gateway_test.rb
186
+ test/*.rb \
187
+ test/database_router/*.rb \
188
+ test/database_router/drivers/*.rb \
189
+ test/database_router/postgre_sql/*.rb \
190
+ test/database_router/tds/*.rb
123
191
  rake test
124
192
  ```
125
193
 
@@ -194,3 +262,16 @@ It mounts the Docker socket so it can discover labelled containers. A read-only
194
262
  | Generated hostname returns `404` | Confirm the web container has the route labels and is attached to `local-gateway`. |
195
263
  | A project uses normal ports unexpectedly | Confirm the gateway container is running and has `local-gateway=true`. |
196
264
  | Authentication rejects the callback | Confirm the generated hostname matches the allowed wildcard callback pattern. |
265
+
266
+ ## Release 0.2.1
267
+
268
+ - PostgreSQL query cancellation works across concurrent worktrees, including
269
+ plaintext cancellation from `psql` and JDBC and encrypted cancellation.
270
+ Session-specific identities prevent backend key collisions and expire when
271
+ their session closes.
272
+ - Invalid database labels and duplicate hostname identities reject only the
273
+ affected routes, leaving unrelated worktrees and database drivers available.
274
+ - SQL Server negotiation can fall back from a stalled backend within the
275
+ shared handshake deadline.
276
+ - PostgreSQL STARTTLS support in Traefik is documented accurately. The gateway
277
+ retains cancellation-aware session routing without changing consumer labels.
data/docker-compose.yml CHANGED
@@ -22,6 +22,37 @@ services:
22
22
  networks:
23
23
  - local-gateway
24
24
 
25
+ database-router:
26
+ image: ruby:3.4.7-alpine
27
+ command:
28
+ - ruby
29
+ - -I/app/lib
30
+ - -rlocal_development_gateway
31
+ - -e
32
+ - LocalDevelopmentGateway::DatabaseRouter.run
33
+ healthcheck:
34
+ test:
35
+ - CMD
36
+ - ruby
37
+ - -rsocket
38
+ - -e
39
+ - TCPSocket.new("127.0.0.1", 1433).close; TCPSocket.new("127.0.0.1", 5432).close
40
+ interval: 1s
41
+ timeout: 3s
42
+ retries: 30
43
+ start_period: 1s
44
+ labels:
45
+ local-gateway: "true"
46
+ ports:
47
+ - "127.0.0.1:1433:1433"
48
+ - "127.0.0.1:5432:5432"
49
+ restart: unless-stopped
50
+ volumes:
51
+ - ./lib:/app/lib:ro
52
+ - /var/run/docker.sock:/var/run/docker.sock:ro
53
+ networks:
54
+ - local-gateway
55
+
25
56
  networks:
26
57
  local-gateway:
27
58
  name: local-gateway
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ require "local_development_gateway/database_router/wire"
7
+
8
+ module LocalDevelopmentGateway
9
+ class DatabaseRouter::DockerApi
10
+ MAX_RESPONSE_BYTES = 8 * 1024 * 1024
11
+ SOCKET_PATH = "/var/run/docker.sock"
12
+
13
+ def initialize(socket_path: SOCKET_PATH)
14
+ @socket_path = socket_path
15
+ end
16
+
17
+ def get(path)
18
+ socket = UNIXSocket.new(@socket_path)
19
+ socket.write(
20
+ "GET #{path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n"
21
+ )
22
+ response =
23
+ DatabaseRouter::Wire.read_until_eof(
24
+ socket,
25
+ max_bytes: MAX_RESPONSE_BYTES,
26
+ deadline: DatabaseRouter::Wire.deadline
27
+ )
28
+ headers, body = response.split("\r\n\r\n", 2)
29
+ unless headers&.start_with?("HTTP/1.1 200", "HTTP/1.0 200")
30
+ raise Error,
31
+ "Docker API request failed: #{headers&.lines&.first&.strip}"
32
+ end
33
+
34
+ body = decode_chunks(body) if headers.downcase.include?(
35
+ "transfer-encoding: chunked"
36
+ )
37
+ JSON.parse(body)
38
+ ensure
39
+ socket&.close
40
+ end
41
+
42
+ private
43
+
44
+ def decode_chunks(body)
45
+ decoded = +""
46
+ loop do
47
+ line_end = body.index("\r\n")
48
+ raise Error, "Invalid chunked Docker API response" unless line_end
49
+
50
+ size = Integer(body.byteslice(0, line_end).split(";", 2).first, 16)
51
+ body = body.byteslice(line_end + 2..)
52
+ break if size.zero?
53
+ unless body.bytesize >= size + 2 && body.byteslice(size, 2) == "\r\n"
54
+ raise Error, "Invalid chunked Docker API response"
55
+ end
56
+
57
+ decoded << body.byteslice(0, size)
58
+ body = body.byteslice(size + 2..)
59
+ end
60
+ decoded
61
+ rescue ArgumentError, TypeError
62
+ raise Error, "Invalid chunked Docker API response"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "local_development_gateway/database_router/docker_api"
4
+
5
+ module LocalDevelopmentGateway
6
+ class DatabaseRouter::DockerRoutes
7
+ NETWORK_NAME = "local-gateway"
8
+ HOSTNAME_LABEL = "local-gateway.tcp.hostname"
9
+ PORT_LABEL = "local-gateway.tcp.port"
10
+ DRIVER_LABEL = "local-gateway.tcp.driver"
11
+ HOSTNAME_PATTERN = /\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+localhost\z/
12
+
13
+ def initialize(client: DatabaseRouter::DockerApi.new)
14
+ @client = client
15
+ end
16
+
17
+ def call
18
+ routes =
19
+ @client
20
+ .get("/containers/json")
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(" ")}"
34
+ end
35
+
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] }
41
+ end
42
+
43
+ private
44
+
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
+
50
+ labels = container.fetch("Labels")
51
+ driver = labels[DRIVER_LABEL]
52
+ hostname = labels[HOSTNAME_LABEL]
53
+ port = labels[PORT_LABEL]
54
+ values = [driver, hostname, port]
55
+ return if values.all?(&:nil?)
56
+ raise Error, "Incomplete labelled database route" if values.any?(&:nil?)
57
+
58
+ unless DatabaseRouter.drivers.any? { |candidate|
59
+ candidate.name == driver
60
+ }
61
+ raise Error, "Unsupported database driver: #{driver}"
62
+ end
63
+ unless HOSTNAME_PATTERN.match?(hostname)
64
+ raise Error, "Invalid labelled database hostname: #{hostname}"
65
+ end
66
+
67
+ port = Integer(port, 10)
68
+ unless (1..65_535).cover?(port)
69
+ raise Error, "Invalid labelled database port: #{port}"
70
+ end
71
+
72
+ DatabaseRouter::Route.new(
73
+ driver: driver,
74
+ hostname: hostname,
75
+ port: port,
76
+ target_address: target_address
77
+ )
78
+ rescue KeyError
79
+ raise Error, "Incomplete labelled database route"
80
+ rescue ArgumentError, TypeError
81
+ raise Error, "Invalid labelled database port: #{port}"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module LocalDevelopmentGateway
6
+ module DatabaseRouter::Drivers
7
+ class PostgreSqlCertificate
8
+ def initialize
9
+ @key = OpenSSL::PKey::EC.generate("prime256v1")
10
+ @certificate = OpenSSL::X509::Certificate.new
11
+ @certificate.version = 2
12
+ @certificate.serial = 1
13
+ @certificate.subject =
14
+ OpenSSL::X509::Name.parse("/CN=local-development-gateway")
15
+ @certificate.issuer = @certificate.subject
16
+ @certificate.public_key = @key
17
+ @certificate.not_before = Time.now - 60
18
+ @certificate.not_after = Time.now + (10 * 365 * 24 * 60 * 60)
19
+ extensions = OpenSSL::X509::ExtensionFactory.new
20
+ extensions.subject_certificate = @certificate
21
+ extensions.issuer_certificate = @certificate
22
+ @certificate.add_extension(
23
+ extensions.create_extension("basicConstraints", "CA:FALSE", true)
24
+ )
25
+ @certificate.add_extension(
26
+ extensions.create_extension("keyUsage", "digitalSignature", true)
27
+ )
28
+ @certificate.add_extension(
29
+ extensions.create_extension("extendedKeyUsage", "serverAuth")
30
+ )
31
+ @certificate.sign(@key, OpenSSL::Digest::SHA256.new)
32
+ end
33
+
34
+ def context(&servername_callback)
35
+ context = OpenSSL::SSL::SSLContext.new
36
+ context.cert = @certificate
37
+ context.key = @key
38
+ context.min_version = OpenSSL::SSL::TLS1_2_VERSION
39
+ context.servername_cb = servername_callback
40
+ context
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ require "local_development_gateway/database_router/wire"
6
+ require "local_development_gateway/database_router/drivers/postgre_sql_certificate"
7
+ require "local_development_gateway/database_router/postgre_sql/session"
8
+
9
+ module LocalDevelopmentGateway
10
+ module DatabaseRouter::Drivers
11
+ class PostgreSqlDriver
12
+ NAME = "postgresql"
13
+ LISTEN_PORT = 5432
14
+ SSL_REQUEST = [8, 80_877_103].pack("NN").freeze
15
+ CANCEL_REQUEST = [16, 80_877_102].pack("NN").freeze
16
+
17
+ def initialize(
18
+ certificate: PostgreSqlCertificate.new,
19
+ cancellations: DatabaseRouter::PostgreSql::CancellationRoutes.new
20
+ )
21
+ @certificate = certificate
22
+ @cancellations = cancellations
23
+ end
24
+
25
+ def name
26
+ NAME
27
+ end
28
+
29
+ def listen_port
30
+ LISTEN_PORT
31
+ end
32
+
33
+ def connect(client, routes:, connector:)
34
+ deadline = DatabaseRouter::Wire.deadline
35
+ request =
36
+ DatabaseRouter::Wire.read_exactly(client, 8, deadline: deadline)
37
+ if cancel_request?(request)
38
+ cancel(client, connector, deadline)
39
+ return
40
+ end
41
+ raise Error, "PostgreSQL SSL is required" unless request == SSL_REQUEST
42
+
43
+ client.write("S")
44
+ hostname = nil
45
+ context =
46
+ @certificate.context do |_socket, name|
47
+ hostname = name&.downcase
48
+ nil
49
+ end
50
+ tls = OpenSSL::SSL::SSLSocket.new(client, context)
51
+ tls.sync_close = true
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
+
66
+ unless hostname
67
+ raise Error, "PostgreSQL TLS ClientHello does not contain SNI"
68
+ end
69
+
70
+ selected = routes.call.find { |route| route.hostname == hostname }
71
+ raise Error, "No PostgreSQL route for #{hostname}" unless selected
72
+
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
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "local_development_gateway/database_router/tds/message"
4
+ require "local_development_gateway/database_router/tds/tls_client_hello"
5
+
6
+ module LocalDevelopmentGateway
7
+ module DatabaseRouter::Drivers
8
+ class SqlServerDriver
9
+ NEGOTIATION_ATTEMPT_TIMEOUT = 1
10
+
11
+ NAME = "sql_server"
12
+ LISTEN_PORT = 1433
13
+
14
+ def name
15
+ NAME
16
+ end
17
+
18
+ def listen_port
19
+ LISTEN_PORT
20
+ end
21
+
22
+ def connect(client, routes:, connector:)
23
+ deadline = DatabaseRouter::Wire.deadline
24
+ prelogin = DatabaseRouter::Tds::Message.read(client, deadline: deadline)
25
+ routes = routes.call
26
+ if routes.empty?
27
+ raise Error, "No labelled sql_server routes are available"
28
+ end
29
+ provisional, target, response =
30
+ negotiate(routes, prelogin, connector, deadline)
31
+ DatabaseRouter::Tds::Message.write(client, response)
32
+
33
+ hostname, messages = read_client_hello(client, deadline)
34
+ selected = routes.find { |route| route.hostname == hostname }
35
+ raise Error, "No SQL Server route for #{hostname}" unless selected
36
+
37
+ if selected != provisional
38
+ target.close
39
+ target = connector.call(selected, deadline: deadline)
40
+ DatabaseRouter::Tds::Message.write(target, prelogin)
41
+ DatabaseRouter::Tds::Message.read(target, deadline: deadline)
42
+ end
43
+
44
+ messages.each do |message|
45
+ DatabaseRouter::Tds::Message.write(target, message)
46
+ end
47
+ DatabaseRouter::Connection.new(
48
+ source: client,
49
+ target: target,
50
+ route: selected
51
+ )
52
+ rescue StandardError
53
+ target&.close
54
+ raise
55
+ end
56
+
57
+ def forward(connection)
58
+ DatabaseRouter::Wire.proxy(connection.source, connection.target)
59
+ end
60
+
61
+ private
62
+
63
+ def negotiate(routes, prelogin, connector, deadline)
64
+ routes.each do |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)
74
+ DatabaseRouter::Tds::Message.write(target, prelogin)
75
+ return [
76
+ route,
77
+ target,
78
+ DatabaseRouter::Tds::Message.read(
79
+ target,
80
+ deadline: attempt_deadline
81
+ )
82
+ ]
83
+ rescue Error, EOFError, IOError, SystemCallError
84
+ target&.close
85
+ end
86
+ raise Error, "No labelled SQL Server backends are reachable"
87
+ end
88
+
89
+ def read_client_hello(client, deadline)
90
+ hello = DatabaseRouter::Tds::TlsClientHello.new
91
+ messages = []
92
+ loop do
93
+ message =
94
+ DatabaseRouter::Tds::Message.read(client, deadline: deadline)
95
+ messages << message
96
+ hostname = hello.append(message.payload)
97
+ return hostname, messages if hostname
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "local_development_gateway/database_router/wire"
4
+
5
+ module LocalDevelopmentGateway
6
+ module DatabaseRouter::Tds
7
+ class Message
8
+ MAX_BYTES = 1024 * 1024
9
+ MAX_PACKETS = 16
10
+ Packet = Data.define(:header, :payload)
11
+
12
+ attr_reader :packets
13
+
14
+ def self.read(io, deadline:)
15
+ packets = []
16
+ size = 0
17
+ loop do
18
+ if packets.length >= MAX_PACKETS
19
+ raise Error, "TDS message has too many packets"
20
+ end
21
+
22
+ header = DatabaseRouter::Wire.read_exactly(io, 8, deadline: deadline)
23
+ length = header.byteslice(2, 2).unpack1("n")
24
+ raise Error, "Invalid TDS packet length" if length < 8
25
+
26
+ size += length - 8
27
+ raise Error, "TDS message is too large" if size > MAX_BYTES
28
+
29
+ packets << Packet.new(
30
+ header: header,
31
+ payload:
32
+ DatabaseRouter::Wire.read_exactly(
33
+ io,
34
+ length - 8,
35
+ deadline: deadline
36
+ )
37
+ )
38
+ break if header.getbyte(1) & 1 == 1
39
+ end
40
+ new(packets)
41
+ end
42
+
43
+ def self.write(io, message)
44
+ message.packets.each do |packet|
45
+ io.write(packet.header, packet.payload)
46
+ end
47
+ end
48
+
49
+ def initialize(packets)
50
+ @packets = packets
51
+ end
52
+
53
+ def payload
54
+ packets.map(&:payload).join
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LocalDevelopmentGateway
4
+ class DatabaseRouter::Tds::TlsByteReader
5
+ def initialize(bytes)
6
+ @bytes = bytes
7
+ @offset = 0
8
+ end
9
+
10
+ def empty?
11
+ @offset == @bytes.bytesize
12
+ end
13
+
14
+ def read(length)
15
+ if length.negative? || @offset + length > @bytes.bytesize
16
+ raise Error, "Invalid TLS ClientHello"
17
+ end
18
+
19
+ value = @bytes.byteslice(@offset, length)
20
+ @offset += length
21
+ value
22
+ end
23
+
24
+ def uint8
25
+ read(1).unpack1("C")
26
+ end
27
+
28
+ def uint16
29
+ read(2).unpack1("n")
30
+ end
31
+
32
+ def vector8
33
+ read(uint8)
34
+ end
35
+
36
+ def vector16
37
+ read(uint16)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "local_development_gateway/database_router/tds/tls_byte_reader"
4
+
5
+ module LocalDevelopmentGateway
6
+ class DatabaseRouter::Tds::TlsClientHello
7
+ MAX_BYTES = 128 * 1024
8
+ MAX_RECORD_BYTES = (16 * 1024) + 2048
9
+
10
+ def initialize
11
+ @record_buffer = +""
12
+ @handshake = +""
13
+ @received = 0
14
+ end
15
+
16
+ def append(bytes)
17
+ @received += bytes.bytesize
18
+ raise Error, "TLS ClientHello is too large" if @received > MAX_BYTES
19
+
20
+ @record_buffer << bytes
21
+ consume_records
22
+ server_name if complete?
23
+ end
24
+
25
+ private
26
+
27
+ def consume_records
28
+ while @record_buffer.bytesize >= 5
29
+ content_type = @record_buffer.getbyte(0)
30
+ length = @record_buffer.byteslice(3, 2).unpack1("n")
31
+ raise Error, "Invalid TLS ClientHello record" unless content_type == 22
32
+ raise Error, "TLS record is too large" if length > MAX_RECORD_BYTES
33
+ break if @record_buffer.bytesize < length + 5
34
+
35
+ @handshake << @record_buffer.byteslice(5, length)
36
+ @record_buffer = @record_buffer.byteslice(length + 5..) || +""
37
+ end
38
+ end
39
+
40
+ def complete?
41
+ return false if @handshake.bytesize < 4
42
+ unless @handshake.getbyte(0) == 1
43
+ raise Error, "Expected a TLS ClientHello"
44
+ end
45
+
46
+ @handshake.bytesize >= 4 + uint24(@handshake, 1)
47
+ end
48
+
49
+ def server_name
50
+ length = uint24(@handshake, 1)
51
+ reader =
52
+ DatabaseRouter::Tds::TlsByteReader.new(@handshake.byteslice(4, length))
53
+ reader.read(2 + 32)
54
+ reader.vector8
55
+ reader.vector16
56
+ reader.vector8
57
+ extensions = DatabaseRouter::Tds::TlsByteReader.new(reader.vector16)
58
+ until extensions.empty?
59
+ type = extensions.uint16
60
+ data = extensions.vector16
61
+ next unless type.zero?
62
+
63
+ names =
64
+ DatabaseRouter::Tds::TlsByteReader.new(
65
+ DatabaseRouter::Tds::TlsByteReader.new(data).vector16
66
+ )
67
+ until names.empty?
68
+ name_type = names.uint8
69
+ name = names.vector16
70
+ if name_type.zero?
71
+ return name.force_encoding(Encoding::UTF_8).downcase
72
+ end
73
+ end
74
+ end
75
+ raise Error, "TLS ClientHello does not contain SNI"
76
+ end
77
+
78
+ def uint24(bytes, offset)
79
+ (bytes.getbyte(offset) << 16) | (bytes.getbyte(offset + 1) << 8) |
80
+ bytes.getbyte(offset + 2)
81
+ rescue NoMethodError
82
+ raise Error, "Invalid TLS ClientHello"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+
6
+ module LocalDevelopmentGateway
7
+ module DatabaseRouter::Wire
8
+ HANDSHAKE_TIMEOUT = 5
9
+
10
+ module_function
11
+
12
+ def deadline
13
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + HANDSHAKE_TIMEOUT
14
+ end
15
+
16
+ def read_exactly(io, length, deadline:)
17
+ bytes = +""
18
+ while bytes.bytesize < length
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
33
+ end
34
+ bytes
35
+ end
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
+
83
+ def read_until_eof(io, max_bytes:, deadline:)
84
+ bytes = +""
85
+ loop do
86
+ wait(io, readable: true, deadline: deadline)
87
+ bytes << io.readpartial(16 * 1024)
88
+ raise Error, "Response is too large" if bytes.bytesize > max_bytes
89
+ end
90
+ rescue EOFError
91
+ bytes
92
+ end
93
+
94
+ def accept_tls(socket, deadline:)
95
+ loop do
96
+ case socket.accept_nonblock(exception: false)
97
+ when :wait_readable
98
+ wait(socket, readable: true, deadline: deadline)
99
+ when :wait_writable
100
+ wait(socket, readable: false, deadline: deadline)
101
+ else
102
+ return socket
103
+ end
104
+ end
105
+ rescue OpenSSL::SSL::SSLError => error
106
+ raise Error, "PostgreSQL TLS handshake failed: #{error.message}"
107
+ end
108
+
109
+ def wait(io, readable:, deadline:)
110
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ ready =
112
+ remaining.positive? &&
113
+ IO.select(
114
+ readable ? [io] : nil,
115
+ readable ? nil : [io],
116
+ nil,
117
+ remaining
118
+ )
119
+ raise Error, "Database handshake timed out" unless ready
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+
6
+ module LocalDevelopmentGateway
7
+ class DatabaseRouter
8
+ CONNECT_TIMEOUT = 3
9
+ MAX_CONNECTIONS = 128
10
+ MAX_HANDSHAKES = 128
11
+ Route = Data.define(:driver, :hostname, :port, :target_address)
12
+ Connection = Data.define(:source, :target, :route)
13
+
14
+ def self.run
15
+ new.run
16
+ end
17
+
18
+ def self.drivers
19
+ @drivers ||= [
20
+ Drivers::SqlServerDriver.new,
21
+ Drivers::PostgreSqlDriver.new
22
+ ].freeze
23
+ end
24
+
25
+ def initialize(
26
+ routes: DockerRoutes.new,
27
+ drivers: self.class.drivers,
28
+ servers: nil,
29
+ max_connections: MAX_CONNECTIONS
30
+ )
31
+ @routes = routes
32
+ @drivers = drivers
33
+ @session_slots = SizedQueue.new(max_connections)
34
+ max_connections.times { @session_slots << true }
35
+ @servers =
36
+ servers ||
37
+ drivers.to_h do |driver|
38
+ [driver.name, TCPServer.new("0.0.0.0", driver.listen_port)]
39
+ end
40
+ end
41
+
42
+ def run
43
+ slots = SizedQueue.new(MAX_HANDSHAKES)
44
+ MAX_HANDSHAKES.times { slots << true }
45
+ @drivers
46
+ .map do |driver|
47
+ Thread.new do
48
+ server = @servers.fetch(driver.name)
49
+ loop do
50
+ client = server.accept
51
+ slots.pop
52
+ Thread.new(client) do |connection|
53
+ route(connection, driver, handshake_slots: slots)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ .each(&:join)
59
+ end
60
+
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
70
+
71
+ session_slot = @session_slots.pop(true)
72
+ driver.forward(connection)
73
+ rescue EOFError
74
+ nil
75
+ rescue ThreadError
76
+ warn "Database session limit reached"
77
+ rescue Error => error
78
+ warn error.message
79
+ rescue StandardError => error
80
+ warn error.full_message
81
+ ensure
82
+ handshake_slots&.push(true)
83
+ @session_slots << true if session_slot
84
+ connection&.source&.close
85
+ client&.close
86
+ connection&.target&.close
87
+ end
88
+
89
+ private
90
+
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
+
95
+ Socket.tcp(
96
+ route.target_address,
97
+ route.port,
98
+ connect_timeout: [CONNECT_TIMEOUT, remaining].min
99
+ )
100
+ end
101
+ end
102
+ end
103
+
104
+ require "local_development_gateway/database_router/docker_routes"
105
+ require "local_development_gateway/database_router/drivers/sql_server_driver"
106
+ require "local_development_gateway/database_router/drivers/postgre_sql_driver"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocalDevelopmentGateway
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
- require_relative "local_development_gateway/version"
4
+ require "local_development_gateway/version"
5
5
 
6
6
  module LocalDevelopmentGateway
7
7
  PROJECT_NAME = "local-gateway"
8
8
  NETWORK_NAME = "local-gateway"
9
- SERVICE_NAME = "gateway"
9
+ SERVICE_NAMES = %w[gateway database-router].freeze
10
10
  GATEWAY_LABEL = "local-gateway"
11
+ OBSOLETE_SERVICE_NAMES = %w[dns tds-router].freeze
11
12
  ASSET_ROOT = File.expand_path("..", __dir__)
12
13
  COMPOSE_FILE = File.join(ASSET_ROOT, "docker-compose.yml")
13
14
  TRAEFIK_CONFIG_FILE = File.join(ASSET_ROOT, "config", "traefik.yml")
@@ -15,6 +16,8 @@ module LocalDevelopmentGateway
15
16
  class Error < StandardError
16
17
  end
17
18
 
19
+ require "local_development_gateway/database_router"
20
+
18
21
  class DockerError < Error
19
22
  attr_reader :command, :output
20
23
 
@@ -72,9 +75,12 @@ module LocalDevelopmentGateway
72
75
  alias start ensure_running
73
76
 
74
77
  def ready?
75
- network_exists? &&
76
- gateway_container_ids(status: "running").any? do |id|
77
- healthy_container?(id)
78
+ network_exists? && obsolete_services_absent? &&
79
+ SERVICE_NAMES.all? do |service|
80
+ gateway_container_ids(
81
+ service: service,
82
+ status: "running"
83
+ ).any? { |id| healthy_container?(id) }
78
84
  end
79
85
  end
80
86
 
@@ -150,10 +156,13 @@ module LocalDevelopmentGateway
150
156
  end
151
157
 
152
158
  def gateway_exists?
153
- network_exists? && !gateway_container_ids.empty?
159
+ network_exists? &&
160
+ SERVICE_NAMES.any? do |service|
161
+ !gateway_container_ids(service: service).empty?
162
+ end
154
163
  end
155
164
 
156
- def gateway_container_ids(status: nil)
165
+ def gateway_container_ids(service:, status: nil)
157
166
  args = ["ps"]
158
167
  args << "--all" unless status
159
168
  args.concat(
@@ -163,7 +172,7 @@ module LocalDevelopmentGateway
163
172
  "--filter",
164
173
  "label=com.docker.compose.project=#{PROJECT_NAME}",
165
174
  "--filter",
166
- "label=com.docker.compose.service=#{SERVICE_NAME}",
175
+ "label=com.docker.compose.service=#{service}",
167
176
  "--filter",
168
177
  "label=#{GATEWAY_LABEL}=true"
169
178
  ]
@@ -173,6 +182,12 @@ module LocalDevelopmentGateway
173
182
  output.lines.map(&:strip).reject(&:empty?)
174
183
  end
175
184
 
185
+ def obsolete_services_absent?
186
+ OBSOLETE_SERVICE_NAMES.all? do |service|
187
+ gateway_container_ids(service: service).empty?
188
+ end
189
+ end
190
+
176
191
  def healthy_container?(id)
177
192
  @runner.call(
178
193
  "inspect",
@@ -207,7 +222,8 @@ module LocalDevelopmentGateway
207
222
 
208
223
  def gateway_container?(container)
209
224
  container[:project] == PROJECT_NAME &&
210
- container[:service] == SERVICE_NAME && container[:label] == "true"
225
+ SERVICE_NAMES.include?(container[:service]) &&
226
+ container[:label] == "true"
211
227
  end
212
228
  end
213
229
 
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.1.3
4
+ version: 0.2.1
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-12 00:00:00.000000000 Z
11
+ date: 2026-10-08 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.
@@ -24,6 +24,18 @@ files:
24
24
  - config/traefik.yml
25
25
  - docker-compose.yml
26
26
  - lib/local_development_gateway.rb
27
+ - lib/local_development_gateway/database_router.rb
28
+ - lib/local_development_gateway/database_router/docker_api.rb
29
+ - lib/local_development_gateway/database_router/docker_routes.rb
30
+ - lib/local_development_gateway/database_router/drivers/postgre_sql_certificate.rb
31
+ - lib/local_development_gateway/database_router/drivers/postgre_sql_driver.rb
32
+ - lib/local_development_gateway/database_router/drivers/sql_server_driver.rb
33
+ - lib/local_development_gateway/database_router/postgre_sql/cancellation_routes.rb
34
+ - lib/local_development_gateway/database_router/postgre_sql/session.rb
35
+ - lib/local_development_gateway/database_router/tds/message.rb
36
+ - lib/local_development_gateway/database_router/tds/tls_byte_reader.rb
37
+ - lib/local_development_gateway/database_router/tds/tls_client_hello.rb
38
+ - lib/local_development_gateway/database_router/wire.rb
27
39
  - lib/local_development_gateway/version.rb
28
40
  homepage: https://github.com/icefoganalytics/local-development-gateway
29
41
  licenses: