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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +43 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +202 -0
- data/README.md +233 -0
- data/SECURITY.md +11 -0
- data/lib/cloud_sql_ruby_connector/auth_types.rb +41 -0
- data/lib/cloud_sql_ruby_connector/credentials/base.rb +61 -0
- data/lib/cloud_sql_ruby_connector/credentials/metadata.rb +50 -0
- data/lib/cloud_sql_ruby_connector/credentials/service_account.rb +115 -0
- data/lib/cloud_sql_ruby_connector/credentials/user_credentials.rb +73 -0
- data/lib/cloud_sql_ruby_connector/errors.rb +55 -0
- data/lib/cloud_sql_ruby_connector/ip_address_types.rb +51 -0
- data/lib/cloud_sql_ruby_connector/postgresql/connector.rb +255 -0
- data/lib/cloud_sql_ruby_connector/rails.rb +128 -0
- data/lib/cloud_sql_ruby_connector/sqladmin_fetcher.rb +182 -0
- data/lib/cloud_sql_ruby_connector/ssl_proxy.rb +92 -0
- data/lib/cloud_sql_ruby_connector/version.rb +19 -0
- data/lib/cloud_sql_ruby_connector.rb +59 -0
- metadata +78 -0
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
20
|
+
module CloudSQLRubyConnector
|
|
21
|
+
module Credentials
|
|
22
|
+
# GCE/Cloud Run metadata server credentials
|
|
23
|
+
class Metadata < Base
|
|
24
|
+
METADATA_BASE = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default"
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def refresh_token(scope)
|
|
29
|
+
uri = URI("#{METADATA_BASE}/token?scopes=#{SCOPES[scope]}")
|
|
30
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
31
|
+
http.open_timeout = 2
|
|
32
|
+
http.read_timeout = 2
|
|
33
|
+
|
|
34
|
+
request = Net::HTTP::Get.new(uri)
|
|
35
|
+
request["Metadata-Flavor"] = "Google"
|
|
36
|
+
|
|
37
|
+
response = http.request(request)
|
|
38
|
+
data = JSON.parse(response.body)
|
|
39
|
+
|
|
40
|
+
raise AuthenticationError, "Failed to get metadata token: #{response.body}" unless response.code.to_i == 200
|
|
41
|
+
|
|
42
|
+
store_token(scope, data["access_token"], data["expires_in"])
|
|
43
|
+
rescue Errno::EHOSTUNREACH, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
|
|
44
|
+
raise AuthenticationError, "Not running on GCE/Cloud Run and no credentials found"
|
|
45
|
+
rescue JSON::ParserError => e
|
|
46
|
+
raise AuthenticationError, "Invalid metadata response: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
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 "openssl"
|
|
18
|
+
require "net/http"
|
|
19
|
+
require "json"
|
|
20
|
+
require "base64"
|
|
21
|
+
|
|
22
|
+
module CloudSQLRubyConnector
|
|
23
|
+
module Credentials
|
|
24
|
+
# Service Account credentials from JSON key file
|
|
25
|
+
class ServiceAccount < Base
|
|
26
|
+
attr_reader :client_email
|
|
27
|
+
|
|
28
|
+
# Load credentials from a JSON file
|
|
29
|
+
# @param path [String] path to the JSON key file
|
|
30
|
+
# @return [ServiceAccount, UserCredentials] depending on the credential type
|
|
31
|
+
def self.from_file(path)
|
|
32
|
+
data = JSON.parse(File.read(path))
|
|
33
|
+
|
|
34
|
+
if data["type"] == "authorized_user"
|
|
35
|
+
UserCredentials.new(data)
|
|
36
|
+
else
|
|
37
|
+
new(data)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(data)
|
|
42
|
+
super()
|
|
43
|
+
validate_required_fields!(data)
|
|
44
|
+
@client_email = data["client_email"]
|
|
45
|
+
@private_key = OpenSSL::PKey::RSA.new(data["private_key"])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def validate_required_fields!(data)
|
|
51
|
+
raise ConfigurationError, "Missing 'client_email' in service account credentials" if data["client_email"].nil?
|
|
52
|
+
raise ConfigurationError, "Missing 'private_key' in service account credentials" if data["private_key"].nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
def refresh_token(scope)
|
|
58
|
+
now = Time.now.to_i
|
|
59
|
+
payload = {
|
|
60
|
+
iss: @client_email,
|
|
61
|
+
scope: SCOPES[scope],
|
|
62
|
+
aud: TOKEN_URI,
|
|
63
|
+
iat: now,
|
|
64
|
+
exp: now + 3600
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
jwt = encode_jwt(payload)
|
|
68
|
+
|
|
69
|
+
uri = URI(TOKEN_URI)
|
|
70
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
71
|
+
http.use_ssl = true
|
|
72
|
+
http.open_timeout = 10
|
|
73
|
+
http.read_timeout = 10
|
|
74
|
+
|
|
75
|
+
request = Net::HTTP::Post.new(uri)
|
|
76
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
77
|
+
request.body = URI.encode_www_form(
|
|
78
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
79
|
+
assertion: jwt
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
response = http.request(request)
|
|
83
|
+
data = JSON.parse(response.body)
|
|
84
|
+
|
|
85
|
+
unless response.code.to_i == 200
|
|
86
|
+
raise AuthenticationError, "Failed to get access token: #{data["error_description"] || data["error"]}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
store_token(scope, data["access_token"], data["expires_in"])
|
|
90
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
91
|
+
raise AuthenticationError, "Token request timed out: #{e.message}"
|
|
92
|
+
rescue JSON::ParserError => e
|
|
93
|
+
raise AuthenticationError, "Invalid token response: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def encode_jwt(payload)
|
|
99
|
+
header = { alg: "RS256", typ: "JWT" }
|
|
100
|
+
segments = [
|
|
101
|
+
base64url_encode(header.to_json),
|
|
102
|
+
base64url_encode(payload.to_json)
|
|
103
|
+
]
|
|
104
|
+
signing_input = segments.join(".")
|
|
105
|
+
signature = @private_key.sign("SHA256", signing_input)
|
|
106
|
+
segments << base64url_encode(signature)
|
|
107
|
+
segments.join(".")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def base64url_encode(data)
|
|
111
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
20
|
+
module CloudSQLRubyConnector
|
|
21
|
+
module Credentials
|
|
22
|
+
# User credentials from gcloud auth application-default login
|
|
23
|
+
class UserCredentials < Base
|
|
24
|
+
def initialize(data)
|
|
25
|
+
super()
|
|
26
|
+
validate_required_fields!(data)
|
|
27
|
+
@client_id = data["client_id"]
|
|
28
|
+
@client_secret = data["client_secret"]
|
|
29
|
+
@refresh_token_value = data["refresh_token"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_required_fields!(data)
|
|
35
|
+
raise ConfigurationError, "Missing 'client_id' in user credentials" if data["client_id"].nil?
|
|
36
|
+
raise ConfigurationError, "Missing 'client_secret' in user credentials" if data["client_secret"].nil?
|
|
37
|
+
raise ConfigurationError, "Missing 'refresh_token' in user credentials" if data["refresh_token"].nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
protected
|
|
41
|
+
|
|
42
|
+
def refresh_token(scope)
|
|
43
|
+
uri = URI(TOKEN_URI)
|
|
44
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
45
|
+
http.use_ssl = true
|
|
46
|
+
http.open_timeout = 10
|
|
47
|
+
http.read_timeout = 10
|
|
48
|
+
|
|
49
|
+
request = Net::HTTP::Post.new(uri)
|
|
50
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
51
|
+
request.body = URI.encode_www_form(
|
|
52
|
+
client_id: @client_id,
|
|
53
|
+
client_secret: @client_secret,
|
|
54
|
+
refresh_token: @refresh_token_value,
|
|
55
|
+
grant_type: "refresh_token"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
response = http.request(request)
|
|
59
|
+
data = JSON.parse(response.body)
|
|
60
|
+
|
|
61
|
+
unless response.code.to_i == 200
|
|
62
|
+
raise AuthenticationError, "Failed to refresh token: #{data["error_description"] || data["error"]}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
store_token(scope, data["access_token"], data["expires_in"])
|
|
66
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
67
|
+
raise AuthenticationError, "Token refresh timed out: #{e.message}"
|
|
68
|
+
rescue JSON::ParserError => e
|
|
69
|
+
raise AuthenticationError, "Invalid token response: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
# Base error class for all Cloud SQL Connector errors
|
|
19
|
+
class Error < StandardError
|
|
20
|
+
attr_reader :code
|
|
21
|
+
|
|
22
|
+
def initialize(message, code: nil)
|
|
23
|
+
@code = code
|
|
24
|
+
super(message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when authentication fails
|
|
29
|
+
class AuthenticationError < Error
|
|
30
|
+
def initialize(message, code: "EAUTH")
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raised when connection to Cloud SQL instance fails
|
|
36
|
+
class ConnectionError < Error
|
|
37
|
+
def initialize(message, code: "ECONNECTION")
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Raised when configuration is invalid
|
|
43
|
+
class ConfigurationError < Error
|
|
44
|
+
def initialize(message, code: "ECONFIG")
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised when IP address is not available
|
|
50
|
+
class IpAddressError < Error
|
|
51
|
+
def initialize(message, code: "ENOIPADDRESS")
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
# IP address types for Cloud SQL connections
|
|
19
|
+
module IpAddressTypes
|
|
20
|
+
PUBLIC = "PUBLIC"
|
|
21
|
+
PRIVATE = "PRIVATE"
|
|
22
|
+
PSC = "PSC"
|
|
23
|
+
|
|
24
|
+
ALL = [PUBLIC, PRIVATE, PSC].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def valid?(type)
|
|
28
|
+
ALL.include?(type.to_s.upcase)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize(type)
|
|
32
|
+
normalized = type.to_s.upcase
|
|
33
|
+
unless valid?(normalized)
|
|
34
|
+
raise ConfigurationError,
|
|
35
|
+
"Invalid IP address type: #{type}. Valid types: #{ALL.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
normalized
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the API key used in Cloud SQL Admin API response
|
|
42
|
+
def api_key(type)
|
|
43
|
+
case normalize(type)
|
|
44
|
+
when PUBLIC then "PRIMARY"
|
|
45
|
+
when PRIVATE then "PRIVATE"
|
|
46
|
+
when PSC then "PSC"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
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 "openssl"
|
|
18
|
+
require "socket"
|
|
19
|
+
|
|
20
|
+
module CloudSQLRubyConnector
|
|
21
|
+
module PostgreSQL
|
|
22
|
+
# PostgreSQL connector for Cloud SQL connections
|
|
23
|
+
#
|
|
24
|
+
# Provides secure, authenticated connections to Cloud SQL PostgreSQL instances
|
|
25
|
+
# using ephemeral certificates and optional IAM authentication.
|
|
26
|
+
#
|
|
27
|
+
# @example Basic usage
|
|
28
|
+
# connector = CloudSQLRubyConnector::PostgreSQL::Connector.new("my-project:us-central1:my-instance")
|
|
29
|
+
# conn = connector.connect(user: "myuser", password: "mypass", dbname: "mydb")
|
|
30
|
+
# result = conn.exec("SELECT NOW()")
|
|
31
|
+
# conn.close
|
|
32
|
+
# connector.close
|
|
33
|
+
#
|
|
34
|
+
# @example Using IAM authentication
|
|
35
|
+
# connector = CloudSQLRubyConnector::PostgreSQL::Connector.new(
|
|
36
|
+
# "my-project:us-central1:my-instance",
|
|
37
|
+
# auth_type: :iam
|
|
38
|
+
# )
|
|
39
|
+
# conn = connector.connect(user: "sa@project.iam", dbname: "mydb")
|
|
40
|
+
#
|
|
41
|
+
class Connector
|
|
42
|
+
CLOUD_SQL_PORT = 3307
|
|
43
|
+
CERT_REFRESH_BUFFER = 300 # Refresh certificate 5 minutes before expiration
|
|
44
|
+
|
|
45
|
+
attr_reader :project, :region, :instance_name, :ip_type, :auth_type
|
|
46
|
+
|
|
47
|
+
# Initialize a new connector
|
|
48
|
+
#
|
|
49
|
+
# @param instance_connection_name [String] Cloud SQL instance connection name (PROJECT:REGION:INSTANCE)
|
|
50
|
+
# @param credentials [Credentials::Base] Optional credentials object
|
|
51
|
+
# @param ip_type [String, Symbol] IP address type: :public, :private, or :psc (default: :public)
|
|
52
|
+
# @param auth_type [String, Symbol] Authentication type: :password or :iam (default: :password)
|
|
53
|
+
# @param api_endpoint [String] Optional custom API endpoint
|
|
54
|
+
def initialize(instance_connection_name, credentials: nil, ip_type: IpAddressTypes::PUBLIC,
|
|
55
|
+
auth_type: AuthTypes::PASSWORD, api_endpoint: nil)
|
|
56
|
+
@project, @region, @instance_name = parse_connection_name(instance_connection_name)
|
|
57
|
+
@ip_type = IpAddressTypes.normalize(ip_type)
|
|
58
|
+
@auth_type = AuthTypes.normalize(auth_type)
|
|
59
|
+
@credentials = credentials || default_credentials
|
|
60
|
+
@api_endpoint = api_endpoint
|
|
61
|
+
|
|
62
|
+
# Generate RSA key pair once per connector instance
|
|
63
|
+
@private_key, @public_key = generate_keys
|
|
64
|
+
|
|
65
|
+
# Certificate cache with expiration tracking
|
|
66
|
+
@cached_info = nil
|
|
67
|
+
@cert_expiration = Time.at(0)
|
|
68
|
+
@lock = Mutex.new
|
|
69
|
+
|
|
70
|
+
# SQL Admin API fetcher
|
|
71
|
+
@fetcher = SQLAdminFetcher.new(credentials: @credentials, api_endpoint: @api_endpoint)
|
|
72
|
+
|
|
73
|
+
# Track active proxies for cleanup
|
|
74
|
+
@proxies = []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create a connected PG::Connection
|
|
78
|
+
#
|
|
79
|
+
# @param user [String] Database username
|
|
80
|
+
# @param password [String] Database password (optional for IAM auth)
|
|
81
|
+
# @param dbname [String] Database name
|
|
82
|
+
# @param extra_options [Hash] Additional options to pass to PG.connect
|
|
83
|
+
# @return [PG::Connection] Connected PostgreSQL connection
|
|
84
|
+
def connect(user:, dbname:, password: nil, **extra_options)
|
|
85
|
+
require "pg"
|
|
86
|
+
|
|
87
|
+
validate_connection_params!(user: user)
|
|
88
|
+
conn_info = ensure_valid_connection_info!
|
|
89
|
+
|
|
90
|
+
effective_user = @auth_type == AuthTypes::IAM ? format_iam_user(user) : user
|
|
91
|
+
effective_password = @auth_type == AuthTypes::IAM ? @credentials.access_token(scope: :login) : password
|
|
92
|
+
|
|
93
|
+
ssl_socket = create_ssl_connection(conn_info)
|
|
94
|
+
proxy = SslProxy.new(ssl_socket)
|
|
95
|
+
proxy.start
|
|
96
|
+
@lock.synchronize { @proxies << proxy }
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
PG.connect(
|
|
100
|
+
host: "127.0.0.1",
|
|
101
|
+
port: proxy.port,
|
|
102
|
+
user: effective_user,
|
|
103
|
+
password: effective_password,
|
|
104
|
+
dbname: dbname,
|
|
105
|
+
sslmode: "disable",
|
|
106
|
+
**extra_options
|
|
107
|
+
)
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
proxy.stop
|
|
110
|
+
raise e
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Get connection options that can be used with PG.connect
|
|
115
|
+
# This is an alternative to using #connect directly
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash] Connection options including :stream proc
|
|
118
|
+
def get_options
|
|
119
|
+
conn_info = ensure_valid_connection_info!
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
stream: -> { create_ssl_connection(conn_info) },
|
|
123
|
+
ip_address: conn_info[:ip_address],
|
|
124
|
+
server_ca_cert: conn_info[:server_ca_cert],
|
|
125
|
+
client_cert: conn_info[:client_cert],
|
|
126
|
+
private_key: conn_info[:private_key]
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get the IP address for the instance
|
|
131
|
+
#
|
|
132
|
+
# @return [String] IP address
|
|
133
|
+
def ip_address
|
|
134
|
+
ensure_valid_connection_info![:ip_address]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Close the connector and clean up resources
|
|
138
|
+
def close
|
|
139
|
+
@lock.synchronize do
|
|
140
|
+
@proxies.each(&:stop)
|
|
141
|
+
@proxies.clear
|
|
142
|
+
@cached_info = nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def parse_connection_name(name)
|
|
149
|
+
parts = name.to_s.split(":")
|
|
150
|
+
unless parts.length == 3
|
|
151
|
+
raise ConfigurationError,
|
|
152
|
+
"Invalid instance connection name '#{name}'. Expected format: PROJECT:REGION:INSTANCE"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
parts
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def generate_keys
|
|
159
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
160
|
+
[key.to_pem, key.public_key.to_pem]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def default_credentials
|
|
164
|
+
if ENV["GOOGLE_APPLICATION_CREDENTIALS"]
|
|
165
|
+
return Credentials::ServiceAccount.from_file(ENV["GOOGLE_APPLICATION_CREDENTIALS"])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
gcloud_path = File.expand_path("~/.config/gcloud/application_default_credentials.json")
|
|
169
|
+
return Credentials::ServiceAccount.from_file(gcloud_path) if File.exist?(gcloud_path)
|
|
170
|
+
|
|
171
|
+
Credentials::Metadata.new
|
|
172
|
+
rescue JSON::ParserError => e
|
|
173
|
+
raise ConfigurationError, "Invalid credentials file: #{e.message}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ensure_valid_connection_info!
|
|
177
|
+
@lock.synchronize do
|
|
178
|
+
refresh_connection_info! if @cached_info.nil? || Time.now > (@cert_expiration - CERT_REFRESH_BUFFER)
|
|
179
|
+
@cached_info
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def refresh_connection_info!
|
|
184
|
+
metadata = @fetcher.fetch_metadata(
|
|
185
|
+
project: @project,
|
|
186
|
+
region: @region,
|
|
187
|
+
instance: @instance_name
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
cert_data = @fetcher.fetch_ephemeral_cert(
|
|
191
|
+
project: @project,
|
|
192
|
+
instance: @instance_name,
|
|
193
|
+
public_key: @public_key,
|
|
194
|
+
auth_type: @auth_type
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
ip_address = select_ip_address(metadata[:ip_addresses])
|
|
198
|
+
|
|
199
|
+
@cert_expiration = cert_data[:expiration]
|
|
200
|
+
@cached_info = {
|
|
201
|
+
ip_address: ip_address,
|
|
202
|
+
server_ca_cert: metadata[:server_ca_cert],
|
|
203
|
+
client_cert: cert_data[:cert],
|
|
204
|
+
private_key: @private_key
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def select_ip_address(ip_addresses)
|
|
209
|
+
api_key = IpAddressTypes.api_key(@ip_type)
|
|
210
|
+
ip = ip_addresses[api_key]
|
|
211
|
+
|
|
212
|
+
unless ip
|
|
213
|
+
raise IpAddressError.new(
|
|
214
|
+
"Cannot connect to instance, #{@ip_type} IP address not found",
|
|
215
|
+
code: "ENO#{@ip_type}IPADDRESS"
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
ip
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_connection_params!(user:)
|
|
223
|
+
raise ConfigurationError, "User is required for database connection" if user.nil? || user.empty?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def format_iam_user(user)
|
|
227
|
+
suffix = ".gserviceaccount.com"
|
|
228
|
+
user.end_with?(suffix) ? user[0...-suffix.length] : user
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def create_ssl_connection(conn_info)
|
|
232
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
233
|
+
ssl_context.min_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
234
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
235
|
+
|
|
236
|
+
ssl_context.cert = OpenSSL::X509::Certificate.new(conn_info[:client_cert])
|
|
237
|
+
ssl_context.key = OpenSSL::PKey::RSA.new(conn_info[:private_key])
|
|
238
|
+
|
|
239
|
+
cert_store = OpenSSL::X509::Store.new
|
|
240
|
+
cert_store.add_cert(OpenSSL::X509::Certificate.new(conn_info[:server_ca_cert]))
|
|
241
|
+
ssl_context.cert_store = cert_store
|
|
242
|
+
|
|
243
|
+
tcp_socket = Socket.tcp(conn_info[:ip_address], CLOUD_SQL_PORT, connect_timeout: 30)
|
|
244
|
+
|
|
245
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
|
246
|
+
ssl_socket.sync_close = true
|
|
247
|
+
ssl_socket.connect
|
|
248
|
+
|
|
249
|
+
ssl_socket
|
|
250
|
+
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
|
|
251
|
+
raise ConnectionError, "Failed to connect to Cloud SQL instance: #{e.message}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|