lex-node 0.3.6 → 0.3.8

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: dc51c7fec2b526f23237c3c8da1c8457f7b10250ad7606a3d519049675025cc5
4
- data.tar.gz: a0f8d664fdd16a3d16d64a948e3dfe2e8c27935251f1be5ffeef3bac21042220
3
+ metadata.gz: 7d243dbcf948208a7274558621a0653a8f35b209398a992dfe2cfa658af21c61
4
+ data.tar.gz: bfa04d660f833939a44d155703e5c6038e71d03bc3c511b4f3a0d66d1988a056
5
5
  SHA512:
6
- metadata.gz: fd71f5b8d3152d625af005aef5add980d90548d6299bf7f6afd93b69585941b69c37b29ecc882d9325a0075d0fc60b744c9080305e2375350714f3a8cfcb52ba
7
- data.tar.gz: c127f5a0b891e70df65b4639588a628596c8207ec31a21529bee8f39117c4cbf36b4d202d27b2619224e815035291bd8c8b81015f1a369071ff1041a8339d770
6
+ metadata.gz: 64d3c06f36f78d07eb13ea50344e53cdff8526d84aa66652d349186b8f87965997a406714a0caccc731aa6cdd04a4a6f8caac821fd8ca87f5357ef090b0ae872
7
+ data.tar.gz: d1e66f3a081e57b5430b9f3faa9a89f420ba479f89444197a176e6f2b52ff96d5a90856e312e2b3b74f35f98c887c24d502c4e2a9edf954b07641dd4c67579e4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
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
+
13
+ ## [0.3.7] - 2026-03-31
14
+
15
+ ### Fixed
16
+ - `ClusterControl` exchange now inherits from `Legion::Transport::Exchange` instead of being a bare PORO with constants, fixing `NOT_FOUND` error during autobuild queue binding
17
+
3
18
  ## [0.3.6] - 2026-03-31
4
19
 
5
20
  ### Added
data/README.md CHANGED
@@ -68,6 +68,37 @@ Each node periodically calls `beat` to broadcast its presence. On startup, nodes
68
68
 
69
69
  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.
70
70
 
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
+
71
102
  ## Transport
72
103
 
73
104
  - **Exchange**: `node` (topic exchange)
@@ -22,7 +22,7 @@ module Legion
22
22
  end
23
23
 
24
24
  def run_now?
25
- true
25
+ false
26
26
  end
27
27
 
28
28
  def time
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/node/control_auth'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Node
@@ -28,6 +30,11 @@ module Legion
28
30
  def generate_task?
29
31
  false
30
32
  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
31
38
  end
32
39
  end
33
40
  end
@@ -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
@@ -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
@@ -28,8 +28,10 @@ module Legion
28
28
  gem_name = "lex-#{name}"
29
29
  log.debug "update_gem: installing #{gem_name} #{version || 'latest'}"
30
30
 
31
- Gem.install(gem_name, version)
32
- Legion.reload if reload
31
+ install_result = install_extension_gem(gem_name, version)
32
+ raise Gem::InstallError, install_result[:output].to_s unless install_result[:success]
33
+
34
+ reload_extension(gem_name) if reload
33
35
 
34
36
  publish_update_result(action: 'update_gem', status: 'success', detail: "#{gem_name} #{version || 'latest'}")
35
37
  rescue StandardError => e
@@ -146,6 +148,20 @@ module Legion
146
148
 
147
149
  private
148
150
 
151
+ def install_extension_gem(gem_name, version)
152
+ require 'legion/extensions/gem_source' unless defined?(Legion::Extensions::GemSource) # rubocop:disable Legion/HelperMigration/RequireDefinedGuard
153
+
154
+ Legion::Extensions::GemSource.install_gem(gem_name, version: version)
155
+ rescue LoadError => e
156
+ { success: false, output: "GemSource unavailable: #{e.message}" }
157
+ end
158
+
159
+ def reload_extension(gem_name)
160
+ raise 'extension-scoped reload is unavailable' unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:reload_extension)
161
+
162
+ Legion::Extensions.reload_extension(gem_name)
163
+ end
164
+
149
165
  def publish_update_result(action:, status:, detail: nil, error: nil)
