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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2798bd117208f95e197b523f01bbd4937d7128f98fd8661ecfc609db33cb0a43
4
- data.tar.gz: 0ad1e3cbc9654be5959d8bbd66fe13f1b6f9711150a62d06aa1436f1e0029974
3
+ metadata.gz: e2216f17aaf84164eccc09087fb78e2a52339c24ae67207a43e220de31927fc3
4
+ data.tar.gz: 143caaf03ac594ef2cf9fb298836b7226f67c8b43ee4e0c0e5cdc26aaeb9b4d0
5
5
  SHA512:
6
- metadata.gz: 6ebaf6abe1388508ee2be7f365a129b3c83c5846a3cae522758323d000bb97e965a64c7dfb5ed4d4efc093e8f6b8245cc860861a725238da862faee01820f1fd
7
- data.tar.gz: 119413c94340f70c038782af0724835c00becfef3d1794f7de5b43a9c3c5e633663cafb73e5c67d68c22a3fdd0bd403e2cdfb86f4bc25f2edadd729709f2968b
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 = false
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
- begin
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
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.8.4'
6
+ VERSION = '0.8.5'
7
7
  end
8
8
  end
9
9
  end
@@ -156,6 +156,9 @@ module Legion
156
156
 
157
157
  def self.default_settings
158
158
  {
159
+ ssl: {
160
+ verify: false
161
+ },
159
162
  fleet: {
160
163
  consumer: {
161
164
  enabled: false,
@@ -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
- expect(mod.socket_open?('127.0.0.1', 39_999, timeout: 0.05)).to be false
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 'uses 0.1 second default timeout' do
264
- server = TCPServer.new('127.0.0.1', 0)
265
- port = server.addr[1]
266
- expect(mod.socket_open?('127.0.0.1', port, timeout: 0.1)).to be true
267
- ensure
268
- server&.close
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
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