Skip to content

Commit 210df8d

Browse files
authored
Merge pull request #4 from LegionIO/codex/secure-extension-hot-reload-control
[codex] secure extension hot reload control
2 parents 5142081 + 969a3dd commit 210df8d

20 files changed

Lines changed: 719 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## [0.3.8] - 2026-04-25
4+
5+
### Added
6+
- Configurable cluster-control payload signing via `extensions.node.cluster_control.auth.mode` (`auto`, `required`, `disabled`), with timestamp freshness and nonce format validation when signing is active.
7+
8+
### Changed
9+
- `ClusterControl` queues now read durability and retention settings from `extensions.node.cluster_control.queue`; defaults remain durable and non-auto-delete so per-node commands survive node restarts.
10+
- Beat no longer runs immediately at actor construction, avoiding startup reconciliation before extension boot completes.
11+
- `update_gem` now installs through `Legion::Extensions::GemSource` and calls extension-scoped reload instead of full daemon reload.
12+
313
## [0.3.7] - 2026-03-31
414

515
### Fixed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,37 @@ Each node periodically calls `beat` to broadcast its presence. On startup, nodes
6868

6969
Dynamic config changes (`update_settings`) and gem upgrades (`update_gem`) can be pushed to individual nodes or broadcast to all nodes at runtime. Both operations publish an `UpdateResult` message to `node.<name>.update_result` so the outcome can be observed cluster-wide.
7070

71+
### Cluster Control Settings
72+
73+
Cluster-control broadcasts are configurable under `extensions.node.cluster_control`. By default, auth mode is `auto`: messages are signed and verified when a shared secret is configured, and unsigned messages are allowed when no secret exists for simpler local or homelab deployments.
74+
75+
```json
76+
{
77+
"extensions": {
78+
"node": {
79+
"cluster_control": {
80+
"auth": {
81+
"mode": "auto",
82+
"timestamp_skew_seconds": 300,
83+
"nonce_bytes": 16
84+
},
85+
"queue": {
86+
"durable": true,
87+
"exclusive": false,
88+
"auto_delete": false,
89+
"queue_type": "classic",
90+
"expires_ms": 604800000,
91+
"message_ttl_ms": 86400000,
92+
"max_length": 1000
93+
}
94+
}
95+
}
96+
}
97+
}
98+
```
99+
100+
Set `auth.mode` to `required` to force HMAC signatures, or `disabled` to run without cluster-control signatures even when a shared secret is present.
101+
71102
## Transport
72103

73104
- **Exchange**: `node` (topic exchange)

lib/legion/extensions/node.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
# frozen_string_literal: true
22

33
require 'legion/extensions/node/version'
4+
require 'legion/extensions/node/config'
45
require 'legion/extensions/node/helpers/rabbitmq'
56

67
module Legion
78
module Extensions
89
module Node
910
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core, false
11+
12+
def self.default_settings
13+
Config.default_settings
14+
end
1015
end
1116
end
1217
end

lib/legion/extensions/node/actors/beat.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def generate_task?
2222
end
2323

2424
def run_now?
25-
true
25+
false
2626
end
2727

2828
def time

