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 +4 -4
- data/README.md +60 -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 +69 -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 +60 -0
- data/lib/local_development_gateway/database_router/drivers/sql_server_driver.rb +80 -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 +61 -0
- data/lib/local_development_gateway/database_router.rb +105 -0
- data/lib/local_development_gateway/version.rb +1 -1
- data/lib/local_development_gateway.rb +25 -9
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad3ccae73de13d5b8f94f5745116e2c87cb323421b0df17efa76376648c5c99f
|
|
4
|
+
data.tar.gz: 628768727722325fcb3195d849de79e4c28e62f903618aaa50caf5b40e128500
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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,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
|
|
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,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.
|
|
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-
|
|
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:
|