cloud_sql_ruby_connector 1.0.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.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative "../cloud_sql_ruby_connector"
18
+ require "active_record"
19
+ require "active_record/connection_adapters/postgresql_adapter"
20
+
21
+ module CloudSQLRubyConnector
22
+ # Rails integration for Cloud SQL Connector
23
+ #
24
+ # @example Usage in config/initializers/cloud_sql.rb
25
+ # require "cloud_sql_ruby_connector/rails"
26
+ #
27
+ # CloudSQLRubyConnector::Rails.setup!(
28
+ # instance: "project:region:instance",
29
+ # ip_type: :private,
30
+ # auth_type: :iam
31
+ # )
32
+ #
33
+ # @example Then in database.yml
34
+ # production:
35
+ # adapter: cloud_sql_postgresql
36
+ # database: myapp_production
37
+ # username: myuser@project.iam.gserviceaccount.com
38
+ # pool: 5
39
+ #
40
+ module Rails
41
+ class << self
42
+ attr_reader :connector
43
+
44
+ # Set up the Cloud SQL connector for Rails
45
+ #
46
+ # @param instance [String] Cloud SQL instance connection name
47
+ # @param ip_type [String, Symbol] IP address type (default: :public)
48
+ # @param auth_type [String, Symbol] Authentication type (default: :password)
49
+ # @param credentials [Credentials::Base] Optional custom credentials
50
+ def setup!(instance:, ip_type: IpAddressTypes::PUBLIC, auth_type: AuthTypes::PASSWORD, credentials: nil)
51
+ @connector = PostgreSQL::Connector.new(
52
+ instance,
53
+ ip_type: ip_type,
54
+ auth_type: auth_type,
55
+ credentials: credentials
56
+ )
57
+ register_adapter!
58
+ register_shutdown_hook!
59
+ end
60
+
61
+ # Close the connector and clean up resources
62
+ def shutdown!
63
+ @connector&.close
64
+ @connector = nil
65
+ end
66
+
67
+ private
68
+
69
+ def register_adapter!
70
+ ActiveRecord::ConnectionAdapters.register(
71
+ "cloud_sql_postgresql",
72
+ "CloudSQLRubyConnector::Rails::CloudSQLPostgreSQLAdapter",
73
+ "cloud_sql_ruby_connector/rails"
74
+ )
75
+ end
76
+
77
+ def register_shutdown_hook!
78
+ at_exit { shutdown! }
79
+ end
80
+ end
81
+
82
+ # Custom adapter that uses CloudSQLRubyConnector for connections
83
+ class CloudSQLPostgreSQLAdapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
84
+ ADAPTER_NAME = "CloudSQL PostgreSQL"
85
+
86
+ # PG connection options that can be passed through to PG.connect
87
+ # See: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
88
+ ALLOWED_PG_OPTIONS = %i[
89
+ application_name
90
+ client_encoding
91
+ connect_timeout
92
+ options
93
+ keepalives
94
+ keepalives_idle
95
+ keepalives_interval
96
+ keepalives_count
97
+ tcp_user_timeout
98
+ target_session_attrs
99
+ ].freeze
100
+
101
+ class << self
102
+ # In Rails 7.1+, new_client receives processed conn_params (user, dbname)
103
+ # not the raw config (username, database)
104
+ def new_client(conn_params)
105
+ connector = CloudSQLRubyConnector::Rails.connector
106
+ raise ConfigurationError, "CloudSQLRubyConnector::Rails.setup! not called" unless connector
107
+
108
+ # conn_params already has :user and :dbname (processed by parent's initialize)
109
+ cfg = if conn_params.respond_to?(:symbolize_keys)
110
+ conn_params.symbolize_keys
111
+ else
112
+ conn_params.to_h.transform_keys(&:to_sym)
113
+ end
114
+
115
+ # Only pass through known PG connection options
116
+ extra_options = cfg.slice(*ALLOWED_PG_OPTIONS)
117
+
118
+ connector.connect(
119
+ user: cfg[:user],
120
+ password: cfg[:password],
121
+ dbname: cfg[:dbname],
122
+ **extra_options
123
+ )
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "net/http"
18
+ require "json"
19
+ require "openssl"
20
+
21
+ module CloudSQLRubyConnector
22
+ # Fetches instance metadata and ephemeral certificates from Cloud SQL Admin API
23
+ class SQLAdminFetcher
24
+ API_VERSION = "v1beta4"
25
+ DEFAULT_ENDPOINT = "https://sqladmin.googleapis.com"
26
+ HTTP_TIMEOUT = 30 # seconds
27
+
28
+ def initialize(credentials:, api_endpoint: nil)
29
+ @credentials = credentials
30
+ @api_endpoint = api_endpoint || DEFAULT_ENDPOINT
31
+ end
32
+
33
+ # Fetch instance metadata including IP addresses and server CA certificate
34
+ # @param project [String] Google Cloud project ID
35
+ # @param region [String] Cloud SQL instance region
36
+ # @param instance [String] Cloud SQL instance name
37
+ # @return [Hash] instance metadata
38
+ def fetch_metadata(project:, region:, instance:)
39
+ token = @credentials.access_token(scope: :admin)
40
+ uri = URI("#{@api_endpoint}/sql/#{API_VERSION}/projects/#{project}/instances/#{instance}/connectSettings")
41
+
42
+ response = http_get(uri, token)
43
+ data = parse_response(response)
44
+
45
+ raise ConfigurationError, "Region mismatch: expected #{region}, got #{data["region"]}" if data["region"] != region
46
+
47
+ ip_addresses = parse_ip_addresses(
48
+ data["ipAddresses"],
49
+ data["dnsName"],
50
+ data["dnsNames"],
51
+ data["pscEnabled"]
52
+ )
53
+
54
+ server_ca_cert = data.dig("serverCaCert", "cert")
55
+ raise ConnectionError, "No valid CA certificate found for instance" if server_ca_cert.nil?
56
+
57
+ {
58
+ ip_addresses: ip_addresses,
59
+ server_ca_cert: server_ca_cert,
60
+ database_version: data["databaseVersion"],
61
+ dns_name: data["dnsName"]
62
+ }
63
+ end
64
+
65
+ # Fetch an ephemeral certificate for client authentication
66
+ # @param project [String] Google Cloud project ID
67
+ # @param instance [String] Cloud SQL instance name
68
+ # @param public_key [String] RSA public key in PEM format
69
+ # @param auth_type [String] Authentication type (PASSWORD or IAM)
70
+ # @return [Hash] certificate data with :cert and :expiration
71
+ def fetch_ephemeral_cert(project:, instance:, public_key:, auth_type:)
72
+ token = @credentials.access_token(scope: :admin)
73
+ uri = URI("#{@api_endpoint}/sql/#{API_VERSION}/projects/#{project}/instances/#{instance}:generateEphemeralCert")
74
+
75
+ body = { "public_key" => public_key }
76
+
77
+ # For IAM auth, include the login token
78
+ if auth_type == AuthTypes::IAM
79
+ login_token = @credentials.access_token(scope: :login)
80
+ body["access_token"] = login_token
81
+ end
82
+
83
+ response = http_post(uri, token, body)
84
+ data = parse_response(response)
85
+
86
+ cert_pem = data.dig("ephemeralCert", "cert")
87
+ raise ConnectionError, "Failed to retrieve ephemeral certificate" if cert_pem.nil?
88
+
89
+ cert = OpenSSL::X509::Certificate.new(cert_pem)
90
+
91
+ {
92
+ cert: cert_pem,
93
+ expiration: cert.not_after
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def parse_response(response)
100
+ data = JSON.parse(response.body)
101
+
102
+ if response.code.to_i >= 400
103
+ error_msg = data.dig("error", "message") || response.body
104
+ raise ConnectionError, "API request failed: #{error_msg}"
105
+ end
106
+
107
+ data
108
+ rescue JSON::ParserError => e
109
+ raise ConnectionError, "Invalid API response: #{e.message}"
110
+ end
111
+
112
+ # Parse IP addresses from API response
113
+ # Node.js ref: https://github.com/GoogleCloudPlatform/cloud-sql-nodejs-connector/blob/main/src/sqladmin-fetcher.ts
114
+ def parse_ip_addresses(ip_data, dns_name, dns_names, psc_enabled)
115
+ ip_addresses = {}
116
+
117
+ # Parse regular IP addresses (PUBLIC/PRIVATE)
118
+ ip_data&.each do |ip|
119
+ case ip["type"]
120
+ when "PRIMARY"
121
+ ip_addresses["PRIMARY"] = ip["ipAddress"]
122
+ when "PRIVATE"
123
+ ip_addresses["PRIVATE"] = ip["ipAddress"]
124
+ end
125
+ end
126
+
127
+ # PSC uses DNS names, not IP addresses
128
+ # First, check dns_names array for PSC connection type
129
+ if dns_names.is_a?(Array)
130
+ dns_names.each do |dnm|
131
+ if dnm["connectionType"] == "PRIVATE_SERVICE_CONNECT" && dnm["dnsScope"] == "INSTANCE"
132
+ ip_addresses["PSC"] = dnm["name"]
133
+ break
134
+ end
135
+ end
136
+ end
137
+
138
+ # Fallback to legacy dns_name field if PSC not found and pscEnabled is true
139
+ ip_addresses["PSC"] = dns_name if ip_addresses["PSC"].nil? && dns_name && psc_enabled
140
+
141
+ ip_addresses
142
+ end
143
+
144
+ def http_get(uri, token)
145
+ http = create_http_client(uri)
146
+
147
+ request = Net::HTTP::Get.new(uri)
148
+ request["Authorization"] = "Bearer #{token}"
149
+ request["Content-Type"] = "application/json"
150
+
151
+ http.request(request)
152
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
153
+ raise ConnectionError, "Request timed out: #{e.message}"
154
+ rescue StandardError => e
155
+ raise ConnectionError, "HTTP request failed: #{e.message}"
156
+ end
157
+
158
+ def http_post(uri, token, body)
159
+ http = create_http_client(uri)
160
+
161
+ request = Net::HTTP::Post.new(uri)
162
+ request["Authorization"] = "Bearer #{token}"
163
+ request["Content-Type"] = "application/json"
164
+ request.body = body.to_json
165
+
166
+ http.request(request)
167
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
168
+ raise ConnectionError, "Request timed out: #{e.message}"
169
+ rescue StandardError => e
170
+ raise ConnectionError, "HTTP request failed: #{e.message}"
171
+ end
172
+
173
+ def create_http_client(uri)
174
+ http = Net::HTTP.new(uri.host, uri.port)
175
+ http.use_ssl = true
176
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
177
+ http.open_timeout = HTTP_TIMEOUT
178
+ http.read_timeout = HTTP_TIMEOUT
179
+ http
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "socket"
18
+
19
+ module CloudSQLRubyConnector
20
+ # Local TCP proxy that bridges pg (plain) to Cloud SQL (SSL)
21
+ #
22
+ # This proxy is necessary because Cloud SQL requires direct TLS connections,
23
+ # but libpq (PostgreSQL client) sends an SSLRequest message first, which
24
+ # Cloud SQL doesn't understand. The proxy accepts plain TCP connections
25
+ # locally and forwards them over the pre-established SSL connection.
26
+ class SslProxy
27
+ attr_reader :port
28
+
29
+ def initialize(ssl_socket)
30
+ @ssl_socket = ssl_socket
31
+ @server = TCPServer.new("127.0.0.1", 0) # Port 0 = kernel assigns available port
32
+ @port = @server.addr[1]
33
+ @running = false
34
+ @threads = []
35
+ @mutex = Mutex.new
36
+ end
37
+
38
+ # Start the proxy server in a background thread
39
+ def start
40
+ @running = true
41
+ @accept_thread = Thread.new { accept_loop }
42
+ end
43
+
44
+ # Stop the proxy server and clean up resources
45
+ def stop
46
+ @mutex.synchronize do
47
+ @running = false
48
+ @accept_thread&.kill if @accept_thread&.alive?
49
+ @threads.each { |t| t.kill if t.alive? }
50
+ @threads.clear
51
+ begin
52
+ @server.close
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ begin
57
+ @ssl_socket.close
58
+ rescue StandardError
59
+ nil
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def accept_loop
67
+ client = @server.accept
68
+ @server.close # Close listener immediately - we only need one client
69
+
70
+ @mutex.synchronize do
71
+ @threads << Thread.new { forward(client, @ssl_socket) }
72
+ @threads << Thread.new { forward(@ssl_socket, client) }
73
+ end
74
+ rescue IOError
75
+ # Server closed
76
+ end
77
+
78
+ def forward(from, to)
79
+ while @running
80
+ data = from.readpartial(16_384)
81
+ to.write(data)
82
+ end
83
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
84
+ # Close the other side to unblock the paired thread
85
+ begin
86
+ to.close
87
+ rescue StandardError
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module CloudSQLRubyConnector
18
+ VERSION = "1.0.0"
19
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative "cloud_sql_ruby_connector/version"
18
+ require_relative "cloud_sql_ruby_connector/errors"
19
+ require_relative "cloud_sql_ruby_connector/ip_address_types"
20
+ require_relative "cloud_sql_ruby_connector/auth_types"
21
+ require_relative "cloud_sql_ruby_connector/credentials/base"
22
+ require_relative "cloud_sql_ruby_connector/credentials/service_account"
23
+ require_relative "cloud_sql_ruby_connector/credentials/user_credentials"
24
+ require_relative "cloud_sql_ruby_connector/credentials/metadata"
25
+ require_relative "cloud_sql_ruby_connector/ssl_proxy"
26
+ require_relative "cloud_sql_ruby_connector/sqladmin_fetcher"
27
+ require_relative "cloud_sql_ruby_connector/postgresql/connector"
28
+
29
+ # Cloud SQL Ruby Connector
30
+ #
31
+ # A Ruby connector for Google Cloud SQL that provides secure, IAM-based
32
+ # authentication without requiring the Cloud SQL Auth Proxy.
33
+ #
34
+ # @example Basic usage with PostgreSQL
35
+ # require 'cloud_sql_ruby_connector'
36
+ # require 'pg'
37
+ #
38
+ # connector = CloudSQLRubyConnector::PostgreSQL::Connector.new("my-project:us-central1:my-instance")
39
+ # conn = connector.connect(user: "myuser", password: "mypass", dbname: "mydb")
40
+ # result = conn.exec("SELECT NOW()")
41
+ # puts result.first
42
+ # conn.close
43
+ # connector.close
44
+ #
45
+ # @example Using IAM authentication
46
+ # connector = CloudSQLRubyConnector::PostgreSQL::Connector.new(
47
+ # "my-project:us-central1:my-instance",
48
+ # auth_type: :iam,
49
+ # ip_type: :private
50
+ # )
51
+ # conn = connector.connect(
52
+ # user: "service-account@project.iam.gserviceaccount.com",
53
+ # dbname: "mydb"
54
+ # )
55
+ #
56
+ module CloudSQLRubyConnector
57
+ module PostgreSQL
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cloud_sql_ruby_connector
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Martin Milo
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-01-17 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ description: An unofficial Ruby connector for Google Cloud SQL that provides secure,
27
+ IAM-based authentication without requiring the Cloud SQL Auth Proxy.
28
+ email:
29
+ - milomartin.za@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - CODE_OF_CONDUCT.md
36
+ - CONTRIBUTING.md
37
+ - LICENSE
38
+ - README.md
39
+ - SECURITY.md
40
+ - lib/cloud_sql_ruby_connector.rb
41
+ - lib/cloud_sql_ruby_connector/auth_types.rb
42
+ - lib/cloud_sql_ruby_connector/credentials/base.rb
43
+ - lib/cloud_sql_ruby_connector/credentials/metadata.rb
44
+ - lib/cloud_sql_ruby_connector/credentials/service_account.rb
45
+ - lib/cloud_sql_ruby_connector/credentials/user_credentials.rb
46
+ - lib/cloud_sql_ruby_connector/errors.rb
47
+ - lib/cloud_sql_ruby_connector/ip_address_types.rb
48
+ - lib/cloud_sql_ruby_connector/postgresql/connector.rb
49
+ - lib/cloud_sql_ruby_connector/rails.rb
50
+ - lib/cloud_sql_ruby_connector/sqladmin_fetcher.rb
51
+ - lib/cloud_sql_ruby_connector/ssl_proxy.rb
52
+ - lib/cloud_sql_ruby_connector/version.rb
53
+ homepage: https://github.com/martinmilo/cloud-sql-ruby-connector
54
+ licenses:
55
+ - Apache-2.0
56
+ metadata:
57
+ homepage_uri: https://github.com/martinmilo/cloud-sql-ruby-connector
58
+ source_code_uri: https://github.com/martinmilo/cloud-sql-ruby-connector
59
+ changelog_uri: https://github.com/martinmilo/cloud-sql-ruby-connector/blob/main/CHANGELOG.md
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.3.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.6.2
76
+ specification_version: 4
77
+ summary: Cloud SQL Ruby Connector
78
+ test_files: []