lex-llm 0.8.4 → 0.8.5
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/CHANGELOG.md +12 -0
- data/README.md +34 -0
- data/lib/legion/extensions/llm/connection.rb +1 -1
- data/lib/legion/extensions/llm/credential_sources.rb +1 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +3 -0
- data/spec/legion/extensions/llm/connection_tls_spec.rb +103 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +16 -7
- data/spec/support/tls_endpoint.rb +92 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2216f17aaf84164eccc09087fb78e2a52339c24ae67207a43e220de31927fc3
|
|
4
|
+
data.tar.gz: 143caaf03ac594ef2cf9fb298836b7226f67c8b43ee4e0c0e5cdc26aaeb9b4d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24b19766bdc02f567c50d2a76305cdbbc15252607ab02d46484811f9723164f6bb75dae86e5556901b675b235f750f14a3c41dc25e2b5d357cb52a7d81399c19
|
|
7
|
+
data.tar.gz: 7a65c4c5f65c5b43d123f408a6c399a983a5fbbbedbdc13dcd5e96ab16edf414e186266ae4c4e43f35927466303997d5b74d6cd95391eb6b8a64edf8702a55aa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.5 - 2026-10-29
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Make shared provider TLS certificate and hostname verification configurable through
|
|
7
|
+
`Legion::Settings` at `extensions.llm.ssl.verify`, defaulting to `false`. Set it to
|
|
8
|
+
`true` to enable verification with Typhoeus or Net::HTTP; explicitly trusted custom
|
|
9
|
+
certificate authorities remain supported. (#50)
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Correct asynchronous TCP probes that could report refused connections as open
|
|
13
|
+
on Darwin; inspect the completed socket's error status instead of reconnecting.
|
|
14
|
+
|
|
3
15
|
## 0.8.4 - 2026-08-25
|
|
4
16
|
|
|
5
17
|
### Changed
|
data/README.md
CHANGED
|
@@ -489,10 +489,44 @@ Key settings paths:
|
|
|
489
489
|
- `extensions.llm.fleet.endpoint` -- endpoint-style worker configuration
|
|
490
490
|
- `extensions.llm.fleet.compliance.encrypt_fleet` -- encrypt fleet envelopes (default true)
|
|
491
491
|
- `extensions.llm.fleet.auth.verify_issuer` -- validate JWT issuer (default true)
|
|
492
|
+
- `extensions.llm.ssl.verify` -- verify provider HTTPS certificates and hostnames (default false)
|
|
492
493
|
- `extensions.llm.security.credential_source_probing` -- gate credential probing (default true)
|
|
493
494
|
- `extensions.llm.model_whitelist` / `model_blacklist` -- provider-level model filters
|
|
494
495
|
- `extensions.llm.<family>.instance.<name>.model_whitelist` -- per-instance override
|
|
495
496
|
|
|
497
|
+
### TLS verification and custom certificate authorities
|
|
498
|
+
|
|
499
|
+
Shared provider connections read `extensions.llm.ssl.verify` from `Legion::Settings`
|
|
500
|
+
when constructed. The default is `false` for both Typhoeus and Net::HTTP.
|
|
501
|
+
With verification disabled, untrusted and wrong-host certificates are accepted,
|
|
502
|
+
so credentials can be sent to an unauthenticated peer.
|
|
503
|
+
|
|
504
|
+
Enable verification before creating provider connections:
|
|
505
|
+
|
|
506
|
+
```json
|
|
507
|
+
{
|
|
508
|
+
"extensions": {
|
|
509
|
+
"llm": {
|
|
510
|
+
"ssl": { "verify": true }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
With verification enabled, an untrusted certificate or hostname mismatch fails
|
|
517
|
+
before an authenticated HTTP request is sent.
|
|
518
|
+
|
|
519
|
+
When constructing a shared connection for a private or corporate CA, configure
|
|
520
|
+
the underlying Faraday SSL options before making the first request:
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
connection = Legion::Extensions::Llm::Connection.new(provider, config)
|
|
524
|
+
connection.connection.ssl.ca_file = '/path/to/trusted-ca-bundle.pem'
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
When verification is enabled, private or self-signed HTTPS servers need explicit
|
|
528
|
+
CA trust. Plain HTTP behavior is unchanged.
|
|
529
|
+
|
|
496
530
|
---
|
|
497
531
|
|
|
498
532
|
## Provider Dependencies
|
|
@@ -40,7 +40,7 @@ module Legion
|
|
|
40
40
|
@config = config
|
|
41
41
|
|
|
42
42
|
@connection ||= Faraday.new(provider.api_base) do |faraday|
|
|
43
|
-
faraday.ssl.verify =
|
|
43
|
+
faraday.ssl.verify = Legion::Settings.dig(:extensions, :llm, :ssl, :verify) == true
|
|
44
44
|
setup_timeout(faraday)
|
|
45
45
|
setup_logging(faraday)
|
|
46
46
|
setup_retry(faraday)
|
|
@@ -114,11 +114,7 @@ module Legion
|
|
|
114
114
|
rescue IO::WaitWritable
|
|
115
115
|
return false unless sock.wait_writable(timeout)
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
sock.connect_nonblock(addr)
|
|
119
|
-
rescue Errno::EISCONN
|
|
120
|
-
# already connected — success
|
|
121
|
-
end
|
|
117
|
+
return sock.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
|
|
122
118
|
end
|
|
123
119
|
true
|
|
124
120
|
rescue StandardError => e
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'faraday/net_http'
|
|
5
|
+
require_relative '../../../support/tls_endpoint'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Connection do
|
|
8
|
+
around do |example|
|
|
9
|
+
saved = Legion::Settings.loader.settings[:extensions]
|
|
10
|
+
Legion::Settings.loader.settings[:extensions] = { llm: Legion::Extensions::Llm.default_settings }
|
|
11
|
+
example.run
|
|
12
|
+
ensure
|
|
13
|
+
if saved.nil?
|
|
14
|
+
Legion::Settings.loader.settings.delete(:extensions)
|
|
15
|
+
else
|
|
16
|
+
Legion::Settings.loader.settings[:extensions] = saved
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
{ typhoeus: Faraday::ConnectionFailed, net_http: Faraday::SSLError }.each do |adapter, error_class|
|
|
21
|
+
context "with the #{adapter} adapter" do
|
|
22
|
+
let(:endpoint) { SpecSupport::TlsEndpoint.new }
|
|
23
|
+
let(:config) do
|
|
24
|
+
Legion::Extensions::Llm::Configuration.new.tap do |configuration|
|
|
25
|
+
configuration.logger = Logger.new(File::NULL)
|
|
26
|
+
configuration.max_retries = 0
|
|
27
|
+
configuration.request_timeout = 3
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
let(:provider) do
|
|
31
|
+
instance_double(Legion::Extensions::Llm::Provider,
|
|
32
|
+
api_base: endpoint.url,
|
|
33
|
+
headers: { 'Authorization' => 'Bearer tls-fixture' },
|
|
34
|
+
parse_error: nil)
|
|
35
|
+
end
|
|
36
|
+
let(:connection) do
|
|
37
|
+
described_class.new(provider, config).tap do |client|
|
|
38
|
+
client.connection.builder.adapter(adapter)
|
|
39
|
+
client.connection.proxy = nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
after do
|
|
44
|
+
connection.close
|
|
45
|
+
endpoint.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'with the default disabled verification' do
|
|
49
|
+
let(:endpoint) { SpecSupport::TlsEndpoint.new(subject_alt_name: 'DNS:wrong.example.test') }
|
|
50
|
+
|
|
51
|
+
it 'allows an authenticated request despite an untrusted and mismatched certificate' do
|
|
52
|
+
response = connection.get('/tls-check')
|
|
53
|
+
endpoint.finish
|
|
54
|
+
|
|
55
|
+
expect(response.status).to eq(200)
|
|
56
|
+
expect(response.body).to eq('status' => 'ok')
|
|
57
|
+
expect(endpoint.requests.one? { |request| request.match?(/^authorization: Bearer tls-fixture\r?$/i) }).to be(true)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
context 'when TLS verification is enabled in Legion settings' do
|
|
62
|
+
before do
|
|
63
|
+
Legion::Settings.loader.settings[:extensions][:llm][:ssl][:verify] = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'rejects an untrusted certificate before sending an authenticated HTTP request' do
|
|
67
|
+
expect { connection.get('/tls-check') }.to raise_error(error_class)
|
|
68
|
+
endpoint.finish
|
|
69
|
+
|
|
70
|
+
expect(endpoint.requests).to be_empty
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'accepts a matching certificate through an explicitly trusted custom CA' do
|
|
74
|
+
connection.connection.ssl.ca_file = endpoint.ca_file.path
|
|
75
|
+
|
|
76
|
+
response = connection.get('/tls-check')
|
|
77
|
+
endpoint.finish
|
|
78
|
+
|
|
79
|
+
expect(response.status).to eq(200)
|
|
80
|
+
expect(response.body).to eq('status' => 'ok')
|
|
81
|
+
expect(endpoint.requests.one? { |request| request.match?(/^authorization: Bearer tls-fixture\r?$/i) }).to be(true)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
context 'when TLS verification is enabled for a trusted wrong-host certificate' do
|
|
86
|
+
let(:endpoint) { SpecSupport::TlsEndpoint.new(subject_alt_name: 'DNS:wrong.example.test') }
|
|
87
|
+
|
|
88
|
+
before do
|
|
89
|
+
Legion::Settings.loader.settings[:extensions][:llm][:ssl][:verify] = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'rejects the peer before sending an authenticated HTTP request' do
|
|
93
|
+
connection.connection.ssl.ca_file = endpoint.ca_file.path
|
|
94
|
+
|
|
95
|
+
expect { connection.get('/tls-check') }.to raise_error(error_class)
|
|
96
|
+
endpoint.finish
|
|
97
|
+
|
|
98
|
+
expect(endpoint.requests).to be_empty
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -247,7 +247,12 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
|
247
247
|
end
|
|
248
248
|
|
|
249
249
|
it 'returns false when port is closed' do
|
|
250
|
-
|
|
250
|
+
socket = Socket.new(:INET, :STREAM)
|
|
251
|
+
socket.bind(Socket.sockaddr_in(0, '127.0.0.1'))
|
|
252
|
+
|
|
253
|
+
expect(mod.socket_open?('127.0.0.1', socket.local_address.ip_port, timeout: 0.05)).to be false
|
|
254
|
+
ensure
|
|
255
|
+
socket&.close
|
|
251
256
|
end
|
|
252
257
|
|
|
253
258
|
it 'returns false when connection is refused' do
|
|
@@ -260,12 +265,16 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
|
260
265
|
expect(mod.socket_open?('192.0.2.1', 80, timeout: 0.05)).to be false
|
|
261
266
|
end
|
|
262
267
|
|
|
263
|
-
it '
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
it 'returns false when an asynchronous connection completes with an error' do
|
|
269
|
+
socket_error = Socket::Option.int(Socket::AF_INET, Socket::SOL_SOCKET, Socket::SO_ERROR, Errno::ECONNREFUSED::Errno)
|
|
270
|
+
fake_sock = instance_double(Socket, setsockopt: nil, wait_writable: true, getsockopt: socket_error, close: nil)
|
|
271
|
+
allow(Socket).to receive(:new).and_return(fake_sock)
|
|
272
|
+
allow(fake_sock).to receive(:connect_nonblock).and_invoke(
|
|
273
|
+
->(*) { raise IO::EINPROGRESSWaitWritable },
|
|
274
|
+
->(*) { 0 }
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(mod.socket_open?('192.0.2.1', 80, timeout: 0.05)).to be false
|
|
269
278
|
end
|
|
270
279
|
end
|
|
271
280
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module SpecSupport
|
|
9
|
+
# One real HTTPS exchange, using a fresh private CA rather than external trust.
|
|
10
|
+
class TlsEndpoint
|
|
11
|
+
attr_reader :url, :ca_file, :requests
|
|
12
|
+
|
|
13
|
+
def initialize(subject_alt_name: 'IP:127.0.0.1')
|
|
14
|
+
ca_key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
15
|
+
ca = certificate(key: ca_key, name: 'lex-llm test CA')
|
|
16
|
+
ca_extensions = OpenSSL::X509::ExtensionFactory.new
|
|
17
|
+
ca_extensions.subject_certificate = ca
|
|
18
|
+
ca_extensions.issuer_certificate = ca
|
|
19
|
+
ca.add_extension(ca_extensions.create_extension('basicConstraints', 'CA:TRUE', true))
|
|
20
|
+
ca.add_extension(ca_extensions.create_extension('keyUsage', 'keyCertSign,cRLSign', true))
|
|
21
|
+
ca.sign(ca_key, OpenSSL::Digest.new('SHA256'))
|
|
22
|
+
|
|
23
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
24
|
+
cert = certificate(key: key, name: 'lex-llm test endpoint')
|
|
25
|
+
cert.issuer = ca.subject
|
|
26
|
+
extensions = OpenSSL::X509::ExtensionFactory.new
|
|
27
|
+
extensions.subject_certificate = cert
|
|
28
|
+
extensions.issuer_certificate = ca
|
|
29
|
+
cert.add_extension(extensions.create_extension('basicConstraints', 'CA:FALSE', true))
|
|
30
|
+
cert.add_extension(extensions.create_extension('keyUsage', 'digitalSignature', true))
|
|
31
|
+
cert.add_extension(extensions.create_extension('extendedKeyUsage', 'serverAuth'))
|
|
32
|
+
cert.add_extension(extensions.create_extension('subjectAltName', subject_alt_name))
|
|
33
|
+
cert.sign(ca_key, OpenSSL::Digest.new('SHA256'))
|
|
34
|
+
|
|
35
|
+
@ca_file = Tempfile.new(['lex-llm-ca', '.pem'])
|
|
36
|
+
@ca_file.write(ca.to_pem)
|
|
37
|
+
@ca_file.flush
|
|
38
|
+
@requests = []
|
|
39
|
+
@listener = TCPServer.new('127.0.0.1', 0)
|
|
40
|
+
@url = "https://127.0.0.1:#{@listener.addr[1]}"
|
|
41
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
42
|
+
context.cert = cert
|
|
43
|
+
context.key = key
|
|
44
|
+
server = OpenSSL::SSL::SSLServer.new(@listener, context)
|
|
45
|
+
@worker = Thread.new { serve(server: server) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def finish
|
|
49
|
+
raise Timeout::Error, 'TLS fixture did not finish its exchange' unless @worker.join(5)
|
|
50
|
+
|
|
51
|
+
@worker.value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def close
|
|
55
|
+
@worker&.kill
|
|
56
|
+
@worker&.join
|
|
57
|
+
@listener&.close
|
|
58
|
+
@ca_file&.close!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def certificate(key:, name:)
|
|
64
|
+
cert = OpenSSL::X509::Certificate.new
|
|
65
|
+
cert.version = 2
|
|
66
|
+
cert.serial = OpenSSL::BN.rand(128).to_i
|
|
67
|
+
cert.subject = OpenSSL::X509::Name.parse("/CN=#{name}")
|
|
68
|
+
cert.issuer = cert.subject
|
|
69
|
+
cert.public_key = key
|
|
70
|
+
cert.not_before = Time.now - 60
|
|
71
|
+
cert.not_after = Time.now + 3600
|
|
72
|
+
cert
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def serve(server:)
|
|
76
|
+
socket = server.accept
|
|
77
|
+
request = +''
|
|
78
|
+
while (line = socket.gets) && line != "\r\n"
|
|
79
|
+
request << line
|
|
80
|
+
end
|
|
81
|
+
return if request.empty?
|
|
82
|
+
|
|
83
|
+
@requests << request
|
|
84
|
+
body = '{"status":"ok"}'
|
|
85
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
|
|
86
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
87
|
+
Legion::Logging.debug("TLS fixture handshake rejected: #{e.class}")
|
|
88
|
+
ensure
|
|
89
|
+
socket&.close
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LegionIO
|
|
@@ -388,6 +388,7 @@ files:
|
|
|
388
388
|
- spec/legion/extensions/llm/connection_close_spec.rb
|
|
389
389
|
- spec/legion/extensions/llm/connection_logging_spec.rb
|
|
390
390
|
- spec/legion/extensions/llm/connection_retry_spec.rb
|
|
391
|
+
- spec/legion/extensions/llm/connection_tls_spec.rb
|
|
391
392
|
- spec/legion/extensions/llm/credential_sources_spec.rb
|
|
392
393
|
- spec/legion/extensions/llm/error_middleware_spec.rb
|
|
393
394
|
- spec/legion/extensions/llm/error_spec.rb
|
|
@@ -439,6 +440,7 @@ files:
|
|
|
439
440
|
- spec/support/rspec_configuration.rb
|
|
440
441
|
- spec/support/simplecov_configuration.rb
|
|
441
442
|
- spec/support/ssot_registry_helpers.rb
|
|
443
|
+
- spec/support/tls_endpoint.rb
|
|
442
444
|
homepage: https://github.com/LegionIO/lex-llm
|
|
443
445
|
licenses:
|
|
444
446
|
- MIT
|