lib/legion/extensions/node/actors/cluster_control.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'legion/extensions/node/control_auth'
4+
35
module Legion
46
module Extensions
57
module Node
@@ -28,6 +30,11 @@ def check_subtask?
2830
def generate_task?
2931
false
3032
end
33+
34+
def process_message(message, metadata, delivery_info)
35+
verified_message = Legion::Extensions::Node::ControlAuth.verify!(message)
36+
super(verified_message, metadata, delivery_info)
37+
end
3138
end
3239
end
3340
end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
module Legion
4+
module Extensions
5+
module Node
6+
module Config
7+
DEFAULT_SETTINGS = {
8+
cluster_control: {
9+
auth: {
10+
mode: 'auto',
11+
timestamp_skew_seconds: 300,
12+
nonce_bytes: 16
13+
},
14+
queue: {
15+
durable: true,
16+
exclusive: false,
17+
auto_delete: false,
18+
queue_type: 'classic',
19+
expires_ms: 604_800_000,
20+
message_ttl_ms: 86_400_000,
21+
max_length: 1000
22+
}
23+
}
24+
}.freeze
25+
26+
module_function
27+
28+
def default_settings
29+
deep_dup(DEFAULT_SETTINGS)
30+
end
31+
32+
def cluster_control
33+
deep_merge(default_settings[:cluster_control], extension_settings[:cluster_control] || {})
34+
end
35+
36+
def control_auth
37+
cluster_control[:auth] || {}
38+
end
39+
40+
def control_queue
41+
cluster_control[:queue] || {}
42+
end
43+
44+
def extension_settings
45+
return {} unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:dig)
46+
47+
settings = Legion::Settings.dig(:extensions, :node)
48+
settings.is_a?(Hash) ? symbolize_keys(settings) : {}
49+
rescue StandardError => e
50+
log.debug("node settings lookup failed: #{e.message}")
51+
{}
52+
end
53+
54+
def deep_merge(base, override)
55+
base_hash = symbolize_keys(base)
56+
override_hash = symbolize_keys(override)
57+
base_hash.merge(override_hash) do |_key, old_value, new_value|
58+
old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
59+
end
60+
end
61+
62+
def deep_dup(value)
63+
case value
64+
when Hash
65+
value.each_with_object({}) { |(key, val), result| result[key] = deep_dup(val) }
66+
when Array
67+
value.map { |item| deep_dup(item) }
68+
else
69+
value
70+
end
71+
end
72+
73+
def symbolize_keys(value)
74+
case value
75+
when Hash
76+
value.each_with_object({}) { |(key, val), result| result[key.to_sym] = symbolize_keys(val) }
77+
when Array
78+
value.map { |item| symbolize_keys(item) }
79+
else
80+
value
81+
end
82+
end
83+
84+
def log
85+
Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : Legion::Logging
86+
end
87+
end
88+
end
89+
end
90+
end
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# frozen_string_literal: true
2+
3+
require 'openssl'
4+
require 'securerandom'
5+
require 'json'
6+
require 'legion/extensions/node/config'
7+
8+
module Legion
9+
module Extensions
10+
module Node
11+
module ControlAuth
12+
AUTH_MODES = %w[auto required disabled].freeze
13+
14+
class UnauthorizedControlMessage < StandardError; end
15+
16+
module_function
17+
18+
def sign(payload)
19+
normalized = deep_symbolize(payload).dup
20+
return normalized unless sign_control_messages?
21+
22+
control = {
23+
sender: node_name,
24+
timestamp: Time.now.utc.to_i,
25+
nonce: SecureRandom.hex(nonce_bytes)
26+
}
27+
normalized[:control] = control.merge(signature: signature_for(normalized.merge(control: control)))
28+
normalized
29+
end
30+
31+
def verify!(payload)
32+
normalized = deep_symbolize(payload)
33+
raise UnauthorizedControlMessage, 'missing control signature' unless normalized.is_a?(Hash)
34+
return normalized unless verify_control_messages?
35+
36+
control = normalized[:control]
37+
raise UnauthorizedControlMessage, 'missing control signature' unless control.is_a?(Hash)
38+
39+
signature = control[:signature].to_s
40+
raise UnauthorizedControlMessage, 'missing control signature' if signature.empty?
41+
raise UnauthorizedControlMessage, 'stale control message' unless fresh_timestamp?(control[:timestamp])
42+
raise UnauthorizedControlMessage, 'invalid control nonce' unless valid_nonce?(control[:nonce])
43+
44+
unsigned_control = control.dup
45+
unsigned_control.delete(:signature)
46+
expected = signature_for(normalized.merge(control: unsigned_control))
47+
raise UnauthorizedControlMessage, 'invalid control signature' unless secure_compare(signature, expected)
48+
49+
normalized
50+
end
51+
52+
def sign_control_messages?
53+
return false if auth_disabled?
54+
return true if auth_required?
55+
56+
!secret.to_s.empty?
57+
end
58+
59+
def verify_control_messages?
60+
return false if auth_disabled?
61+
return true if auth_required?
62+
63+
!secret.to_s.empty?
64+
end
65+
66+
def auth_mode
67+
configured = auth_settings[:mode] || auth_settings[:enabled]
68+
mode = case configured
69+
when true then 'required'
70+
when false then 'disabled'
71+
else configured.to_s
72+
end
73+
AUTH_MODES.include?(mode) ? mode : 'auto'
74+
end
75+
76+
def auth_required?
77+
auth_mode == 'required'
78+
end
79+
80+
def auth_disabled?
81+
auth_mode == 'disabled'
82+
end
83+
84+
def auth_settings
85+
Legion::Extensions::Node::Config.control_auth
86+
end
87+
88+
def secret
89+
configured_secret || ENV.fetch('LEGION_NODE_CONTROL_SECRET', nil)
90+
end
91+
92+
def configured_secret
93+
if defined?(Legion::Settings) && Legion::Settings.respond_to?(:dig)
94+
auth_settings[:secret] ||
95+
Legion::Settings.dig(:cluster, :control_secret) ||
96+
Legion::Settings.dig(:crypt, :cluster_secret)
97+
end
98+
rescue StandardError => e
99+
log.debug("control secret lookup failed: #{e.message}")
100+
nil
101+
end
102+
103+
def signature_for(payload)
104+
value = secret
105+
raise UnauthorizedControlMessage, 'cluster control secret is not configured' if value.to_s.empty?
106+
107+
OpenSSL::HMAC.hexdigest('SHA256', value.to_s, canonical_json(payload))
108+
end
109+
110+
def canonical_json(value)
111+
::JSON.generate(canonicalize(value))
112+
end
113+
114+
def canonicalize(value)
115+
case value
116+
when Hash
117+
value.each_with_object({}) do |(key, val), result|
118+
result[key.to_s] = canonicalize(val)
119+
end.sort.to_h
120+
when Array
121+
value.map { |item| canonicalize(item) }
122+
else
123+
value
124+
end
125+
end
126+
127+
def deep_symbolize(value)
128+
case value
129+
when Hash
130+
value.each_with_object({}) do |(key, val), result|
131+
result[key.to_sym] = deep_symbolize(val)
132+
end
133+
when Array
134+
value.map { |item| deep_symbolize(item) }
135+
else
136+
value
137+
end
138+
end
139+
140+
def fresh_timestamp?(timestamp)
141+
ts = Integer(timestamp)
142+
(Time.now.utc.to_i - ts).abs <= timestamp_skew_seconds
143+
rescue ArgumentError, TypeError => e
144+
log.debug("control timestamp validation failed: #{e.message}")
145+
false
146+
end
147+
148+
def timestamp_skew_seconds
149+
Integer(auth_settings[:timestamp_skew_seconds])
150+
rescue ArgumentError, TypeError => e
151+
log.debug("control timestamp skew setting invalid: #{e.message}")
152+
300
153+
end
154+
155+
def nonce_bytes
156+
bytes = Integer(auth_settings[:nonce_bytes])
157+
bytes.positive? ? bytes : 16
158+
rescue ArgumentError, TypeError => e
159+
log.debug("control nonce byte setting invalid: #{e.message}")
160+
16
161+
end
162+
163+
def valid_nonce?(nonce)
164+
nonce.to_s.match?(/\A[0-9a-f]{#{nonce_bytes * 2}}\z/i)
165+
end
166+
167+
def secure_compare(left, right)
168+
return false unless left.bytesize == right.bytesize
169+
170+
left.bytes.zip(right.bytes).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero?
171+
end
172+
173+
def node_name
174+
(Legion::Settings[:client]&.fetch(:name, nil) if defined?(Legion::Settings)) || 'unknown'
175+
rescue StandardError => e
176+
log.debug("node name lookup failed: #{e.message}")
177+
'unknown'
178+
end
179+
180+
def log
181+
Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : Legion::Logging
182+
end
183+
end
184+
end
185+
end
186+
end

0 commit comments

Comments
 (0)