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 +4 -4
- data/.gitignore +3 -1
- data/CHANGELOG.md +9 -0
- data/config/tls/README.md +31 -0
- data/config/tls/generate-certs.sh +64 -0
- data/config/tls/settings-tls.json +43 -0
- data/lib/legion/audit/archiver.rb +135 -0
- data/lib/legion/audit/archiver_actor.rb +55 -0
- data/lib/legion/audit/cold_storage.rb +66 -0
- data/lib/legion/cli/audit_command.rb +141 -1
- data/lib/legion/cli/doctor/tls_check.rb +125 -0
- data/lib/legion/cli/doctor_command.rb +2 -0
- data/lib/legion/cli/start.rb +7 -2
- data/lib/legion/compliance/phi_access_log.rb +24 -0
- data/lib/legion/compliance/phi_tag.rb +28 -0
- data/lib/legion/compliance.rb +18 -0
- data/lib/legion/extensions/actors/subscription.rb +45 -2
- data/lib/legion/extensions.rb +90 -24
- data/lib/legion/readiness.rb +1 -1
- data/lib/legion/service.rb +125 -18
- data/lib/legion/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73b570b5654f1f6a65839229bbe08b7dcba61b301cb894efcd34c453c824be3c
|
|
4
|
+
data.tar.gz: 666be120babae098c300353258d1045c06963d258b58db0cc21241eb9be3ddcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f9c0d44daa83169a9ef909bb506ac4b29d37a272d0b7a837aa3e44acc2db40f125de383697784499981d47a13907b0186059f669499cc1c3251e19e544d9df8
|
|
7
|
+
data.tar.gz: 8ab9224d32f76321a5b56143b63b945c95199016df101f856850bada1632683f46d1c894117fac0508f587e3a93ec499ace11b521efb3d7b0298331da0afcd3a
|
data/.gitignore
CHANGED
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'
|
data/lib/legion/cli/start.rb
CHANGED
|
@@ -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]
|
|
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]
|
|
188
|
+
log.info "[Subscription] subscribed: #{lex_name}/#{runner_name} (consumer registered)" if defined?(log)
|
|
146
189
|
end
|
|
147
190
|
|
|
148
191
|
private
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -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[:
|
|
41
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 = {})
|
data/lib/legion/readiness.rb
CHANGED
data/lib/legion/service.rb
CHANGED
|
@@ -49,30 +49,66 @@ module Legion
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
if cache
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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
|
data/lib/legion/version.rb
CHANGED
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
|
+
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
|