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.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.md +296 -0
- data/exe/messhy +6 -0
- data/lib/messhy/cli.rb +280 -0
- data/lib/messhy/configuration.rb +87 -0
- data/lib/messhy/generators/messhy/install_generator.rb +47 -0
- data/lib/messhy/health_checker.rb +203 -0
- data/lib/messhy/host_trust_manager.rb +139 -0
- data/lib/messhy/installer.rb +334 -0
- data/lib/messhy/mesh_builder.rb +70 -0
- data/lib/messhy/railtie.rb +18 -0
- data/lib/messhy/ssh_executor.rb +305 -0
- data/lib/messhy/version.rb +5 -0
- data/lib/messhy/wireguard_status_parser.rb +54 -0
- data/lib/messhy.rb +21 -0
- data/lib/tasks/messhy.rake +32 -0
- data/templates/wg0.conf.erb +24 -0
- metadata +162 -0
|
@@ -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
|