datagrout-conduit 0.1.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/README.md +303 -0
- data/lib/datagrout_conduit/client.rb +601 -0
- data/lib/datagrout_conduit/errors.rb +56 -0
- data/lib/datagrout_conduit/identity.rb +175 -0
- data/lib/datagrout_conduit/oauth.rb +85 -0
- data/lib/datagrout_conduit/registration.rb +220 -0
- data/lib/datagrout_conduit/transport/base.rb +158 -0
- data/lib/datagrout_conduit/transport/jsonrpc.rb +36 -0
- data/lib/datagrout_conduit/transport/mcp.rb +107 -0
- data/lib/datagrout_conduit/types.rb +142 -0
- data/lib/datagrout_conduit/version.rb +5 -0
- data/lib/datagrout_conduit.rb +53 -0
- metadata +145 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module DatagroutConduit
|
|
6
|
+
# mTLS client identity for Conduit connections.
|
|
7
|
+
#
|
|
8
|
+
# Holds the client certificate and private key presented during every TLS
|
|
9
|
+
# handshake. The server verifies the caller's identity without any
|
|
10
|
+
# application-layer token.
|
|
11
|
+
#
|
|
12
|
+
# == Auto-discovery order (try_discover)
|
|
13
|
+
#
|
|
14
|
+
# 1. +override_dir+ (if provided)
|
|
15
|
+
# 2. +CONDUIT_MTLS_CERT+ / +CONDUIT_MTLS_KEY+ env vars (PEM strings)
|
|
16
|
+
# 3. +CONDUIT_IDENTITY_DIR+ env var → directory with identity.pem + identity_key.pem
|
|
17
|
+
# 4. +~/.conduit/identity.pem+ + +identity_key.pem+
|
|
18
|
+
# 5. +.conduit/+ relative to cwd
|
|
19
|
+
class Identity
|
|
20
|
+
attr_reader :cert_pem, :key_pem, :ca_pem, :expires_at
|
|
21
|
+
|
|
22
|
+
def initialize(cert_pem:, key_pem:, ca_pem: nil, expires_at: nil)
|
|
23
|
+
validate_cert!(cert_pem)
|
|
24
|
+
validate_key!(key_pem)
|
|
25
|
+
@cert_pem = cert_pem
|
|
26
|
+
@key_pem = key_pem
|
|
27
|
+
@ca_pem = ca_pem
|
|
28
|
+
@expires_at = expires_at
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build from PEM strings already in memory.
|
|
32
|
+
def self.from_pem(cert_pem, key_pem, ca_pem: nil)
|
|
33
|
+
new(cert_pem: cert_pem, key_pem: key_pem, ca_pem: ca_pem)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build by reading PEM files from disk.
|
|
37
|
+
def self.from_paths(cert_path, key_path, ca_path: nil)
|
|
38
|
+
cert_pem = File.read(cert_path)
|
|
39
|
+
key_pem = File.read(key_path)
|
|
40
|
+
ca_pem = ca_path ? File.read(ca_path) : nil
|
|
41
|
+
new(cert_pem: cert_pem, key_pem: key_pem, ca_pem: ca_pem)
|
|
42
|
+
rescue Errno::ENOENT => e
|
|
43
|
+
raise ConfigError, "Cannot read identity file: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build from environment variables.
|
|
47
|
+
#
|
|
48
|
+
# Variables:
|
|
49
|
+
# - CONDUIT_MTLS_CERT — PEM string for the client certificate
|
|
50
|
+
# - CONDUIT_MTLS_KEY — PEM string for the private key
|
|
51
|
+
# - CONDUIT_MTLS_CA — PEM string for the CA (optional)
|
|
52
|
+
#
|
|
53
|
+
# Returns nil if CONDUIT_MTLS_CERT is not set.
|
|
54
|
+
def self.from_env
|
|
55
|
+
cert = ENV["CONDUIT_MTLS_CERT"]
|
|
56
|
+
return nil if cert.nil? || cert.empty?
|
|
57
|
+
|
|
58
|
+
key = ENV["CONDUIT_MTLS_KEY"]
|
|
59
|
+
raise ConfigError, "CONDUIT_MTLS_CERT is set but CONDUIT_MTLS_KEY is missing" if key.nil? || key.empty?
|
|
60
|
+
|
|
61
|
+
ca = ENV["CONDUIT_MTLS_CA"]
|
|
62
|
+
ca = nil if ca && ca.empty?
|
|
63
|
+
|
|
64
|
+
new(cert_pem: cert, key_pem: key, ca_pem: ca)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Walk the auto-discovery chain and return the first identity found,
|
|
68
|
+
# or nil if nothing is available.
|
|
69
|
+
def self.try_discover(override_dir: nil)
|
|
70
|
+
# 1. Override directory
|
|
71
|
+
if override_dir
|
|
72
|
+
id = try_load_from_dir(override_dir)
|
|
73
|
+
return id if id
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 2. Environment variables (individual cert/key PEMs)
|
|
77
|
+
id = from_env
|
|
78
|
+
return id if id
|
|
79
|
+
|
|
80
|
+
# 3. CONDUIT_IDENTITY_DIR env var
|
|
81
|
+
identity_dir = ENV["CONDUIT_IDENTITY_DIR"]
|
|
82
|
+
if identity_dir && !identity_dir.empty?
|
|
83
|
+
id = try_load_from_dir(identity_dir)
|
|
84
|
+
return id if id
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# 4. ~/.conduit/
|
|
88
|
+
home = ENV["HOME"] || ENV["USERPROFILE"]
|
|
89
|
+
if home
|
|
90
|
+
id = try_load_from_dir(File.join(home, ".conduit"))
|
|
91
|
+
return id if id
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# 5. .conduit/ relative to cwd
|
|
95
|
+
id = try_load_from_dir(File.join(Dir.pwd, ".conduit"))
|
|
96
|
+
return id if id
|
|
97
|
+
|
|
98
|
+
nil
|
|
99
|
+
rescue ConfigError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def with_expiry(expires_at)
|
|
104
|
+
dup.tap { |i| i.instance_variable_set(:@expires_at, expires_at) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns true if the certificate expires within +threshold_days+.
|
|
108
|
+
# Returns false when no expiry is known.
|
|
109
|
+
def needs_rotation?(threshold_days: 30)
|
|
110
|
+
return false if @expires_at.nil?
|
|
111
|
+
|
|
112
|
+
deadline = Time.now + (threshold_days * 86_400)
|
|
113
|
+
deadline > @expires_at
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Return an OpenSSL::X509::Certificate for use with Faraday SSL config.
|
|
117
|
+
def openssl_cert
|
|
118
|
+
OpenSSL::X509::Certificate.new(@cert_pem)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Return an OpenSSL::PKey for use with Faraday SSL config.
|
|
122
|
+
def openssl_key
|
|
123
|
+
OpenSSL::PKey.read(@key_pem)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Return an OpenSSL::X509::Certificate for the CA, if present.
|
|
127
|
+
def openssl_ca
|
|
128
|
+
@ca_pem ? OpenSSL::X509::Certificate.new(@ca_pem) : nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Configure Faraday SSL options with this identity's mTLS credentials.
|
|
132
|
+
def configure_ssl(ssl)
|
|
133
|
+
ssl.client_cert = openssl_cert
|
|
134
|
+
ssl.client_key = openssl_key
|
|
135
|
+
if @ca_pem
|
|
136
|
+
store = OpenSSL::X509::Store.new
|
|
137
|
+
store.add_cert(openssl_ca)
|
|
138
|
+
ssl.cert_store = store
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class << self
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def try_load_from_dir(dir)
|
|
146
|
+
cert_path = File.join(dir, "identity.pem")
|
|
147
|
+
key_path = File.join(dir, "identity_key.pem")
|
|
148
|
+
return nil unless File.exist?(cert_path) && File.exist?(key_path)
|
|
149
|
+
|
|
150
|
+
ca_path = File.join(dir, "ca.pem")
|
|
151
|
+
ca = File.exist?(ca_path) ? ca_path : nil
|
|
152
|
+
|
|
153
|
+
from_paths(cert_path, key_path, ca_path: ca)
|
|
154
|
+
rescue ConfigError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def validate_cert!(pem)
|
|
162
|
+
unless pem.include?("-----BEGIN CERTIFICATE-----")
|
|
163
|
+
raise ConfigError, "cert_pem does not appear to contain a PEM certificate"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_key!(pem)
|
|
168
|
+
valid = pem.include?("-----BEGIN PRIVATE KEY-----") ||
|
|
169
|
+
pem.include?("-----BEGIN RSA PRIVATE KEY-----") ||
|
|
170
|
+
pem.include?("-----BEGIN EC PRIVATE KEY-----") ||
|
|
171
|
+
pem.include?("-----BEGIN ENCRYPTED PRIVATE KEY-----")
|
|
172
|
+
raise ConfigError, "key_pem does not appear to contain a PEM private key" unless valid
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module DatagroutConduit
|
|
7
|
+
module OAuth
|
|
8
|
+
# Lazily fetches and caches OAuth 2.1 client_credentials tokens.
|
|
9
|
+
# Thread-safe via Mutex.
|
|
10
|
+
class TokenProvider
|
|
11
|
+
REFRESH_BUFFER_SECONDS = 60
|
|
12
|
+
|
|
13
|
+
attr_reader :client_id, :token_endpoint
|
|
14
|
+
|
|
15
|
+
def initialize(client_id:, client_secret:, token_endpoint:, scope: nil)
|
|
16
|
+
@client_id = client_id
|
|
17
|
+
@client_secret = client_secret
|
|
18
|
+
@token_endpoint = token_endpoint
|
|
19
|
+
@scope = scope
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@cached_token = nil
|
|
22
|
+
@expires_at = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Derive the token endpoint from an MCP URL.
|
|
26
|
+
#
|
|
27
|
+
# "https://app.datagrout.ai/servers/abc/mcp"
|
|
28
|
+
# => "https://app.datagrout.ai/servers/abc/oauth/token"
|
|
29
|
+
def self.derive_token_endpoint(mcp_url)
|
|
30
|
+
idx = mcp_url.index("/mcp")
|
|
31
|
+
base = idx ? mcp_url[0...idx] : mcp_url.chomp("/")
|
|
32
|
+
"#{base}/oauth/token"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Return a valid bearer token, fetching or refreshing as needed.
|
|
36
|
+
def get_token
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
return @cached_token if token_valid?
|
|
39
|
+
|
|
40
|
+
fetch_token!
|
|
41
|
+
@cached_token
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Force-invalidate the cached token (e.g. on receipt of a 401).
|
|
46
|
+
def invalidate!
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@cached_token = nil
|
|
49
|
+
@expires_at = nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def token_valid?
|
|
56
|
+
@cached_token && @expires_at && (Time.now < @expires_at - REFRESH_BUFFER_SECONDS)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fetch_token!
|
|
60
|
+
conn = Faraday.new(url: @token_endpoint) do |f|
|
|
61
|
+
f.request :url_encoded
|
|
62
|
+
f.adapter Faraday.default_adapter
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
params = {
|
|
66
|
+
"grant_type" => "client_credentials",
|
|
67
|
+
"client_id" => @client_id,
|
|
68
|
+
"client_secret" => @client_secret
|
|
69
|
+
}
|
|
70
|
+
params["scope"] = @scope if @scope
|
|
71
|
+
|
|
72
|
+
response = conn.post { |req| req.body = params }
|
|
73
|
+
|
|
74
|
+
unless response.success?
|
|
75
|
+
raise AuthError, "OAuth token endpoint returned #{response.status}: #{response.body}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
data = JSON.parse(response.body)
|
|
79
|
+
@cached_token = data["access_token"]
|
|
80
|
+
expires_in = (data["expires_in"] || 3600).to_i
|
|
81
|
+
@expires_at = Time.now + expires_in
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "faraday"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module DatagroutConduit
|
|
10
|
+
# Substrate identity registration with the DataGrout CA.
|
|
11
|
+
#
|
|
12
|
+
# Handles the issuance flow — turning a freshly-generated keypair into a
|
|
13
|
+
# DG-CA-signed Identity that DataGrout will accept for mTLS.
|
|
14
|
+
#
|
|
15
|
+
# == Flow
|
|
16
|
+
#
|
|
17
|
+
# 1. Generate an ECDSA P-256 keypair with {.generate_keypair}.
|
|
18
|
+
# The private key never leaves the client.
|
|
19
|
+
# 2. Send the *public key* to the DataGrout CA via {.register_identity}
|
|
20
|
+
# (authenticated with a bearer token — user access token or API key).
|
|
21
|
+
# 3. Persist the returned identity to +~/.conduit/+ via {.save_identity}
|
|
22
|
+
# for auto-discovery by future sessions.
|
|
23
|
+
# 4. On renewal (cert near expiry), call {.rotate_identity} which presents
|
|
24
|
+
# the *existing* client certificate over mTLS — no API key needed.
|
|
25
|
+
class Registration
|
|
26
|
+
DG_CA_URL = "https://ca.datagrout.ai/ca.pem"
|
|
27
|
+
DG_SUBSTRATE_ENDPOINT = "https://app.datagrout.ai/api/v1/substrate/identity"
|
|
28
|
+
|
|
29
|
+
# Generate an ECDSA P-256 keypair.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array(String, String)] +[private_key_pem, public_key_pem]+
|
|
32
|
+
def self.generate_keypair
|
|
33
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
34
|
+
private_pem = key.to_pem
|
|
35
|
+
|
|
36
|
+
public_pem = if key.respond_to?(:public_to_pem)
|
|
37
|
+
key.public_to_pem
|
|
38
|
+
else
|
|
39
|
+
pub = OpenSSL::PKey::EC.new(key.group)
|
|
40
|
+
pub.public_key = key.public_key
|
|
41
|
+
pub.to_pem
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
[private_pem, public_pem]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Register identity with the DataGrout CA.
|
|
48
|
+
#
|
|
49
|
+
# Sends only the public key. The private key never leaves the client.
|
|
50
|
+
# Authenticated with a bearer token (user access token or API key).
|
|
51
|
+
#
|
|
52
|
+
# @param public_key_pem [String] PEM-encoded public key
|
|
53
|
+
# @param auth_token [String] bearer token for authentication
|
|
54
|
+
# @param name [String] human-readable label for the substrate instance
|
|
55
|
+
# @param substrate_endpoint [String] registration endpoint URL
|
|
56
|
+
# @return [RegistrationResponse]
|
|
57
|
+
def self.register_identity(public_key_pem, auth_token:, name: "conduit-client",
|
|
58
|
+
substrate_endpoint: DG_SUBSTRATE_ENDPOINT)
|
|
59
|
+
conn = Faraday.new(url: substrate_endpoint) do |f|
|
|
60
|
+
f.request :json
|
|
61
|
+
f.response :json, content_type: /\bjson$/
|
|
62
|
+
f.adapter Faraday.default_adapter
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response = conn.post do |req|
|
|
66
|
+
req.url "register"
|
|
67
|
+
req.headers["Authorization"] = "Bearer #{auth_token}"
|
|
68
|
+
req.headers["Content-Type"] = "application/json"
|
|
69
|
+
req.body = JSON.generate(
|
|
70
|
+
name: name,
|
|
71
|
+
public_key_pem: public_key_pem
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless response.success?
|
|
76
|
+
raise AuthError, "Registration failed (HTTP #{response.status}): #{response.body}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
body = response.body
|
|
80
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
81
|
+
|
|
82
|
+
RegistrationResponse.new(
|
|
83
|
+
id: body["id"],
|
|
84
|
+
cert_pem: body["cert_pem"],
|
|
85
|
+
ca_cert_pem: body["ca_cert_pem"],
|
|
86
|
+
fingerprint: body["fingerprint"],
|
|
87
|
+
name: body["name"],
|
|
88
|
+
registered_at: body["registered_at"],
|
|
89
|
+
valid_until: body["valid_until"]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Rotate identity using existing mTLS cert.
|
|
94
|
+
#
|
|
95
|
+
# Generates a new public key, sends it to the +/rotate+ endpoint
|
|
96
|
+
# authenticated by the *current* cert over mTLS (no API key needed),
|
|
97
|
+
# and returns a fresh DG-CA-signed certificate.
|
|
98
|
+
#
|
|
99
|
+
# @param identity [Identity] current mTLS identity for authentication
|
|
100
|
+
# @param new_public_key_pem [String] PEM-encoded new public key
|
|
101
|
+
# @param name [String] human-readable label
|
|
102
|
+
# @param substrate_endpoint [String] registration endpoint URL
|
|
103
|
+
# @return [RegistrationResponse]
|
|
104
|
+
def self.rotate_identity(identity, new_public_key_pem, name: "conduit-client",
|
|
105
|
+
substrate_endpoint: DG_SUBSTRATE_ENDPOINT)
|
|
106
|
+
conn = Faraday.new(url: substrate_endpoint) do |f|
|
|
107
|
+
f.request :json
|
|
108
|
+
f.response :json, content_type: /\bjson$/
|
|
109
|
+
f.adapter Faraday.default_adapter
|
|
110
|
+
identity.configure_ssl(f.ssl)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
response = conn.post do |req|
|
|
114
|
+
req.url "rotate"
|
|
115
|
+
req.headers["Content-Type"] = "application/json"
|
|
116
|
+
req.body = JSON.generate(
|
|
117
|
+
name: name,
|
|
118
|
+
public_key_pem: new_public_key_pem
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
unless response.success?
|
|
123
|
+
raise ConnectionError, "Rotation failed (HTTP #{response.status}): #{response.body}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
body = response.body
|
|
127
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
128
|
+
|
|
129
|
+
RegistrationResponse.new(
|
|
130
|
+
id: body["id"],
|
|
131
|
+
cert_pem: body["cert_pem"],
|
|
132
|
+
ca_cert_pem: body["ca_cert_pem"],
|
|
133
|
+
fingerprint: body["fingerprint"],
|
|
134
|
+
name: body["name"],
|
|
135
|
+
registered_at: body["registered_at"],
|
|
136
|
+
valid_until: body["valid_until"]
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Save identity files to a directory with secure permissions (0600).
|
|
141
|
+
#
|
|
142
|
+
# @param cert_pem [String] DG-signed certificate PEM
|
|
143
|
+
# @param key_pem [String] private key PEM
|
|
144
|
+
# @param dir [String] directory path
|
|
145
|
+
# @param ca_pem [String, nil] CA certificate PEM
|
|
146
|
+
# @return [Hash] paths to written files (+:cert+, +:key+, +:ca+)
|
|
147
|
+
def self.save_identity(cert_pem, key_pem, dir, ca_pem: nil)
|
|
148
|
+
FileUtils.mkdir_p(dir)
|
|
149
|
+
|
|
150
|
+
cert_path = File.join(dir, "identity.pem")
|
|
151
|
+
key_path = File.join(dir, "identity_key.pem")
|
|
152
|
+
|
|
153
|
+
File.write(cert_path, cert_pem)
|
|
154
|
+
File.write(key_path, key_pem)
|
|
155
|
+
File.chmod(0o600, cert_path)
|
|
156
|
+
File.chmod(0o600, key_path)
|
|
157
|
+
|
|
158
|
+
paths = { cert: cert_path, key: key_path }
|
|
159
|
+
|
|
160
|
+
if ca_pem
|
|
161
|
+
ca_path = File.join(dir, "ca.pem")
|
|
162
|
+
File.write(ca_path, ca_pem)
|
|
163
|
+
File.chmod(0o600, ca_path)
|
|
164
|
+
paths[:ca] = ca_path
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
paths
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Fetch the DataGrout CA certificate from +ca.datagrout.ai+.
|
|
171
|
+
#
|
|
172
|
+
# Uses the system trust store for TLS (not the DG CA itself), so there
|
|
173
|
+
# is no circularity.
|
|
174
|
+
#
|
|
175
|
+
# @param ca_url [String] URL to fetch the CA cert from
|
|
176
|
+
# @return [String] PEM-encoded CA certificate
|
|
177
|
+
def self.fetch_ca_cert(ca_url: DG_CA_URL)
|
|
178
|
+
response = Faraday.get(ca_url)
|
|
179
|
+
|
|
180
|
+
unless response.success?
|
|
181
|
+
raise ConnectionError, "Failed to fetch CA cert (HTTP #{response.status})"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
pem = response.body
|
|
185
|
+
unless pem.include?("-----BEGIN CERTIFICATE-----")
|
|
186
|
+
raise ConnectionError, "Response from #{ca_url} does not look like a PEM certificate"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
pem
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Refresh CA cert in the given directory.
|
|
193
|
+
#
|
|
194
|
+
# @param dir [String] directory to write +ca.pem+ into
|
|
195
|
+
# @param ca_url [String] URL to fetch the CA cert from
|
|
196
|
+
# @return [String] path to the written +ca.pem+ file
|
|
197
|
+
def self.refresh_ca_cert(dir, ca_url: DG_CA_URL)
|
|
198
|
+
ca_pem = fetch_ca_cert(ca_url: ca_url)
|
|
199
|
+
FileUtils.mkdir_p(dir)
|
|
200
|
+
ca_path = File.join(dir, "ca.pem")
|
|
201
|
+
File.write(ca_path, ca_pem)
|
|
202
|
+
File.chmod(0o600, ca_path)
|
|
203
|
+
ca_path
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Returns +~/.conduit/+ as the canonical identity directory.
|
|
207
|
+
#
|
|
208
|
+
# @return [String, nil]
|
|
209
|
+
def self.default_identity_dir
|
|
210
|
+
home = ENV["HOME"] || ENV["USERPROFILE"]
|
|
211
|
+
home ? File.join(home, ".conduit") : nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
RegistrationResponse = Struct.new(
|
|
216
|
+
:id, :cert_pem, :ca_cert_pem, :fingerprint,
|
|
217
|
+
:name, :registered_at, :valid_until,
|
|
218
|
+
keyword_init: true
|
|
219
|
+
)
|
|
220
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module DatagroutConduit
|
|
9
|
+
module Transport
|
|
10
|
+
# Base transport shared by MCP and JSONRPC transports.
|
|
11
|
+
# Manages a Faraday connection with optional mTLS and auth headers.
|
|
12
|
+
class Base
|
|
13
|
+
attr_reader :url
|
|
14
|
+
|
|
15
|
+
def initialize(url:, auth: {}, identity: nil)
|
|
16
|
+
@url = url
|
|
17
|
+
@auth = normalize_auth(auth)
|
|
18
|
+
@identity = identity
|
|
19
|
+
@connected = false
|
|
20
|
+
@connection = build_connection
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def connect
|
|
24
|
+
URI.parse(@url) # validate URL
|
|
25
|
+
@connected = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def disconnect
|
|
29
|
+
@connected = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def connected?
|
|
33
|
+
@connected
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Subclasses must implement this.
|
|
37
|
+
def send_request(_method, _params = nil, id: nil)
|
|
38
|
+
raise NotImplementedError, "#{self.class}#send_request must be implemented"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def ensure_connected!
|
|
44
|
+
raise NotInitializedError unless @connected
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def next_id
|
|
48
|
+
SecureRandom.uuid
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_connection
|
|
52
|
+
Faraday.new(url: @url) do |f|
|
|
53
|
+
f.request :json
|
|
54
|
+
f.response :json, content_type: /\bjson$/
|
|
55
|
+
f.adapter Faraday.default_adapter
|
|
56
|
+
|
|
57
|
+
configure_ssl(f) if @identity
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def configure_ssl(faraday)
|
|
62
|
+
faraday.ssl.client_cert = @identity.openssl_cert
|
|
63
|
+
faraday.ssl.client_key = @identity.openssl_key
|
|
64
|
+
if @identity.ca_pem
|
|
65
|
+
store = OpenSSL::X509::Store.new
|
|
66
|
+
store.add_cert(@identity.openssl_ca)
|
|
67
|
+
faraday.ssl.cert_store = store
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_headers
|
|
72
|
+
headers = { "Content-Type" => "application/json" }
|
|
73
|
+
|
|
74
|
+
case @auth[:type]
|
|
75
|
+
when :bearer
|
|
76
|
+
headers["Authorization"] = "Bearer #{@auth[:token]}"
|
|
77
|
+
when :api_key
|
|
78
|
+
headers["X-API-Key"] = @auth[:key]
|
|
79
|
+
when :basic
|
|
80
|
+
encoded = Base64.strict_encode64("#{@auth[:username]}:#{@auth[:password]}")
|
|
81
|
+
headers["Authorization"] = "Basic #{encoded}"
|
|
82
|
+
when :oauth
|
|
83
|
+
token = @auth[:provider].get_token
|
|
84
|
+
headers["Authorization"] = "Bearer #{token}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
headers
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_jsonrpc_body(method, params, id)
|
|
91
|
+
body = {
|
|
92
|
+
"jsonrpc" => "2.0",
|
|
93
|
+
"id" => id || next_id,
|
|
94
|
+
"method" => method
|
|
95
|
+
}
|
|
96
|
+
body["params"] = params if params
|
|
97
|
+
body
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_response(response)
|
|
101
|
+
check_rate_limit!(response)
|
|
102
|
+
|
|
103
|
+
if response.status == 401 && @auth[:type] == :oauth
|
|
104
|
+
@auth[:provider].invalidate!
|
|
105
|
+
return :retry_oauth
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return { "accepted" => true } if response.status == 202
|
|
109
|
+
|
|
110
|
+
unless response.success?
|
|
111
|
+
raise ConnectionError, "HTTP #{response.status} error"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
body = response.body
|
|
115
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
116
|
+
|
|
117
|
+
if body.is_a?(Hash) && body["error"]
|
|
118
|
+
err = body["error"]
|
|
119
|
+
raise McpError.new(
|
|
120
|
+
code: err["code"] || -1,
|
|
121
|
+
message: err["message"] || "Unknown error",
|
|
122
|
+
data: err["data"]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
body
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def check_rate_limit!(response)
|
|
130
|
+
return unless response.status == 429
|
|
131
|
+
|
|
132
|
+
used = response.headers["X-RateLimit-Used"]&.to_i || 0
|
|
133
|
+
limit_str = response.headers["X-RateLimit-Limit"] || "50"
|
|
134
|
+
limit = limit_str.casecmp("unlimited").zero? ? "unlimited" : limit_str.to_i
|
|
135
|
+
|
|
136
|
+
raise RateLimitedError.new(used: used, limit: limit)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def normalize_auth(auth)
|
|
140
|
+
return { type: :none } if auth.nil? || auth.empty?
|
|
141
|
+
|
|
142
|
+
auth = auth.transform_keys(&:to_sym) if auth.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
if auth[:bearer]
|
|
145
|
+
{ type: :bearer, token: auth[:bearer] }
|
|
146
|
+
elsif auth[:api_key]
|
|
147
|
+
{ type: :api_key, key: auth[:api_key] }
|
|
148
|
+
elsif auth[:basic]
|
|
149
|
+
{ type: :basic, username: auth[:basic][:username], password: auth[:basic][:password] }
|
|
150
|
+
elsif auth[:oauth] || auth[:provider]
|
|
151
|
+
{ type: :oauth, provider: auth[:oauth] || auth[:provider] }
|
|
152
|
+
else
|
|
153
|
+
{ type: :none }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatagroutConduit
|
|
4
|
+
module Transport
|
|
5
|
+
# JSON-RPC over HTTP POST transport.
|
|
6
|
+
# Sends standard JSON-RPC 2.0 requests via HTTP POST.
|
|
7
|
+
class JsonRpc < Base
|
|
8
|
+
def send_request(method, params = nil, id: nil)
|
|
9
|
+
ensure_connected!
|
|
10
|
+
|
|
11
|
+
request_id = id || next_id
|
|
12
|
+
body = build_jsonrpc_body(method, params, request_id)
|
|
13
|
+
headers = build_headers
|
|
14
|
+
|
|
15
|
+
response = @connection.post do |req|
|
|
16
|
+
req.headers = headers
|
|
17
|
+
req.body = JSON.generate(body)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
result = handle_response(response)
|
|
21
|
+
|
|
22
|
+
if result == :retry_oauth
|
|
23
|
+
headers = build_headers
|
|
24
|
+
response = @connection.post do |req|
|
|
25
|
+
req.headers = headers
|
|
26
|
+
req.body = JSON.generate(body)
|
|
27
|
+
end
|
|
28
|
+
result = handle_response(response)
|
|
29
|
+
raise AuthError, "OAuth token rejected after refresh" if result == :retry_oauth
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|