messhy 0.4.0

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.
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'digest'
7
+ require 'base64'
8
+
9
+ module Messhy
10
+ # rubocop:disable Metrics/ClassLength
11
+ class Installer
12
+ attr_reader :config, :dry_run, :ssh_executor
13
+
14
+ def initialize(config, dry_run: false)
15
+ @config = config
16
+ @dry_run = dry_run
17
+ @ssh_executor = SSHExecutor.new(config)
18
+ @node_keys = load_existing_keys
19
+ @psk_map = load_existing_psks
20
+ end
21
+
22
+ def setup(skip: nil)
23
+ puts '==> Setting up WireGuard mesh network'
24
+ puts "Environment: #{config.environment}"
25
+ puts "Nodes: #{config.node_names.join(', ')}"
26
+ puts
27
+
28
+ # Validate config
29
+ config.validate!
30
+
31
+ # Purge existing WireGuard configs
32
+ puts '==> Cleaning up existing WireGuard installations...'
33
+ purge_all(skip: skip) unless dry_run
34
+
35
+ # Install WireGuard on all nodes (ensures wg binary exists for keygen)
36
+ puts "\n==> Installing WireGuard on nodes..."
37
+ install_wireguard_on_all_nodes(skip: skip)
38
+
39
+ # Generate keys for all nodes
40
+ puts "\n==> Generating WireGuard keys..."
41
+ generate_all_keys(skip: skip)
42
+
43
+ # Build configs
44
+ puts "\n==> Building mesh configurations..."
45
+ mesh_builder = MeshBuilder.new(config, @node_keys, @psk_map || {})
46
+ configs = mesh_builder.build_all_configs
47
+
48
+ # Upload configs and start WireGuard
49
+ puts "\n==> Deploying configurations..."
50
+ deploy_configs(configs, skip: skip)
51
+
52
+ # Force restart all nodes
53
+ puts "\n==> Restarting WireGuard on all nodes..."
54
+ restart_all(skip: skip) unless dry_run
55
+
56
+ # Verify connectivity
57
+ puts "\n==> Verifying mesh connectivity..."
58
+ verify_mesh(skip: skip)
59
+
60
+ puts "\n✓ WireGuard mesh setup complete!"
61
+ end
62
+
63
+ def setup_node(node_name)
64
+ puts "==> Setting up node: #{node_name}"
65
+
66
+ raise Error, "Node not found: #{node_name}" unless config.node_config(node_name)
67
+
68
+ config.validate!
69
+
70
+ # Install WireGuard first so key generation works
71
+ puts "\n==> Installing WireGuard tools..."
72
+ ssh_executor.install_wireguard(node_name) unless dry_run
73
+
74
+ # Load or generate keys for all nodes (needed for mesh config)
75
+ puts "\n==> Ensuring key material exists..."
76
+ generate_all_keys
77
+
78
+ # Build and deploy config
79
+ mesh_builder = MeshBuilder.new(config, @node_keys, @psk_map || {})
80
+ config_content = mesh_builder.build_config_for_node(node_name)
81
+
82
+ if dry_run
83
+ puts "[DRY RUN] Would upload WireGuard config to #{node_name}"
84
+ else
85
+ ssh_executor.upload_config(node_name, config_content)
86
+ ssh_executor.enable_and_start_wireguard(node_name)
87
+ end
88
+
89
+ puts "✓ Node #{node_name} setup complete"
90
+ end
91
+
92
+ def generate_keys(skip: nil)
93
+ puts '==> Generating WireGuard keys (no deploy)'
94
+ config.validate!
95
+ puts "\n==> Installing WireGuard on nodes..."
96
+ install_wireguard_on_all_nodes(skip: skip)
97
+ puts "\n==> Generating WireGuard keys..."
98
+ generate_all_keys(skip: skip)
99
+ puts "✓ Keys stored in #{secrets_dir}" unless dry_run
100
+ end
101
+
102
+ def restart_node(node_name)
103
+ puts "==> Restarting WireGuard on: #{node_name}"
104
+ ssh_executor.restart_wireguard(node_name)
105
+ puts '✓ Restarted'
106
+ end
107
+
108
+ def restart_all(skip: nil)
109
+ puts '==> Restarting WireGuard on all nodes...'
110
+ config.each_node do |node_name, _|
111
+ next if skip && node_name == skip
112
+
113
+ ssh_executor.restart_wireguard(node_name)
114
+ end
115
+ puts '✓ All nodes restarted'
116
+ end
117
+
118
+ def purge_all(skip: nil)
119
+ config.each_node do |node_name, _|
120
+ next if skip && node_name == skip
121
+
122
+ ssh_executor.purge_wireguard(node_name)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def generate_all_keys(skip: nil)
129
+ config.each_node do |node_name, _|
130
+ next if skip && node_name == skip
131
+
132
+ if @node_keys[node_name]
133
+ puts " ✓ Using stored keys for #{node_name}"
134
+ next
135
+ end
136
+
137
+ puts " Generating keys for #{node_name}..."
138
+ if dry_run
139
+ @node_keys[node_name] = fake_keypair_for(node_name)
140
+ else
141
+ new_keypair = ssh_executor.generate_keypair_on_node(node_name)
142
+ @node_keys[node_name] = new_keypair
143
+ store_keypair(node_name, new_keypair)
144
+ end
145
+ end
146
+
147
+ generate_psk_map(skip: skip)
148
+ end
149
+
150
+ def generate_psk_map(skip: nil)
151
+ node_names = config.node_names
152
+ changed = false
153
+
154
+ node_names.each_with_index do |node1, i|
155
+ next if skip && node1 == skip
156
+
157
+ peers = node_names[(i + 1)..] || []
158
+ peers.each do |node2|
159
+ next if skip && node2 == skip
160
+
161
+ pair_key = [node1, node2].sort.join('-')
162
+ next if @psk_map[pair_key]
163
+
164
+ if dry_run
165
+ @psk_map[pair_key] = fake_psk_for(pair_key)
166
+ else
167
+ @psk_map[pair_key] = ssh_executor.generate_psk_on_node(node1)
168
+ changed = true
169
+ end
170
+ end
171
+ end
172
+
173
+ persist_psk_map if changed
174
+ end
175
+
176
+ def install_wireguard_on_all_nodes(skip: nil)
177
+ if skip
178
+ config.each_node do |node_name, _|
179
+ next if node_name == skip
180
+
181
+ puts " Installing on #{node_name}..."
182
+ ssh_executor.install_wireguard(node_name) unless dry_run
183
+ end
184
+ else
185
+ puts ' Installing WireGuard on all nodes in parallel...'
186
+ ssh_executor.install_wireguard_on_all_nodes unless dry_run
187
+ end
188
+ end
189
+
190
+ def deploy_configs(configs, skip: nil)
191
+ if skip
192
+ configs.each do |node_name, config_content|
193
+ next if node_name == skip
194
+
195
+ puts " Deploying to #{node_name}..."
196
+
197
+ if dry_run
198
+ puts ' [DRY RUN] Would upload config and restart WireGuard'
199
+ else
200
+ ssh_executor.upload_config(node_name, config_content)
201
+ ssh_executor.enable_and_start_wireguard(node_name)
202
+ end
203
+ end
204
+ else
205
+ puts ' Deploying configurations to all nodes in parallel...'
206
+ if dry_run
207
+ configs.each_key do |node_name|
208
+ puts " [DRY RUN] Would deploy to #{node_name}"
209
+ end
210
+ else
211
+ ssh_executor.upload_and_start_configs(configs)
212
+ end
213
+ end
214
+ end
215
+
216
+ def verify_mesh(skip: nil)
217
+ return if dry_run
218
+
219
+ HealthChecker.new(config)
220
+
221
+ # Give WireGuard a moment to establish connections
222
+ sleep 3
223
+
224
+ all_ok = true
225
+ config.each_node do |node_name, _|
226
+ next if skip && node_name == skip
227
+
228
+ begin
229
+ status = ssh_executor.get_wireguard_status(node_name)
230
+
231
+ # Count handshakes
232
+ handshakes = status.scan(/latest handshake: (.+?)$/)
233
+ peer_count = status.scan('peer:').size
234
+
235
+ if peer_count.positive?
236
+ handshake_count = handshakes.size
237
+ if handshake_count.positive?
238
+ puts " ✓ #{node_name} - #{peer_count} peers, #{handshake_count} handshakes"
239
+ else
240
+ puts " ⚠ #{node_name} - #{peer_count} peers, no handshakes yet"
241
+ end
242
+ else
243
+ puts " ✗ #{node_name} - No peers connected"
244
+ all_ok = false
245
+ end
246
+ rescue StandardError => e
247
+ puts " ✗ #{node_name} - Error: #{e.message}"
248
+ all_ok = false
249
+ end
250
+ end
251
+
252
+ return if all_ok
253
+
254
+ puts "\nNote: Handshakes may take a few seconds to establish."
255
+ puts "Run 'messhy status' to check detailed connectivity."
256
+ end
257
+
258
+ def secrets_dir
259
+ @secrets_dir ||= File.expand_path(File.join('.secrets', 'wireguard'), Dir.pwd)
260
+ end
261
+
262
+ def psk_file_path
263
+ File.join(secrets_dir, 'psks.yml')
264
+ end
265
+
266
+ def load_existing_keys
267
+ return {} unless Dir.exist?(secrets_dir)
268
+
269
+ Dir.glob(File.join(secrets_dir, '*.yml')).each_with_object({}) do |path, acc|
270
+ next if File.basename(path) == 'psks.yml'
271
+
272
+ data = YAML.load_file(path, aliases: true)
273
+ node_name = (data['node'] || File.basename(path, '.yml')).to_s
274
+ next unless config.node_config(node_name)
275
+ next unless data['private_key'] && data['public_key']
276
+
277
+ acc[node_name] = {
278
+ private_key: data['private_key'],
279
+ public_key: data['public_key']
280
+ }
281
+ rescue StandardError
282
+ next
283
+ end
284
+ end
285
+
286
+ def load_existing_psks
287
+ return {} unless File.exist?(psk_file_path)
288
+
289
+ data = YAML.load_file(psk_file_path, aliases: true)
290
+ pairs = data['pairs'] || {}
291
+ pairs.transform_keys(&:to_s)
292
+ rescue StandardError
293
+ {}
294
+ end
295
+
296
+ def store_keypair(node_name, keypair)
297
+ FileUtils.mkdir_p(secrets_dir)
298
+ path = File.join(secrets_dir, "#{node_name}.yml")
299
+ payload = {
300
+ 'node' => node_name,
301
+ 'private_key' => keypair[:private_key],
302
+ 'public_key' => keypair[:public_key],
303
+ 'generated_at' => Time.now.utc.iso8601
304
+ }
305
+ File.write(path, payload.to_yaml)
306
+ File.chmod(0o600, path)
307
+ end
308
+
309
+ def persist_psk_map
310
+ FileUtils.mkdir_p(secrets_dir)
311
+ payload = {
312
+ 'generated_at' => Time.now.utc.iso8601,
313
+ 'pairs' => @psk_map
314
+ }
315
+ File.write(psk_file_path, payload.to_yaml)
316
+ File.chmod(0o600, psk_file_path)
317
+ end
318
+
319
+ def fake_keypair_for(node_name)
320
+ digest = Digest::SHA256.hexdigest(node_name)
321
+ base = Base64.strict_encode64([digest].pack('H*'))
322
+ {
323
+ private_key: base[0, 44],
324
+ public_key: base.reverse[0, 44]
325
+ }
326
+ end
327
+
328
+ def fake_psk_for(pair_key)
329
+ base = Base64.strict_encode64(Digest::SHA256.digest(pair_key))
330
+ base[0, 44]
331
+ end
332
+ end
333
+ # rubocop:enable Metrics/ClassLength
334
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Messhy
6
+ class MeshBuilder
7
+ attr_reader :config, :node_keys, :psk_map
8
+
9
+ def initialize(config, node_keys = {}, psk_map = {})
10
+ @config = config
11
+ @node_keys = node_keys
12
+ @psk_map = psk_map
13
+ end
14
+
15
+ # rubocop:disable Metrics/AbcSize
16
+ def build_config_for_node(node_name)
17
+ node_config = config.node_config(node_name)
18
+ raise Error, "Node not found: #{node_name}" unless node_config
19
+
20
+ # Get keys for this node
21
+ keys = node_keys[node_name]
22
+ raise Error, "Keys not found for node: #{node_name}" unless keys
23
+
24
+ template_path = File.join(Messhy.root, 'templates', 'wg0.conf.erb')
25
+ template = ERB.new(File.read(template_path), trim_mode: '-')
26
+
27
+ # Prepare data for template
28
+ interface_ip = node_config['private_ip']
29
+ prefix_length = config.network_prefix_length
30
+ private_key = keys[:private_key]
31
+ listen_port = node_config['listen_port'] || config.listen_port
32
+ mtu = config.mtu
33
+
34
+ # Build peers list (all other nodes)
35
+ peers = []
36
+ config.each_node do |peer_name, peer_config|
37
+ next if peer_name == node_name # Skip self
38
+
39
+ peer_keys = node_keys[peer_name]
40
+ next unless peer_keys # Skip if keys not available
41
+
42
+ # Get symmetric PSK for this peer pair
43
+ pair_key = [node_name, peer_name].sort.join('-')
44
+ psk = psk_map[pair_key]
45
+
46
+ peers << {
47
+ name: peer_name,
48
+ public_key: peer_keys[:public_key],
49
+ preshared_key: psk,
50
+ allowed_ips: "#{peer_config['private_ip']}/32",
51
+ endpoint: "#{peer_config['host']}:#{peer_config['listen_port'] || config.listen_port}",
52
+ keepalive: config.keepalive
53
+ }
54
+ end
55
+
56
+ # Render template
57
+ binding_context = binding
58
+ template.result(binding_context)
59
+ end
60
+ # rubocop:enable Metrics/AbcSize
61
+
62
+ def build_all_configs
63
+ configs = {}
64
+ config.each_node do |node_name, _|
65
+ configs[node_name] = build_config_for_node(node_name)
66
+ end
67
+ configs
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ if defined?(Rails::Railtie)
2
+ require 'rails/railtie'
3
+
4
+ module Messhy
5
+ class Railtie < Rails::Railtie
6
+ railtie_name :messhy
7
+
8
+ rake_tasks do
9
+ path = File.expand_path('../tasks/messhy.rake', __dir__)
10
+ load path if File.exist?(path)
11
+ end
12
+
13
+ generators do
14
+ require_relative 'generators/messhy/install_generator'
15
+ end
16
+ end
17
+ end
18
+ end