150
166
  Legion::Extensions::Node::Transport::Messages::UpdateResult.new(
151
167
  action: action,
@@ -5,10 +5,14 @@ module Legion
5
5
  module Node
6
6
  module Transport
7
7
  module Exchanges
8
- class ClusterControl
9
- EXCHANGE_NAME = 'legion.cluster.control'
10
- EXCHANGE_TYPE = :topic
11
- EXCHANGE_OPTIONS = { durable: true }.freeze
8
+ class ClusterControl < Legion::Transport::Exchange
9
+ def exchange_name
10
+ 'legion.cluster.control'
11
+ end
12
+
13
+ def default_type
14
+ 'topic'
15
+ end
12
16
  end
13
17
  end
14
18
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/node/control_auth'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Node
@@ -25,11 +27,11 @@ module Legion
25
27
  end
26
28
 
27
29
  def message
28
- {
30
+ Legion::Extensions::Node::ControlAuth.sign(
29
31
  function: 'update_settings',
30
32
  settings: { extensions: { blocked: [@options[:extension]] } },
31
33
  restart: true
32
- }
34
+ )
33
35
  end
34
36
 
35
37
  def validate
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/node/control_auth'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Node
@@ -23,11 +25,11 @@ module Legion
23
25
  end
24
26
 
25
27
  def message
26
- {
28
+ Legion::Extensions::Node::ControlAuth.sign(
27
29
  function: 'update_settings',
28
30
  settings: @options[:settings],
29
31
  restart: @options.fetch(:restart, false)
30
- }
32
+ )
31
33
  end
32
34
 
33
35
  def validate
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/node/config'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Node
@@ -11,8 +13,36 @@ module Legion
11
13
  end
12
14
 
13
15
  def queue_options
14
- { durable: false, exclusive: false, auto_delete: true,
15
- arguments: { 'x-queue-type': 'classic' } }
16
+ options = Legion::Extensions::Node::Config.control_queue
17
+ { durable: truthy?(options[:durable], default: true),
18
+ exclusive: truthy?(options[:exclusive], default: false),
19
+ auto_delete: truthy?(options[:auto_delete], default: false),
20
+ arguments: queue_arguments(options) }
21
+ end
22
+
23
+ private
24
+
25
+ def queue_arguments(options)
26
+ {
27
+ 'x-queue-type': options[:queue_type],
28
+ 'x-expires': positive_integer(options[:expires_ms]),
29
+ 'x-message-ttl': positive_integer(options[:message_ttl_ms]),
30
+ 'x-max-length': positive_integer(options[:max_length])
31
+ }.compact
32
+ end
33
+
34
+ def positive_integer(value)
35
+ return value if value.is_a?(Integer) && value.positive?
36
+ return nil unless value.to_s.match?(/\A\d+\z/)
37
+
38
+ parsed = value.to_i
39
+ parsed.positive? ? parsed : nil
40
+ end
41
+
42
+ def truthy?(value, default:)
43
+ return default if value.nil?
44
+
45
+ value == true || value.to_s == 'true'
16
46
  end
17
47
  end
18
48
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Node
6
- VERSION = '0.3.6'
6
+ VERSION = '0.3.8'
7
7
  end
8
8
  end
9
9
  end
@@ -1,12 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/node/version'
4
+ require 'legion/extensions/node/config'
4
5
  require 'legion/extensions/node/helpers/rabbitmq'
5
6
 
6
7
  module Legion
7
8
  module Extensions
8
9
  module Node
9
10
  extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core, false
11
+
12
+ def self.default_settings
13
+ Config.default_settings
14
+ end
10
15
  end
11
16
  end
12
17
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-node
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: 0.3.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -150,6 +150,8 @@ files:
150
150
  - lib/legion/extensions/node/actors/push_key.rb
151
151
  - lib/legion/extensions/node/actors/vault.rb
152
152
  - lib/legion/extensions/node/actors/vault_token_request.rb
153
+ - lib/legion/extensions/node/config.rb
154
+ - lib/legion/extensions/node/control_auth.rb
153
155
  - lib/legion/extensions/node/data_test/migrations/001_nodes_table.rb
154
156
  - lib/legion/extensions/node/data_test/migrations/002_node_history_table.rb
155
157
  - lib/legion/extensions/node/data_test/migrations/003_legion_version_colume.rb