local-development-gateway 0.1.3 → 0.2.0

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: ad3ccae73de13d5b8f94f5745116e2c87cb323421b0df17efa76376648c5c99f
4
+ data.tar.gz: 628768727722325fcb3195d849de79e4c28e62f903618aaa50caf5b40e128500
5
5
  SHA512:
6
- metadata.gz: 9a5b33aceca3f70ae6d50fe53dd6a398badbde7b2f7869a2099507bd361308843cc8189924cae4adb0c8203afe189c62a002ed694d73cb0efe9bc8089edab92e
7
- data.tar.gz: '086a30b8f018bf0fd4ce44d410ba5dc23cbb4923d28afd225df171a1c9453a2a3f4cf2a24f8c2a4ed75fb3587f00aa9022cf03c4556a1e73ffc98f269936e95b'
6
+ metadata.gz: 29d3dc511c560f8df87b3cb960098084ba177d8b366bfb4cbea142dcaa59433431f9c9add4d823512440729150aec7442c4f1b9372e5ba5a77af1303b46598ac
7
+ data.tar.gz: 2e808de3a602575bc91b2585372f240de10ee46f90ec562d3e49619cd8153e5eac463b55697a9cad43558e149426d95ab870a8816e083f321e97944cdcb2d7e6
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,56 @@ 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
+ All `.localhost` names resolve to loopback. The gateway publishes loopback-only
147
+ listeners, discovers labelled containers through Docker, and uses the database
148
+ protocol's encrypted handshake to select the hostname-labelled container.
149
+ Container replacements and stopped routes require no consumer lifecycle code
150
+ or cleanup.
151
+
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
156
+ [`docs/architecture/local-tcp-routing.md`](docs/architecture/local-tcp-routing.md)
157
+ for the complete contract and security boundary.
158
+
109
159
  ## Development checks
110
160
 
111
161
  Install the locked development tools with `bundle install`. Run the Ruby
@@ -116,10 +166,17 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
116
166
  Gemfile \
117
167
  Rakefile \
118
168
  lib/local_development_gateway.rb \
