local-development-gateway 0.2.0 → 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 +4 -4
- data/README.md +29 -5
- data/lib/local_development_gateway/database_router/docker_routes.rb +24 -9
- data/lib/local_development_gateway/database_router/drivers/postgre_sql_driver.rb +79 -13
- data/lib/local_development_gateway/database_router/drivers/sql_server_driver.rb +27 -5
- data/lib/local_development_gateway/database_router/postgre_sql/cancellation_routes.rb +47 -0
- data/lib/local_development_gateway/database_router/postgre_sql/session.rb +46 -0
- data/lib/local_development_gateway/database_router/wire.rb +63 -2
- data/lib/local_development_gateway/database_router.rb +31 -30
- data/lib/local_development_gateway/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8baffa29acc52adfcc3112cacc9be936b24528915f86fbc94c323df081e05fd5
|
|
4
|
+
data.tar.gz: 4ab5156e3dc8dae143345b3c58271be2afd4b3485fb92c6bfd18153dd1958734
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ed0938cfb210bda2c320f6c481af5ddabf9f32d8f04fcac7ac94f60bbc9df8da98fe71f7929581d54ab5a20b648c339b92cd78f799b50fb74f3300e64997e0c
|
|
7
|
+
data.tar.gz: b1acdaefc1c2b69b1e101fb5516ee8de7de866e9d9b1d11bd2390d14a860204a869ca8d90a10123a9b304b954fb03297c26b80482743105bee5a53f3bad06c22
|
data/README.md
CHANGED
|
@@ -143,18 +143,27 @@ connects to `db.issue-567.wrap.localhost` on the driver's standard port:
|
|
|
143
143
|
Use the database container's actual internal port in
|
|
144
144
|
`local-gateway.tcp.port`; it does not need to match the host listener.
|
|
145
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
|
+
|
|
146
152
|
All `.localhost` names resolve to loopback. The gateway publishes loopback-only
|
|
147
153
|
listeners, discovers labelled containers through Docker, and uses the database
|
|
148
154
|
protocol's encrypted handshake to select the hostname-labelled container.
|
|
149
155
|
Container replacements and stopped routes require no consumer lifecycle code
|
|
150
156
|
or cleanup.
|
|
151
157
|
|
|
152
|
-
SQL Server encryption remains end to end through the router.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
156
164
|
[`docs/architecture/local-tcp-routing.md`](docs/architecture/local-tcp-routing.md)
|
|
157
|
-
for the complete contract and security boundary
|
|
165
|
+
for the complete contract and security boundary, including the reason for
|
|
166
|
+
retaining cancellation-aware PostgreSQL routing despite Traefik's STARTTLS support.
|
|
158
167
|
|
|
159
168
|
## Development checks
|
|
160
169
|
|
|
@@ -169,6 +178,7 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
|
|
|
169
178
|
lib/local_development_gateway/database_router.rb \
|
|
170
179
|
lib/local_development_gateway/database_router/*.rb \
|
|
171
180
|
lib/local_development_gateway/database_router/drivers/*.rb \
|
|
181
|
+
lib/local_development_gateway/database_router/postgre_sql/*.rb \
|
|
172
182
|
lib/local_development_gateway/database_router/tds/*.rb \
|
|
173
183
|
lib/local_development_gateway/version.rb \
|
|
174
184
|
bin/dev \
|
|
@@ -176,6 +186,7 @@ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
|
|
|
176
186
|
test/*.rb \
|
|
177
187
|
test/database_router/*.rb \
|
|
178
188
|
test/database_router/drivers/*.rb \
|
|
189
|
+
test/database_router/postgre_sql/*.rb \
|
|
179
190
|
test/database_router/tds/*.rb
|
|
180
191
|
rake test
|
|
181
192
|
```
|
|
@@ -251,3 +262,16 @@ It mounts the Docker socket so it can discover labelled containers. A read-only
|
|
|
251
262
|
| Generated hostname returns `404` | Confirm the web container has the route labels and is attached to `local-gateway`. |
|
|
252
263
|
| A project uses normal ports unexpectedly | Confirm the gateway container is running and has `local-gateway=true`. |
|
|
253
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.
|
|
@@ -18,18 +18,35 @@ module LocalDevelopmentGateway
|
|
|
18
18
|
routes =
|
|
19
19
|
@client
|
|
20
20
|
.get("/containers/json")
|
|
21
|
-
.filter_map
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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(
|
|
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
|
|
33
|
+
def connect(client, routes:, connector:)
|
|
27
34
|
deadline = DatabaseRouter::Wire.deadline
|
|
28
35
|
request =
|
|
29
|
-
DatabaseRouter::Wire.read_exactly(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
39
|
-
|
|
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 =
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
@@ -30,6 +30,8 @@ files:
|
|
|
30
30
|
- lib/local_development_gateway/database_router/drivers/postgre_sql_certificate.rb
|
|
31
31
|
- lib/local_development_gateway/database_router/drivers/postgre_sql_driver.rb
|
|
32
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
|
|
33
35
|
- lib/local_development_gateway/database_router/tds/message.rb
|
|
34
36
|
- lib/local_development_gateway/database_router/tds/tls_byte_reader.rb
|
|
35
37
|
- lib/local_development_gateway/database_router/tds/tls_client_hello.rb
|