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 +4 -4
- data/README.md +84 -3
- data/docker-compose.yml +31 -0
- data/lib/local_development_gateway/database_router/docker_api.rb +65 -0
- data/lib/local_development_gateway/database_router/docker_routes.rb +84 -0
- data/lib/local_development_gateway/database_router/drivers/postgre_sql_certificate.rb +44 -0
- data/lib/local_development_gateway/database_router/drivers/postgre_sql_driver.rb +126 -0
- data/lib/local_development_gateway/database_router/drivers/sql_server_driver.rb +102 -0
- 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/tds/message.rb +58 -0
- data/lib/local_development_gateway/database_router/tds/tls_byte_reader.rb +40 -0
- data/lib/local_development_gateway/database_router/tds/tls_client_hello.rb +85 -0
- data/lib/local_development_gateway/database_router/wire.rb +122 -0
- data/lib/local_development_gateway/database_router.rb +106 -0
- data/lib/local_development_gateway/version.rb +1 -1
- data/lib/local_development_gateway.rb +25 -9
- metadata +14 -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
|
@@ -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.
|
|
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.
|
|
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
|
|
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,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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? &&
|
|
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=#{
|
|
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]
|
|
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
|
|
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.
|
|
@@ -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:
|