169
+ lib/local_development_gateway/database_router.rb \
170
+ lib/local_development_gateway/database_router/*.rb \
171
+ lib/local_development_gateway/database_router/drivers/*.rb \
172
+ lib/local_development_gateway/database_router/tds/*.rb \
119
173
  lib/local_development_gateway/version.rb \
120
174
  bin/dev \
121
175
  bin/local-development-gateway \
122
- test/local_development_gateway_test.rb
176
+ test/*.rb \
177
+ test/database_router/*.rb \
178
+ test/database_router/drivers/*.rb \
179
+ test/database_router/tds/*.rb
123
180
  rake test
124
181
  ```
125
182
 
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,69 @@
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 { |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"
25
+ end
26
+
27
+ routes.sort_by { |route| [route.driver, route.hostname] }
28
+ end
29
+
30
+ private
31
+
32
+ def route(container)
33
+ labels = container.fetch("Labels")
34
+ driver = labels[DRIVER_LABEL]
35
+ hostname = labels[HOSTNAME_LABEL]
36
+ port = labels[PORT_LABEL]
37
+ values = [driver, hostname, port]
38
+ return if values.all?(&:nil?)
39
+ raise Error, "Incomplete labelled database route" if values.any?(&:nil?)
40
+
41
+ unless DatabaseRouter.drivers.any? { |candidate|
42
+ candidate.name == driver
43
+ }
44
+ raise Error, "Unsupported database driver: #{driver}"
45
+ end
46
+ unless HOSTNAME_PATTERN.match?(hostname)
47
+ raise Error, "Invalid labelled database hostname: #{hostname}"
48
+ end
49
+
50
+ port = Integer(port, 10)
51
+ unless (1..65_535).cover?(port)
52
+ raise Error, "Invalid labelled database port: #{port}"
53
+ end
54
+
55
+ target_address =
56
+ container.dig("NetworkSettings", "Networks", NETWORK_NAME, "IPAddress")
57
+ return if target_address.nil? || target_address.empty?
58
+
59
+ DatabaseRouter::Route.new(
60
+ driver: driver,
61
+ hostname: hostname,
62
+ port: port,
63
+ target_address: target_address
64
+ )
65
+ rescue ArgumentError, TypeError
66
+ raise Error, "Invalid labelled database port: #{port}"
67
+ end
68
+ end
69
+ 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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "local_development_gateway/database_router/wire"
5
+ require "local_development_gateway/database_router/drivers/postgre_sql_certificate"
6
+
7
+ module LocalDevelopmentGateway
8
+ module DatabaseRouter::Drivers
9
+ class PostgreSqlDriver
10
+ NAME = "postgresql"
11
+ LISTEN_PORT = 5432
12
+ SSL_REQUEST = [8, 80_877_103].pack("NN").freeze
13
+
14
+ def initialize(certificate: PostgreSqlCertificate.new)
15
+ @certificate = certificate
16
+ end
17
+
18
+ def name
19
+ NAME
20
+ end
21
+
22
+ def listen_port
23
+ LISTEN_PORT
24
+ end
25
+
26
+ def connect(client, routes, connector:)
27
+ deadline = DatabaseRouter::Wire.deadline
28
+ 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"
37
+ end
38
+
39
+ client.write("S")
40
+ hostname = nil
41
+ context =
42
+ @certificate.context do |_socket, name|
43
+ hostname = name&.downcase
44
+ nil
45
+ end
46
+ tls = OpenSSL::SSL::SSLSocket.new(client, context)
47
+ tls.sync_close = false
48
+ DatabaseRouter::Wire.accept_tls(tls, deadline: deadline)
49
+ unless hostname
50
+ raise Error, "PostgreSQL TLS ClientHello does not contain SNI"
51
+ end
52
+
53
+ selected = routes.find { |route| route.hostname == hostname }
54
+ raise Error, "No PostgreSQL route for #{hostname}" unless selected
55
+
56
+ [tls, connector.call(selected)]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
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
+ NAME = "sql_server"
10
+ LISTEN_PORT = 1433
11
+
12
+ def name
13
+ NAME
14
+ end
15
+
16
+ def listen_port
17
+ LISTEN_PORT
18
+ end
19
+
20
+ def connect(client, routes, connector:)
21
+ deadline = DatabaseRouter::Wire.deadline
22
+ prelogin = DatabaseRouter::Tds::Message.read(client, deadline: deadline)
23
+ if routes.empty?
24
+ raise Error, "No labelled sql_server routes are available"
25
+ end
26
+ provisional, target, response =
27
+ negotiate(routes, prelogin, connector, deadline)
28
+ DatabaseRouter::Tds::Message.write(client, response)
29
+
30
+ hostname, messages = read_client_hello(client, deadline)
31
+ selected = routes.find { |route| route.hostname == hostname }
32
+ raise Error, "No SQL Server route for #{hostname}" unless selected
33
+
34
+ if selected != provisional
35
+ target.close
36
+ target = connector.call(selected)
37
+ DatabaseRouter::Tds::Message.write(target, prelogin)
38
+ DatabaseRouter::Tds::Message.read(target, deadline: deadline)
39
+ end
40
+
41
+ messages.each do |message|
42
+ DatabaseRouter::Tds::Message.write(target, message)
43
+ end
44
+ [client, target]
45
+ rescue StandardError
46
+ target&.close
47
+ raise
48
+ end
49
+
50
+ private
51
+
52
+ def negotiate(routes, prelogin, connector, deadline)
53
+ routes.each do |route|
54
+ target = connector.call(route)
55
+ DatabaseRouter::Tds::Message.write(target, prelogin)
56
+ return [
57
+ route,
58
+ target,
59
+ DatabaseRouter::Tds::Message.read(target, deadline: deadline)
60
+ ]
61
+ rescue Error, EOFError, IOError, SystemCallError
62
+ target&.close
63
+ end
64
+ raise Error, "No labelled SQL Server backends are reachable"
65
+ end
66
+
67
+ def read_client_hello(client, deadline)
68
+ hello = DatabaseRouter::Tds::TlsClientHello.new
69
+ messages = []
70
+ loop do
71
+ message =
72
+ DatabaseRouter::Tds::Message.read(client, deadline: deadline)
73
+ messages << message
74
+ hostname = hello.append(message.payload)
75
+ return hostname, messages if hostname
76
+ end
77
+ end
78
+ end
79
+ end
80
+ 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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LocalDevelopmentGateway
4
+ module DatabaseRouter::Wire
5
+ HANDSHAKE_TIMEOUT = 5
6
+
7
+ module_function
8
+
9
+ def deadline
10
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + HANDSHAKE_TIMEOUT
11
+ end
12
+
13
+ def read_exactly(io, length, deadline:)
14
+ bytes = +""
15
+ while bytes.bytesize < length
16
+ wait(io, readable: true, deadline: deadline)
17
+ bytes << io.readpartial(length - bytes.bytesize)
18
+ end
19
+ bytes
20
+ end
21
+
22
+ def read_until_eof(io, max_bytes:, deadline:)
23
+ bytes = +""
24
+ loop do
25
+ wait(io, readable: true, deadline: deadline)
26
+ bytes << io.readpartial(16 * 1024)
27
+ raise Error, "Response is too large" if bytes.bytesize > max_bytes
28
+ end
29
+ rescue EOFError
30
+ bytes
31
+ end
32
+
33
+ def accept_tls(socket, deadline:)
34
+ loop do
35
+ case socket.accept_nonblock(exception: false)
36
+ when :wait_readable
37
+ wait(socket, readable: true, deadline: deadline)
38
+ when :wait_writable
39
+ wait(socket, readable: false, deadline: deadline)
40
+ else
41
+ return socket
42
+ end
43
+ end
44
+ rescue OpenSSL::SSL::SSLError => error
45
+ raise Error, "PostgreSQL TLS handshake failed: #{error.message}"
46
+ end
47
+
48
+ def wait(io, readable:, deadline:)
49
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
+ ready =
51
+ remaining.positive? &&
52
+ IO.select(
53
+ readable ? [io] : nil,
54
+ readable ? nil : [io],
55
+ nil,
56
+ remaining
57
+ )
58
+ raise Error, "Database handshake timed out" unless ready
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,105 @@
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
+ Route = Data.define(:driver, :hostname, :port, :target_address)
11
+
12
+ def self.run
13
+ new.run
14
+ end
15
+
16
+ def self.drivers
17
+ @drivers ||= [
18
+ Drivers::SqlServerDriver.new,
19
+ Drivers::PostgreSqlDriver.new
20
+ ].freeze
21
+ end
22
+
23
+ def initialize(
24
+ routes: DockerRoutes.new,
25
+ drivers: self.class.drivers,
26
+ servers: nil
27
+ )
28
+ @routes = routes
29
+ @drivers = drivers
30
+ @servers =
31
+ servers ||
32
+ drivers.to_h do |driver|
33
+ [driver.name, TCPServer.new("0.0.0.0", driver.listen_port)]
34
+ end
35
+ end
36
+
37
+ def run
38
+ slots = SizedQueue.new(MAX_CONNECTIONS)
39
+ MAX_CONNECTIONS.times { slots << true }
40
+ @drivers
41
+ .map do |driver|
42
+ Thread.new do
43
+ server = @servers.fetch(driver.name)
44
+ loop do
45
+ client = server.accept
46
+ slots.pop
47
+ Thread.new(client) do |connection|
48
+ route(connection, driver)
49
+ ensure
50
+ slots << true
51
+ end
52
+ end
53
+ end
54
+ end
55
+ .each(&:join)
56
+ end
57
+
58
+ def route(client, driver)
59
+ routes = @routes.call.select { |route| route.driver == driver.name }
60
+
61
+ source, target =
62
+ driver.connect(client, routes, connector: method(:connect))
63
+ proxy(source, target)
64
+ rescue EOFError
65
+ nil
66
+ rescue Error => error
67
+ warn error.message
68
+ rescue StandardError => error
69
+ warn error.full_message
70
+ ensure
71
+ source&.close unless source.equal?(client)
72
+ client&.close
73
+ target&.close
74
+ end
75
+
76
+ private
77
+
78
+ def connect(route)
79
+ Socket.tcp(
80
+ route.target_address,
81
+ route.port,
82
+ connect_timeout: CONNECT_TIMEOUT
83
+ )
84
+ 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
+ end
101
+ end
102
+
103
+ require "local_development_gateway/database_router/docker_routes"
104
+ require "local_development_gateway/database_router/drivers/sql_server_driver"
105
+ 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.0"
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.0
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-08-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.
@@ -24,6 +24,16 @@ 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/tds/message.rb
34
+ - lib/local_development_gateway/database_router/tds/tls_byte_reader.rb
35
+ - lib/local_development_gateway/database_router/tds/tls_client_hello.rb
36
+ - lib/local_development_gateway/database_router/wire.rb
27
37
  - lib/local_development_gateway/version.rb
28
38
  homepage: https://github.com/icefoganalytics/local-development-gateway
29
39
  licenses: