legionio 1.5.4 → 1.5.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: '0418444aa216ec13ed88dfb05f28df8928ffbfbfabcee2b1680fad3fcc0f6d11'
4
- data.tar.gz: a377af093161703e150a689102bc9a8e460409b36ba27cf60d05d66932f8fb9f
3
+ metadata.gz: 73b570b5654f1f6a65839229bbe08b7dcba61b301cb894efcd34c453c824be3c
4
+ data.tar.gz: 666be120babae098c300353258d1045c06963d258b58db0cc21241eb9be3ddcd
5
5
  SHA512:
6
- metadata.gz: 026fc5fe5f7340f0a26bfac07738a95f8af2ae2fdb66877a105fcd3ddc0ac7cc5fec71f541dfcfa4fd8a59d39df2f90eace55a8f7f66be9dd63ef99f37f585ee
7
- data.tar.gz: 21a74bee63dcb4dcba2c945b5ed7791b5d8158780454290cf207dc5af7aa84985503d77db1ec70b0233e69251e2441a449dcb22416ef792ff7d40bee2ecdab77
6
+ metadata.gz: 6f9c0d44daa83169a9ef909bb506ac4b29d37a272d0b7a837aa3e44acc2db40f125de383697784499981d47a13907b0186059f669499cc1c3251e19e544d9df8
7
+ data.tar.gz: 8ab9224d32f76321a5b56143b63b945c95199016df101f856850bada1632683f46d1c894117fac0508f587e3a93ec499ace11b521efb3d7b0298331da0afcd3a
data/.gitignore CHANGED
@@ -24,4 +24,6 @@ legion_colors*.html
24
24
  legionio_animated*.html
25
25
  legionio_wallpaper*.svg
26
26
  # generated executive briefs
27
- legionio_overview*
27
+ legionio_overview*
28
+ # git worktrees
29
+ .worktrees/
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.5.5] - 2026-03-24
4
+
5
+ ### Added
6
+ - `Legion::Service#setup_api`: optional Puma TLS via `api.tls.enabled` feature flag (default false); falls back to plain HTTP if cert/key missing
7
+ - `Legion::CLI::Doctor::TlsCheck`: `legion doctor` check for TLS configuration across all components (transport, data, api)
8
+ - `config/tls/settings-tls.json`: complete TLS settings template for all components
9
+ - `config/tls/generate-certs.sh`: dev self-signed CA + server/client cert generator
10
+ - `config/tls/README.md`: TLS setup and validation instructions
11
+
3
12
  ## [1.5.4] - 2026-03-24
4
13
 
5
14
  ### Added
@@ -0,0 +1,31 @@
1
+ # LegionIO TLS Configuration
2
+
3
+ Quick-start guide for enabling TLS on all LegionIO components.
4
+
5
+ ## Generating Dev Certificates
6
+
7
+ ```bash
8
+ sudo ./generate-certs.sh /etc/legionio/tls
9
+ ```
10
+
11
+ Requires `openssl` in PATH. Creates:
12
+ - `ca.pem` / `ca.key` — self-signed CA
13
+ - `server.crt` / `server.key` — server certificate (localhost + 127.0.0.1 SAN)
14
+ - `client.crt` / `client.key` — client certificate
15
+
16
+ ## Applying the Settings
17
+
18
+ Copy `settings-tls.json` to your LegionIO settings directory
19
+ (`~/legionio/settings/` or `/etc/legionio/settings/`) and adjust paths.
20
+
21
+ Feature flags (default false — plain connections preserved unless enabled):
22
+ - `data.tls.enabled` — enables TLS for PostgreSQL/MySQL
23
+ - `api.tls.enabled` — enables TLS for the Puma HTTP API
24
+
25
+ ## Validating
26
+
27
+ ```bash
28
+ legion doctor
29
+ ```
30
+
31
+ The TLS doctor check verifies: TLS enabled/verify mode, cert file existence, sslmode correctness.
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Generates a self-signed CA and service certificates for local TLS development.
5
+ # Usage: ./generate-certs.sh [output-dir]
6
+ # Default output-dir: /etc/legionio/tls
7
+
8
+ OUTPUT_DIR="${1:-/etc/legionio/tls}"
9
+ DAYS=365
10
+ CA_CN="LegionIO Dev CA"
11
+ SERVER_CN="legionio-server"
12
+ CLIENT_CN="legionio-client"
13
+
14
+ mkdir -p "${OUTPUT_DIR}"
15
+
16
+ echo "Generating CA key and certificate..."
17
+ openssl genrsa -out "${OUTPUT_DIR}/ca.key" 4096
18
+ openssl req -new -x509 \
19
+ -key "${OUTPUT_DIR}/ca.key" \
20
+ -out "${OUTPUT_DIR}/ca.pem" \
21
+ -days "${DAYS}" \
22
+ -subj "/CN=${CA_CN}/O=LegionIO/OU=Dev"
23
+
24
+ echo "Generating server key and CSR..."
25
+ openssl genrsa -out "${OUTPUT_DIR}/server.key" 2048
26
+ openssl req -new \
27
+ -key "${OUTPUT_DIR}/server.key" \
28
+ -out "${OUTPUT_DIR}/server.csr" \
29
+ -subj "/CN=${SERVER_CN}/O=LegionIO/OU=Dev"
30
+
31
+ echo "Signing server certificate with CA..."
32
+ openssl x509 -req \
33
+ -in "${OUTPUT_DIR}/server.csr" \
34
+ -CA "${OUTPUT_DIR}/ca.pem" \
35
+ -CAkey "${OUTPUT_DIR}/ca.key" \
36
+ -CAcreateserial \
37
+ -out "${OUTPUT_DIR}/server.crt" \
38
+ -days "${DAYS}" \
39
+ -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1")
40
+
41
+ echo "Generating client key and CSR..."
42
+ openssl genrsa -out "${OUTPUT_DIR}/client.key" 2048
43
+ openssl req -new \
44
+ -key "${OUTPUT_DIR}/client.key" \
45
+ -out "${OUTPUT_DIR}/client.csr" \
46
+ -subj "/CN=${CLIENT_CN}/O=LegionIO/OU=Dev"
47
+
48
+ echo "Signing client certificate with CA..."
49
+ openssl x509 -req \
50
+ -in "${OUTPUT_DIR}/client.csr" \
51
+ -CA "${OUTPUT_DIR}/ca.pem" \
52
+ -CAkey "${OUTPUT_DIR}/ca.key" \
53
+ -CAcreateserial \
54
+ -out "${OUTPUT_DIR}/client.crt" \
55
+ -days "${DAYS}"
56
+
57
+ chmod 600 "${OUTPUT_DIR}"/*.key
58
+ rm -f "${OUTPUT_DIR}"/*.csr "${OUTPUT_DIR}"/*.srl
59
+
60
+ echo ""
61
+ echo "Certificates written to ${OUTPUT_DIR}:"
62
+ ls -lh "${OUTPUT_DIR}"
63
+ echo ""
64
+ echo "Reference these paths in settings-tls.json or your legionio settings JSON."
@@ -0,0 +1,43 @@
1
+ {
2
+ "transport": {
3
+ "connection": {
4
+ "port": 5671
5
+ },
6
+ "tls": {
7
+ "enabled": true,
8
+ "verify": "peer",
9
+ "ca": "/etc/legionio/tls/ca.pem",
10
+ "cert": "/etc/legionio/tls/client.crt",
11
+ "key": "/etc/legionio/tls/client.key"
12
+ }
13
+ },
14
+ "data": {
15
+ "adapter": "postgres",
16
+ "tls": {
17
+ "enabled": true,
18
+ "sslmode": "verify-full",
19
+ "ca": "/etc/legionio/tls/ca.pem",
20
+ "cert": "/etc/legionio/tls/client.crt",
21
+ "key": "/etc/legionio/tls/client.key"
22
+ }
23
+ },
24
+ "cache": {
25
+ "adapter": "redis",
26
+ "tls": {
27
+ "enabled": true,
28
+ "verify": "peer",
29
+ "ca": "/etc/legionio/tls/ca.pem"
30
+ }
31
+ },
32
+ "api": {
33
+ "port": 4567,
34
+ "bind": "0.0.0.0",
35
+ "tls": {
36
+ "enabled": true,
37
+ "cert": "/etc/legionio/tls/server.crt",
38
+ "key": "/etc/legionio/tls/server.key",
39
+ "ca": "/etc/legionio/tls/ca.pem",
40
+ "verify": "peer"
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'stringio'
5
+ require_relative 'hash_chain'
6
+ require_relative 'siem_export'
7
+ require_relative 'cold_storage'
8
+
9
+ module Legion
10
+ module Audit
11
+ module Archiver
12
+ module_function
13
+
14
+ def enabled?
15
+ Legion::Settings[:audit]&.dig(:retention, :enabled) == true
16
+ end
17
+
18
+ def hot_days
19
+ Legion::Settings[:audit]&.dig(:retention, :hot_days) || 90
20
+ end
21
+
22
+ def warm_days
23
+ Legion::Settings[:audit]&.dig(:retention, :warm_days) || 365
24
+ end
25
+
26
+ def verify_on_archive?
27
+ Legion::Settings[:audit]&.dig(:retention, :verify_on_archive) != false
28
+ end
29
+
30
+ # hot -> warm: move audit_log rows older than hot_days to audit_log_archive
31
+ def archive_to_warm(cutoff_days: hot_days)
32
+ return { moved: 0, skipped: true } unless enabled?
33
+
34
+ result = Legion::Data::Retention.archive_old_records(
35
+ table: :audit_log,
36
+ archive_after_days: cutoff_days
37
+ )
38
+ { moved: result[:archived], from: :hot, to: :warm }
39
+ end
40
+
41
+ # warm -> cold: export audit_log_archive rows older than warm_days to compressed JSONL,
42
+ # upload to cold storage, record manifest, delete from warm after checksum verification
43
+ def archive_to_cold(cutoff_days: warm_days)
44
+ return { moved: 0, skipped: true } unless enabled?
45
+
46
+ db = Legion::Data.connection
47
+ return { moved: 0, error: 'no_db' } unless db&.table_exists?(:audit_log_archive)
48
+
49
+ cutoff = Time.now - (cutoff_days * 86_400)
50
+ dataset = db[:audit_log_archive].where(::Sequel.lit('created_at < ?', cutoff))
51
+ count = dataset.count
52
+ return { moved: 0 } if count.zero?
53
+
54
+ records = dataset.order(:id).all
55
+ ndjson = Legion::Audit::SiemExport.to_ndjson(records.map { |r| r.is_a?(Hash) ? r : r.values })
56
+ gz_data = compress(ndjson)
57
+ checksum = ::Digest::SHA256.hexdigest(gz_data)
58
+
59
+ path = cold_path(records)
60
+ Legion::Audit::ColdStorage.upload(data: gz_data, path: path)
61
+
62
+ write_manifest(
63
+ tier: 'cold',
64
+ storage_url: path,
65
+ start_date: records.first[:created_at],
66
+ end_date: records.last[:created_at],
67
+ entry_count: count,
68
+ checksum: checksum,
69
+ first_hash: records.first[:record_hash].to_s,
70
+ last_hash: records.last[:record_hash].to_s
71
+ )
72
+
73
+ dataset.delete
74
+ log_info "Archived #{count} warm audit records to cold: #{path}"
75
+ { moved: count, path: path, checksum: checksum }
76
+ end
77
+
78
+ # verify hash chain integrity for a given tier across an optional date range
79
+ def verify_chain(tier: :hot, start_date: nil, end_date: nil)
80
+ records = load_records_for_tier(tier: tier, start_date: start_date, end_date: end_date)
81
+ Legion::Audit::HashChain.verify_chain(records)
82
+ end
83
+
84
+ def cold_storage_url
85
+ Legion::Settings[:audit]&.dig(:retention, :cold_storage) || '/var/lib/legion/audit-archive/'
86
+ end
87
+
88
+ def cold_path(records)
89
+ ts = records.first[:created_at]
90
+ stamp = ts.respond_to?(:strftime) ? ts.strftime('%Y%m%d') : ts.to_s[0, 8].tr('-', '')
91
+ ::File.join(cold_storage_url, "audit_cold_#{stamp}_#{records.last[:id]}.jsonl.gz")
92
+ end
93
+
94
+ def compress(text)
95
+ sio = ::StringIO.new
96
+ gz = ::Zlib::GzipWriter.new(sio)
97
+ gz.write(text)
98
+ gz.close
99
+ sio.string
100
+ end
101
+
102
+ def write_manifest(tier:, storage_url:, start_date:, end_date:, entry_count:, checksum:, first_hash:, last_hash:) # rubocop:disable Metrics/ParameterLists
103
+ db = Legion::Data.connection
104
+ return unless db&.table_exists?(:audit_archive_manifests)
105
+
106
+ db[:audit_archive_manifests].insert(
107
+ tier: tier,
108
+ storage_url: storage_url,
109
+ start_date: start_date,
110
+ end_date: end_date,
111
+ entry_count: entry_count,
112
+ checksum: checksum,
113
+ first_hash: first_hash,
114
+ last_hash: last_hash,
115
+ archived_at: Time.now.utc
116
+ )
117
+ end
118
+
119
+ def load_records_for_tier(tier:, start_date: nil, end_date: nil)
120
+ db = Legion::Data.connection
121
+ table = tier.to_sym == :hot ? :audit_log : :audit_log_archive
122
+ return [] unless db&.table_exists?(table)
123
+
124
+ ds = db[table].order(:id)
125
+ ds = ds.where(::Sequel.lit('created_at >= ?', start_date)) if start_date
126
+ ds = ds.where(::Sequel.lit('created_at <= ?', end_date)) if end_date
127
+ ds.all
128
+ end
129
+
130
+ def log_info(msg)
131
+ Legion::Logging.info("[Audit::Archiver] #{msg}") if defined?(Legion::Logging)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'archiver'
4
+
5
+ module Legion
6
+ module Audit
7
+ class ArchiverActor
8
+ INTERVAL_SECONDS = 3600 # check every hour; day-of-week guard applies
9
+
10
+ class << self
11
+ def enabled?
12
+ Legion::Audit::Archiver.enabled?
13
+ end
14
+
15
+ def schedule_setting
16
+ Legion::Settings[:audit]&.dig(:retention, :archive_schedule) || '0 2 * * 0'
17
+ end
18
+
19
+ # Parse cron day-of-week (field 5) — returns integer 0..6, 0=Sunday
20
+ def scheduled_day_of_week
21
+ schedule_setting.split[4].to_i
22
+ end
23
+
24
+ # Parse cron hour (field 2)
25
+ def scheduled_hour
26
+ schedule_setting.split[1].to_i
27
+ end
28
+ end
29
+
30
+ def run_archival
31
+ return unless self.class.enabled?
32
+
33
+ now = Time.now.utc
34
+ return unless now.wday == self.class.scheduled_day_of_week
35
+ return unless now.hour == self.class.scheduled_hour
36
+
37
+ Legion::Logging.info '[Audit::ArchiverActor] starting weekly archival' if defined?(Legion::Logging)
38
+
39
+ warm_result = Legion::Audit::Archiver.archive_to_warm
40
+ cold_result = Legion::Audit::Archiver.archive_to_cold
41
+
42
+ if Legion::Audit::Archiver.verify_on_archive?
43
+ verify_result = Legion::Audit::Archiver.verify_chain(tier: :warm)
44
+ if !verify_result[:valid] && defined?(Legion::Logging)
45
+ Legion::Logging.error "[Audit::ArchiverActor] chain broken after archival: #{verify_result[:broken_links].count} links"
46
+ end
47
+ end
48
+
49
+ return unless defined?(Legion::Logging)
50
+
51
+ Legion::Logging.info "[Audit::ArchiverActor] complete warm=#{warm_result[:moved]} cold=#{cold_result[:moved]}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Audit
5
+ module ColdStorage
6
+ class BackendNotAvailableError < StandardError; end
7
+
8
+ module_function
9
+
10
+ def backend
11
+ raw = Legion::Settings[:audit]&.dig(:retention, :cold_backend) || 'local'
12
+ raw.to_sym
13
+ end
14
+
15
+ def upload(data:, path:)
16
+ case backend
17
+ when :local then local_upload(data: data, path: path)
18
+ when :s3 then s3_upload(data: data, path: path)
19
+ else raise BackendNotAvailableError, "unknown cold_backend: #{backend}"
20
+ end
21
+ end
22
+
23
+ def download(path:)
24
+ case backend
25
+ when :local then local_download(path: path)
26
+ when :s3 then s3_download(path: path)
27
+ else raise BackendNotAvailableError, "unknown cold_backend: #{backend}"
28
+ end
29
+ end
30
+
31
+ def local_upload(data:, path:)
32
+ ::FileUtils.mkdir_p(::File.dirname(path))
33
+ ::File.binwrite(path, data)
34
+ { path: path, bytes: data.bytesize }
35
+ end
36
+
37
+ def local_download(path:)
38
+ ::File.binread(path)
39
+ end
40
+
41
+ def s3_client
42
+ raise BackendNotAvailableError, 'aws-sdk-s3 gem is required for :s3 cold_backend' \
43
+ unless defined?(Aws::S3::Client)
44
+
45
+ @s3_client ||= Aws::S3::Client.new
46
+ end
47
+
48
+ def s3_bucket
49
+ Legion::Settings[:audit]&.dig(:retention, :s3_bucket) ||
50
+ raise(BackendNotAvailableError, 'audit.retention.s3_bucket must be set for :s3 backend')
51
+ end
52
+
53
+ def s3_upload(data:, path:)
54
+ s3_client.put_object(bucket: s3_bucket, key: path, body: data,
55
+ content_type: 'application/gzip',
56
+ server_side_encryption: 'AES256')
57
+ { path: path, bytes: data.bytesize }
58
+ end
59
+
60
+ def s3_download(path:)
61
+ resp = s3_client.get_object(bucket: s3_bucket, key: path)
62
+ resp.body.read
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../audit/archiver'
4
+ require_relative '../audit/cold_storage'
5
+
3
6
  module Legion
4
7
  module CLI
5
8
  class Audit < Thor
@@ -38,7 +41,7 @@ module Legion
38
41
  end
39
42
  end
40
43
 
41
- desc 'verify', 'Verify audit log hash chain integrity'
44
+ desc 'verify', 'Verify audit log hash chain integrity (lex-audit runner path)'
42
45
  option :json, type: :boolean, default: false, desc: 'Output as JSON'
43
46
  def verify
44
47
  Connection.ensure_settings
@@ -61,6 +64,143 @@ module Legion
61
64
  exit 1
62
65
  end
63
66
  end
67
+
68
+ desc 'archive', 'Archive audit records across tiers (hot -> warm -> cold)'
69
+ option :dry_run, type: :boolean, default: false, aliases: '--dry-run', desc: 'Preview without executing'
70
+ option :execute, type: :boolean, default: false, desc: 'Run archival now'
71
+ option :json, type: :boolean, default: false, desc: 'Output as JSON'
72
+ def archive
73
+ Connection.ensure_settings
74
+ Connection.ensure_data
75
+
76
+ unless Legion::Audit::Archiver.enabled?
77
+ puts 'Audit retention is disabled. Set audit.retention.enabled = true to activate.'
78
+ return
79
+ end
80
+
81
+ if options[:dry_run]
82
+ status = Legion::Data::Retention.retention_status(table: :audit_log)
83
+ output = {
84
+ mode: 'DRY RUN',
85
+ hot_records: status[:active_count],
86
+ warm_records: status[:archived_count],
87
+ oldest_hot: status[:oldest_active]&.to_s,
88
+ oldest_warm: status[:oldest_archived]&.to_s,
89
+ hot_days: Legion::Audit::Archiver.hot_days,
90
+ warm_days: Legion::Audit::Archiver.warm_days
91
+ }
92
+ if options[:json]
93
+ puts Legion::JSON.dump(output)
94
+ else
95
+ puts 'DRY RUN — no records will be moved'
96
+ output.each { |k, v| puts " #{k}: #{v}" }
97
+ end
98
+ return
99
+ end
100
+
101
+ unless options[:execute]
102
+ puts 'Pass --execute to run archival, or --dry-run to preview.'
103
+ return
104
+ end
105
+
106
+ warm_result = Legion::Audit::Archiver.archive_to_warm
107
+ puts "Archived #{warm_result[:moved]} records to warm" unless options[:json]
108
+
109
+ cold_result = Legion::Audit::Archiver.archive_to_cold
110
+ puts "Archived #{cold_result[:moved]} records to cold: #{cold_result[:path]}" unless options[:json]
111
+
112
+ if Legion::Audit::Archiver.verify_on_archive?
113
+ verify_result = Legion::Audit::Archiver.verify_chain(tier: :warm)
114
+ unless options[:json]
115
+ if verify_result[:valid]
116
+ puts "Chain integrity verified: #{verify_result[:records_checked]} warm records"
117
+ else
118
+ puts "WARNING: chain broken in warm tier after archival (#{verify_result[:broken_links].count} links)"
119
+ end
120
+ end
121
+ end
122
+
123
+ puts Legion::JSON.dump({ warm: warm_result, cold: cold_result }) if options[:json]
124
+ end
125
+
126
+ desc 'verify_chain', 'Verify hash chain integrity for a specific tier and date range'
127
+ option :tier, type: :string, default: 'hot', desc: 'Tier to verify: hot, warm'
128
+ option :start, type: :string, desc: 'ISO8601 start date (inclusive)'
129
+ option :end, type: :string, desc: 'ISO8601 end date (inclusive)'
130
+ option :json, type: :boolean, default: false, desc: 'Output as JSON'
131
+ def verify_chain
132
+ Connection.ensure_settings
133
+ Connection.ensure_data
134
+
135
+ tier = options[:tier].to_sym
136
+ start_date = options[:start] ? Time.parse(options[:start]) : nil
137
+ end_date = options[:end] ? Time.parse(options[:end]) : nil
138
+
139
+ result = Legion::Audit::Archiver.verify_chain(
140
+ tier: tier,
141
+ start_date: start_date,
142
+ end_date: end_date
143
+ )
144
+
145
+ if options[:json]
146
+ puts Legion::JSON.dump(result)
147
+ elsif result[:valid]
148
+ puts "Chain valid (#{tier}): #{result[:records_checked]} records verified"
149
+ else
150
+ puts "CHAIN BROKEN in #{tier} tier — #{result[:broken_links].count} broken link(s)"
151
+ result[:broken_links].each { |l| puts " record ##{l[:id]}: expected #{l[:expected]}, got #{l[:got]}" }
152
+ exit 1
153
+ end
154
+ end
155
+
156
+ desc 'restore', 'Restore cold-archived records to warm tier for querying'
157
+ option :date, type: :string, required: true, desc: 'Date stamp of archive to restore (YYYYMMDD or ISO8601)'
158
+ option :json, type: :boolean, default: false, desc: 'Output as JSON'
159
+ def restore
160
+ Connection.ensure_settings
161
+ Connection.ensure_data
162
+
163
+ unless Legion::Audit::Archiver.enabled?
164
+ puts 'Audit retention is disabled.'
165
+ return
166
+ end
167
+
168
+ db = Legion::Data.connection
169
+ unless db&.table_exists?(:audit_archive_manifests)
170
+ puts 'No archive manifests table found. Has migration 039 been run?'
171
+ exit 1
172
+ end
173
+
174
+ date_str = options[:date].tr('-', '')[0, 8]
175
+ manifests = db[:audit_archive_manifests]
176
+ .where(tier: 'cold')
177
+ .where(::Sequel.like(:storage_url, "%#{date_str}%"))
178
+ .all
179
+
180
+ if manifests.empty?
181
+ puts "No cold archives found for date: #{options[:date]}"
182
+ exit 1
183
+ end
184
+
185
+ restored = 0
186
+ manifests.each do |manifest|
187
+ gz_data = Legion::Audit::ColdStorage.download(path: manifest[:storage_url])
188
+ ndjson = ::Zlib::GzipReader.new(::StringIO.new(gz_data)).read
189
+ records = ndjson.split("\n").map { |line| Legion::JSON.load(line) }
190
+
191
+ db.transaction do
192
+ records.each { |r| db[:audit_log_archive].insert(r.transform_keys(&:to_sym)) }
193
+ end
194
+ restored += records.size
195
+ end
196
+
197
+ result = { restored: restored, manifests: manifests.count }
198
+ if options[:json]
199
+ puts Legion::JSON.dump(result)
200
+ else
201
+ puts "Restored #{restored} records from #{manifests.count} cold archive(s) to warm tier"
202
+ end
203
+ end
64
204
  end
65
205
  end
66
206
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class TlsCheck
7
+ def name
8
+ 'TLS'
9
+ end
10
+
11
+ def run
12
+ return Result.new(name: name, status: :skip, message: 'Legion::Settings not available') unless defined?(Legion::Settings)
13
+
14
+ issues = []
15
+ any_tls = false
16
+
17
+ check_transport_tls(issues) && (any_tls = true)
18
+ check_data_tls(issues) && (any_tls = true)
19
+ check_api_tls(issues) && (any_tls = true)
20
+
21
+ build_result(issues, any_tls)
22
+ rescue StandardError => e
23
+ Result.new(
24
+ name: name,
25
+ status: :fail,
26
+ message: "TLS check error: #{e.message}",
27
+ prescription: 'Review TLS settings configuration'
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def check_transport_tls(issues)
34
+ tls = safe_tls_settings(:transport)
35
+ return false unless tls[:enabled]
36
+
37
+ issues << 'transport.tls: verify is none — peer verification disabled' if tls[:verify].to_s == 'none'
38
+
39
+ check_cert_file(tls[:cert], 'transport.tls.cert', issues)
40
+ check_cert_file(tls[:key], 'transport.tls.key', issues)
41
+ check_cert_file(tls[:ca], 'transport.tls.ca', issues)
42
+ true
43
+ end
44
+
45
+ def check_data_tls(issues)
46
+ tls = safe_tls_settings(:data)
47
+ return false unless tls[:enabled]
48
+
49
+ sslmode = tls[:sslmode].to_s
50
+ issues << "data.tls: sslmode is '#{sslmode}' — use 'verify-full' to prevent MITM" unless sslmode.empty? || sslmode == 'verify-full'
51
+
52
+ true
53
+ end
54
+
55
+ def check_api_tls(issues)
56
+ tls = safe_tls_settings(:api)
57
+ return false unless tls[:enabled]
58
+
59
+ cert = tls[:cert]
60
+ key = tls[:key]
61
+
62
+ if cert.nil? || cert.to_s.empty?
63
+ issues << 'api.tls: enabled but api.tls.cert is not set'
64
+ return true
65
+ end
66
+
67
+ if key.nil? || key.to_s.empty?
68
+ issues << 'api.tls: enabled but api.tls.key is not set'
69
+ return true
70
+ end
71
+
72
+ check_cert_file(cert, 'api.tls.cert', issues)
73
+ check_cert_file(key, 'api.tls.key', issues)
74
+ true
75
+ end
76
+
77
+ def build_result(issues, any_tls)
78
+ return Result.new(name: name, status: :pass, message: 'TLS not enabled on any component') unless any_tls
79
+
80
+ if issues.any? { |i| i.include?('not set') }
81
+ return Result.new(
82
+ name: name,
83
+ status: :fail,
84
+ message: issues.first,
85
+ prescription: 'Set the missing TLS cert/key paths in settings'
86
+ )
87
+ end
88
+
89
+ if issues.any?
90
+ return Result.new(
91
+ name: name,
92
+ status: :warn,
93
+ message: issues.first,
94
+ prescription: 'Review TLS configuration — see api.tls / transport.tls / data.tls in settings'
95
+ )
96
+ end
97
+
98
+ Result.new(name: name, status: :pass, message: 'TLS configured correctly on enabled components')
99
+ end
100
+
101
+ def safe_tls_settings(component)
102
+ raw = Legion::Settings[component] || {}
103
+ tls = raw[:tls] || raw['tls'] || {}
104
+ symbolize_keys(tls)
105
+ rescue StandardError
106
+ {}
107
+ end
108
+
109
+ def check_cert_file(path, label, issues)
110
+ return if path.nil? || path.to_s.empty?
111
+ return if path.to_s.start_with?('vault://', 'env://', 'lease://')
112
+ return if ::File.exist?(path.to_s)
113
+
114
+ issues << "#{label}: '#{path}' does not exist"
115
+ end
116
+
117
+ def symbolize_keys(hash)
118
+ return {} unless hash.is_a?(Hash)
119
+
120
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -16,6 +16,7 @@ module Legion
16
16
  autoload :ExtensionsCheck, 'legion/cli/doctor/extensions_check'
17
17
  autoload :PidCheck, 'legion/cli/doctor/pid_check'
18
18
  autoload :PermissionsCheck, 'legion/cli/doctor/permissions_check'
19
+ autoload :TlsCheck, 'legion/cli/doctor/tls_check'
19
20
 
20
21
  def self.exit_on_failure?
21
22
  true
@@ -35,6 +36,7 @@ module Legion
35
36
  ExtensionsCheck
36
37
  PidCheck
37
38
  PermissionsCheck
39
+ TlsCheck
38
40
  ].freeze
39
41
 
40
42
  desc 'diagnose', 'Check environment health and suggest fixes'
@@ -12,6 +12,13 @@ module Legion
12
12
 
13
13
  log_level = options[:log_level] || 'info'
14
14
 
15
+ # Load settings early, before any legion-* gem requires can trigger auto-load.
16
+ # This ensures DNS bootstrap and config file loading happen exactly once.
17
+ require 'legion/json'
18
+ require 'legion/settings'
19
+ directories = Legion::Settings::Loader.default_directories.select { |d| Dir.exist?(d) }
20
+ Legion::Settings.load(config_dirs: directories)
21
+
15
22
  require 'legion'
16
23
  require 'legion/service'
17
24
  require 'legion/process'
@@ -38,8 +45,6 @@ module Legion
38
45
  private
39
46
 
40
47
  def clear_log_file
41
- require 'legion/settings'
42
- Legion::Settings.load
43
48
  logging = Legion::Settings[:logging]
44
49
  return unless logging.is_a?(Hash) && logging[:log_file]
45
50
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Compliance
5
+ module PhiAccessLog
6
+ class << self
7
+ def log_access(resource:, action:, actor:, reason:)
8
+ return unless Legion::Compliance.phi_enabled?
9
+ return unless defined?(Legion::Audit)
10
+
11
+ Legion::Audit.record(
12
+ event_type: 'phi_access',
13
+ principal_id: actor,
14
+ action: action,
15
+ resource: resource,
16
+ detail: { reason: reason, phi: true }
17
+ )
18
+ rescue StandardError => e
19
+ Legion::Logging.error "[Compliance] PhiAccessLog#log_access failed: #{e.message}" if defined?(Legion::Logging)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Compliance
5
+ module PhiTag
6
+ class << self
7
+ def phi?(metadata)
8
+ return false unless Legion::Compliance.phi_enabled?
9
+ return false unless metadata.is_a?(Hash)
10
+
11
+ metadata[:phi] == true
12
+ end
13
+
14
+ def tag(metadata)
15
+ base = metadata.is_a?(Hash) ? metadata : {}
16
+ base.merge(phi: true, data_classification: 'restricted')
17
+ end
18
+
19
+ def tagged_cache_key(key)
20
+ str = key.to_s
21
+ return str if str.start_with?('phi:')
22
+
23
+ "phi:#{str}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/compliance/phi_tag'
4
+ require 'legion/compliance/phi_access_log'
5
+
6
+ module Legion
7
+ module Compliance
8
+ class << self
9
+ def phi_enabled?
10
+ return false unless defined?(Legion::Settings)
11
+
12
+ Legion::Settings[:compliance][:phi_enabled] == true
13
+ rescue StandardError
14
+ false
15
+ end
16
+ end
17
+ end
18
+ end
@@ -47,6 +47,49 @@ module Legion
47
47
  true
48
48
  end
49
49
 
50
+ def prepare # rubocop:disable Metrics/AbcSize
51
+ @queue = queue.new
52
+ @queue.channel.prefetch(prefetch) if defined? prefetch
53
+ consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{Thread.current.object_id}"
54
+ @consumer = Bunny::Consumer.new(@queue.channel, @queue, consumer_tag, false, false)
55
+ @consumer.on_delivery do |delivery_info, metadata, payload|
56
+ message = process_message(payload, metadata, delivery_info)
57
+ fn = find_function(message)
58
+ log.debug "[Subscription] message received: #{lex_name}/#{fn}" if defined?(log)
59
+
60
+ affinity_result = check_region_affinity(message)
61
+ if affinity_result == :reject
62
+ log.warn '[Subscription] nack: region affinity mismatch'
63
+ @queue.reject(delivery_info.delivery_tag) if manual_ack
64
+ next
65
+ end
66
+
67
+ record_cross_region_metric(message) if affinity_result == :remote
68
+
69
+ if use_runner?
70
+ dispatch_runner(message, runner_class, fn, check_subtask?, generate_task?)
71
+ else
72
+ runner_class.send(fn, **message)
73
+ end
74
+ @queue.acknowledge(delivery_info.delivery_tag) if manual_ack
75
+
76
+ cancel if Legion::Settings[:client][:shutting_down]
77
+ rescue StandardError => e
78
+ log.error "[Subscription] message processing failed: #{lex_name}/#{fn}: #{e.message}"
79
+ log.error e.backtrace
80
+ @queue.reject(delivery_info.delivery_tag) if manual_ack
81
+ end
82
+ log.info "[Subscription] prepared: #{lex_name}/#{runner_name}"
83
+ rescue StandardError => e
84
+ log.fatal "Subscription#prepare failed: #{e.message}"
85
+ log.fatal e.backtrace
86
+ end
87
+
88
+ def activate
89
+ @queue.subscribe_with(@consumer)
90
+ log.info "[Subscription] activated: #{lex_name}/#{runner_name} (consumer registered)"
91
+ end
92
+
50
93
  def block
51
94
  false
52
95
  end
@@ -101,7 +144,7 @@ module Legion
101
144
  end
102
145
 
103
146
  def subscribe # rubocop:disable Metrics/AbcSize
104
- log.info "[Subscription] starting: #{lex_name}/#{runner_name}"
147
+ log.info "[Subscription] subscribing: #{lex_name}/#{runner_name}"
105
148
  sleep(delay_start) if delay_start.positive?
106
149
  consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{Thread.current.object_id}"
107
150
  on_cancellation = block { cancel }
@@ -142,7 +185,7 @@ module Legion
142
185
  log.warn "[Subscription] nacking message for #{lex_name}/#{fn}"
143
186
  @queue.reject(delivery_info.delivery_tag) if manual_ack
144
187
  end
145
- log.info "[Subscription] stopped: #{lex_name}/#{runner_name}" if defined?(log)
188
+ log.info "[Subscription] subscribed: #{lex_name}/#{runner_name} (consumer registered)" if defined?(log)
146
189
  end
147
190
 
148
191
  private
@@ -31,14 +31,21 @@ module Legion
31
31
 
32
32
  attr_reader :local_tasks
33
33
 
34
- def shutdown
34
+ def shutdown # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
35
  return nil if @loaded_extensions.nil?
36
36
 
37
37
  @loaded_extensions.each { |name| Catalog.transition(name, :stopping) }
38
38
 
39
+ if @subscription_pool
40
+ @subscription_pool.shutdown
41
+ @subscription_pool.kill unless @subscription_pool.wait_for_termination(5)
42
+ @subscription_pool = nil
43
+ end
44
+
39
45
  @subscription_tasks.each do |task|
40
- task[:threadpool].shutdown
41
- task[:threadpool].kill unless task[:threadpool].wait_for_termination(5)
46
+ task[:running_class]&.new&.cancel if task[:running_class].is_a?(Class)
47
+ rescue StandardError => e
48
+ Legion::Logging.debug "Extension shutdown cancel failed: #{e.message}" if defined?(Legion::Logging)
42
49
  end
43
50
 
44
51
  @loop_tasks.each { |task| task[:running_class].cancel if task[:running_class].respond_to?(:cancel) }
@@ -88,12 +95,19 @@ module Legion
88
95
  def load_extensions_parallel(eligible)
89
96
  return if eligible.empty?
90
97
 
98
+ if defined?(Legion::Transport::Connection) && Legion::Transport::Connection.respond_to?(:open_build_session)
99
+ Legion::Transport::Connection.open_build_session
100
+ end
101
+
91
102
  max_threads = Legion::Settings.dig(:extensions, :parallel_pool_size) || 24
92
103
  pool_size = [eligible.count, max_threads].min
93
104
  executor = Concurrent::FixedThreadPool.new(pool_size)
94
105
 
95
106
  futures = eligible.map do |entry|
96
- Concurrent::Promises.future_on(executor, entry) { |e| load_extension(e) ? e : nil }
107
+ Concurrent::Promises.future_on(executor, entry) do |e|
108
+ Thread.current[:legion_build_session] = true
109
+ load_extension(e) ? e : nil
110
+ end
97
111
  end
98
112
 
99
113
  results = futures.map(&:value)
@@ -101,6 +115,10 @@ module Legion
101
115
  executor.shutdown
102
116
  executor.wait_for_termination(30)
103
117
 
118
+ if defined?(Legion::Transport::Connection) && Legion::Transport::Connection.respond_to?(:close_build_session)
119
+ Legion::Transport::Connection.close_build_session
120
+ end
121
+
104
122
  results.each_with_index do |result, idx|
105
123
  if result
106
124
  Catalog.transition(result[:gem_name], :loaded)
@@ -208,7 +226,18 @@ module Legion
208
226
  return if @pending_actors.nil? || @pending_actors.empty?
209
227
 
210
228
  Legion::Logging.info "Hooking #{@pending_actors.size} deferred actors"
211
- @pending_actors.each { |actor| hook_actor(**actor) }
229
+
230
+ sub_actors = []
231
+ @pending_actors.each do |actor|
232
+ if actor[:actor_class].ancestors.include?(Legion::Extensions::Actors::Subscription)
233
+ sub_actors << actor
234
+ else
235
+ hook_actor(**actor)
236
+ end
237
+ end
238
+
239
+ hook_subscription_actors_pooled(sub_actors) unless sub_actors.empty?
240
+
212
241
  @pending_actors.clear
213
242
  Legion::Logging.info(
214
243
  "Actors hooked: subscription:#{@subscription_tasks.count}," \
@@ -254,7 +283,7 @@ module Legion
254
283
  elsif actor_class.ancestors.include? Legion::Extensions::Actors::Poll
255
284
  @poll_tasks.push(extension_hash)
256
285
  elsif actor_class.ancestors.include? Legion::Extensions::Actors::Subscription
257
- hook_subscription_actor(extension_hash, size, opts)
286
+ hook_subscription_actors_pooled([extension_hash])
258
287
  else
259
288
  Legion::Logging.fatal "#{actor_class} did not match any actor classes (ancestors: #{actor_class.ancestors.first(5).map(&:to_s)})"
260
289
  end
@@ -296,29 +325,66 @@ module Legion
296
325
  []
297
326
  end
298
327
 
299
- def hook_subscription_actor(extension_hash, size, opts)
300
- ext_name = extension_hash[:extension_name]
301
- extension = extension_hash[:extension]
302
- actor_class = extension_hash[:actor_class]
328
+ def hook_subscription_actors_pooled(sub_actors)
329
+ max_channels = Legion::Settings.dig(:transport, :subscription_pool_size) || 16
330
+ prepared = []
303
331
 
304
- unless resolve_remote_invocable(ext_name, opts.merge(actor_class: actor_class, extension: extension))
305
- Legion::Logging.debug { "#{ext_name}/#{extension_hash[:actor_name]} is not remote_invocable, skipping AMQP subscription" }
306
- @local_tasks.push(extension_hash)
307
- return
308
- end
332
+ # Phase 1: Prepare all consumers (parallel, shared pool)
333
+ pool_size = [sub_actors.size, max_channels].min
334
+ @subscription_pool = Concurrent::FixedThreadPool.new(pool_size)
309
335
 
310
- extension_hash[:threadpool] = Concurrent::FixedThreadPool.new(size)
311
- size.times do
312
- extension_hash[:threadpool].post do
313
- klass = actor_class.new
314
- if klass.respond_to?(:async)
315
- klass.async.subscribe
316
- else
317
- klass.subscribe
336
+ sub_actors.each do |actor_hash|
337
+ actor_class = actor_hash[:actor_class]
338
+ ext_name = actor_hash[:extension_name]
339
+ size = resolve_subscription_worker_count(actor_hash)
340
+
341
+ unless resolve_remote_invocable(ext_name, actor_hash)
342
+ @local_tasks.push(actor_hash)
343
+ next
344
+ end
345
+
346
+ size.times do
347
+ entry = { actor_hash: actor_hash, instance: nil }
348
+ prepared << entry
349
+ @subscription_pool.post do
350
+ instance = actor_class.new
351
+ instance.prepare if instance.respond_to?(:prepare)
352
+ entry[:instance] = instance
353
+ rescue StandardError => e
354
+ Legion::Logging.error "Subscription prepare failed for #{ext_name}: #{e.message}" if defined?(Legion::Logging)
318
355
  end
319
356
  end
357
+
358
+ actor_hash[:running_class] = actor_class
359
+ @subscription_tasks.push(actor_hash)
360
+ end
361
+
362
+ @subscription_pool.shutdown
363
+ @subscription_pool.wait_for_termination(30)
364
+
365
+ # Phase 2: Activate sequentially (one basic.consume at a time)
366
+ prepared.each do |entry|
367
+ next unless entry[:instance]
368
+
369
+ begin
370
+ entry[:instance].activate if entry[:instance].respond_to?(:activate)
371
+ rescue StandardError => e
372
+ ext_name = entry[:actor_hash][:extension_name]
373
+ Legion::Logging.error "[Subscription] activate failed for #{ext_name}: #{e.message}" if defined?(Legion::Logging)
374
+ end
375
+ end
376
+ end
377
+
378
+ def resolve_subscription_worker_count(actor_hash)
379
+ ext_name = actor_hash[:extension_name]
380
+ ext_settings = Legion::Settings.dig(:extensions, ext_name.to_sym)
381
+ if ext_settings.is_a?(Hash) && ext_settings.key?(:workers)
382
+ ext_settings[:workers]
383
+ elsif actor_hash[:size].is_a?(Integer)
384
+ actor_hash[:size]
385
+ else
386
+ 1
320
387
  end
321
- @subscription_tasks.push(extension_hash)
322
388
  end
323
389
 
324
390
  def resolve_remote_invocable(extension_name, opts = {})
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Legion
4
4
  module Readiness
5
- COMPONENTS = %i[settings crypt transport cache data gaia extensions api].freeze
5
+ COMPONENTS = %i[settings crypt transport cache data rbac llm gaia extensions api].freeze
6
6
  DRAIN_TIMEOUT = 5
7
7
 
8
8
  class << self
@@ -49,30 +49,66 @@ module Legion
49
49
  end
50
50
 
51
51
  if cache
52
- require 'legion/cache'
53
- Legion::Cache.setup
54
- Legion::Readiness.mark_ready(:cache)
52
+ begin
53
+ require 'legion/cache'
54
+ Legion::Cache.setup
55
+ Legion::Readiness.mark_ready(:cache)
56
+ rescue StandardError => e
57
+ Legion::Logging.warn "Legion::Cache remote failed: #{e.message}, falling back to Cache::Local"
58
+ begin
59
+ Legion::Cache::Local.setup
60
+ Legion::Logging.info 'Legion::Cache::Local connected (fallback)'
61
+ rescue StandardError => e2
62
+ Legion::Logging.warn "Legion::Cache::Local also failed: #{e2.message}"
63
+ end
64
+ Legion::Readiness.mark_ready(:cache)
65
+ end
55
66
  end
56
67
 
57
68
  if data
58
- setup_data
59
- Legion::Readiness.mark_ready(:data)
69
+ begin
70
+ setup_data
71
+ Legion::Readiness.mark_ready(:data)
72
+ rescue StandardError => e
73
+ Legion::Logging.warn "Legion::Data remote failed: #{e.message}, falling back to Data::Local"
74
+ begin
75
+ require 'legion/data'
76
+ Legion::Data::Local.setup if defined?(Legion::Data::Local)
77
+ Legion::Logging.info 'Legion::Data::Local connected (fallback)'
78
+ rescue StandardError => e2
79
+ Legion::Logging.warn "Legion::Data::Local also failed: #{e2.message}"
80
+ end
81
+ Legion::Readiness.mark_ready(:data)
82
+ end
60
83
  end
61
84
 
62
85
  setup_rbac if data
63
86
  setup_cluster if data
64
87
 
65
88
  if llm
66
- setup_llm
67
- Legion::Readiness.mark_ready(:llm)
89
+ begin
90
+ setup_llm
91
+ Legion::Readiness.mark_ready(:llm)
92
+ rescue LoadError
93
+ Legion::Logging.info 'Legion::LLM gem is not installed'
94
+ rescue StandardError => e
95
+ Legion::Logging.warn "Legion::LLM failed: #{e.message}"
96
+ end
68
97
  end
69
98
 
70
99
  if gaia
71
- setup_gaia
72
- Legion::Readiness.mark_ready(:gaia)
100
+ begin
101
+ setup_gaia
102
+ Legion::Readiness.mark_ready(:gaia)
103
+ rescue LoadError
104
+ Legion::Logging.info 'Legion::Gaia gem is not installed'
105
+ rescue StandardError => e
106
+ Legion::Logging.warn "Legion::Gaia failed: #{e.message}"
107
+ end
73
108
  end
74
109
 
75
110
  setup_telemetry
111
+ setup_audit_archiver
76
112
  setup_safety_metrics
77
113
  setup_supervision if supervision
78
114
 
@@ -204,20 +240,29 @@ module Legion
204
240
  port = api_settings[:port] || 4567
205
241
  bind = api_settings[:bind] || '0.0.0.0'
206
242
 
243
+ Legion::API.set :port, port
244
+ Legion::API.set :bind, bind
245
+ Legion::API.set :server, :puma
246
+ Legion::API.set :environment, :production
247
+
248
+ tls_cfg = build_api_tls_config(api_settings)
249
+ if tls_cfg
250
+ Legion::API.set :ssl_bind_options, tls_cfg
251
+ Legion::API.set :server_settings, { quiet: true, **ssl_server_settings(tls_cfg, bind, port) }
252
+ Legion::Logging.info "Starting Legion API (TLS) on #{bind}:#{port}"
253
+ else
254
+ require 'puma'
255
+ puma_log = ::Puma::LogWriter.new(StringIO.new, StringIO.new)
256
+ Legion::API.set :server_settings, { log_writer: puma_log, quiet: true }
257
+ Legion::Logging.info "Starting Legion API on #{bind}:#{port}"
258
+ end
259
+
207
260
  @api_thread = Thread.new do
208
261
  retries = 0
209
262
  max_retries = api_settings.fetch(:bind_retries, 10)
210
- retry_wait = api_settings.fetch(:bind_retry_wait, 3)
263
+ retry_wait = api_settings.fetch(:bind_retry_wait, 3)
211
264
 
212
265
  begin
213
- Legion::API.set :port, port
214
- Legion::API.set :bind, bind
215
- Legion::API.set :server, :puma
216
- Legion::API.set :environment, :production
217
- require 'puma'
218
- puma_log = ::Puma::LogWriter.new(StringIO.new, StringIO.new)
219
- Legion::API.set :server_settings, { log_writer: puma_log, quiet: true }
220
- Legion::Logging.info "Starting Legion API on #{bind}:#{port}"
221
266
  Legion::API.run!(traps: false)
222
267
  rescue Errno::EADDRINUSE
223
268
  retries += 1
@@ -348,6 +393,30 @@ module Legion
348
393
  Legion::Logging.warn "OpenTelemetry setup failed: #{e.message}"
349
394
  end
350
395
 
396
+ def setup_audit_archiver
397
+ require_relative 'audit/archiver_actor'
398
+ return unless Legion::Audit::ArchiverActor.enabled?
399
+
400
+ @audit_archiver_thread = Thread.new do
401
+ loop do
402
+ Legion::Audit::ArchiverActor.new.run_archival
403
+ rescue StandardError => e
404
+ Legion::Logging.error "[Audit::ArchiverActor] error: #{e.message}" if defined?(Legion::Logging)
405
+ ensure
406
+ sleep Legion::Audit::ArchiverActor::INTERVAL_SECONDS
407
+ end
408
+ end
409
+ @audit_archiver_thread.abort_on_exception = false
410
+ Legion::Logging.info 'Audit archiver actor started' if defined?(Legion::Logging)
411
+ rescue StandardError => e
412
+ Legion::Logging.warn "Audit archiver setup failed: #{e.message}" if defined?(Legion::Logging)
413
+ end
414
+
415
+ def shutdown_audit_archiver
416
+ @audit_archiver_thread&.kill
417
+ @audit_archiver_thread = nil
418
+ end
419
+
351
420
  def setup_safety_metrics
352
421
  require_relative 'telemetry/safety_metrics'
353
422
  Legion::Telemetry::SafetyMetrics.start
@@ -381,6 +450,7 @@ module Legion
381
450
  Legion::Settings[:client][:shutting_down] = true
382
451
  Legion::Events.emit('service.shutting_down')
383
452
 
453
+ shutdown_audit_archiver
384
454
  shutdown_api
385
455
 
386
456
  Legion::Metrics.reset! if defined?(Legion::Metrics)
@@ -506,5 +576,42 @@ module Legion
506
576
  Legion::Logging.debug "Service#log_privacy_mode_status failed: #{e.message}" if defined?(Legion::Logging)
507
577
  nil
508
578
  end
579
+
580
+ private
581
+
582
+ def build_api_tls_config(api_settings)
583
+ tls = api_settings[:tls] || {}
584
+ tls = tls.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
585
+ return nil unless tls[:enabled] == true
586
+
587
+ cert = tls[:cert]
588
+ key = tls[:key]
589
+
590
+ unless cert && !cert.to_s.empty? && key && !key.to_s.empty?
591
+ Legion::Logging.warn 'api.tls enabled but cert or key is missing — falling back to plain HTTP'
592
+ return nil
593
+ end
594
+
595
+ {
596
+ cert: cert,
597
+ key: key,
598
+ ca: tls[:ca],
599
+ verify_mode: verify_mode_for(tls[:verify])
600
+ }.compact
601
+ end
602
+
603
+ def ssl_server_settings(tls_cfg, bind, port)
604
+ return {} unless tls_cfg
605
+
606
+ { binds: ["ssl://#{bind}:#{port}?cert=#{tls_cfg[:cert]}&key=#{tls_cfg[:key]}"] }
607
+ end
608
+
609
+ def verify_mode_for(verify)
610
+ case verify.to_s
611
+ when 'none' then 'none'
612
+ when 'mutual' then 'force_peer'
613
+ else 'peer'
614
+ end
615
+ end
509
616
  end
510
617
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.5.4'
4
+ VERSION = '1.5.5'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.4
4
+ version: 1.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -375,6 +375,9 @@ files:
375
375
  - completions/_legionio
376
376
  - completions/legion.bash
377
377
  - completions/legionio.bash
378
+ - config/tls/README.md
379
+ - config/tls/generate-certs.sh
380
+ - config/tls/settings-tls.json
378
381
  - deploy/helm/legion/Chart.yaml
379
382
  - deploy/helm/legion/templates/_helpers.tpl
380
383
  - deploy/helm/legion/templates/deployment-api.yaml
@@ -458,6 +461,9 @@ files:
458
461
  - lib/legion/api/workers.rb
459
462
  - lib/legion/api/workflow.rb
460
463
  - lib/legion/audit.rb
464
+ - lib/legion/audit/archiver.rb
465
+ - lib/legion/audit/archiver_actor.rb
466
+ - lib/legion/audit/cold_storage.rb
461
467
  - lib/legion/audit/hash_chain.rb
462
468
  - lib/legion/audit/siem_export.rb
463
469
  - lib/legion/capacity/model.rb
@@ -563,6 +569,7 @@ files:
563
569
  - lib/legion/cli/doctor/rabbitmq_check.rb
564
570
  - lib/legion/cli/doctor/result.rb
565
571
  - lib/legion/cli/doctor/ruby_version_check.rb
572
+ - lib/legion/cli/doctor/tls_check.rb
566
573
  - lib/legion/cli/doctor/vault_check.rb
567
574
  - lib/legion/cli/doctor_command.rb
568
575
  - lib/legion/cli/error.rb
@@ -664,6 +671,9 @@ files:
664
671
  - lib/legion/cluster.rb
665
672
  - lib/legion/cluster/leader.rb
666
673
  - lib/legion/cluster/lock.rb
674
+ - lib/legion/compliance.rb
675
+ - lib/legion/compliance/phi_access_log.rb
676
+ - lib/legion/compliance/phi_tag.rb
667
677
  - lib/legion/context.rb
668
678
  - lib/legion/data/local_migrations/20260319000001_create_extension_catalog.rb
669
679
  - lib/legion/data/local_migrations/20260319000002_create_extension_permissions.rb