messhy 0.8.3 → 0.9.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +32 -5
- data/lib/messhy/cli.rb +19 -2
- data/lib/messhy/configuration.rb +64 -5
- data/lib/messhy/dns_manager.rb +4 -4
- data/lib/messhy/health_checker.rb +11 -11
- data/lib/messhy/installer.rb +81 -2
- data/lib/messhy/mesh_builder.rb +6 -4
- data/lib/messhy/ssh_executor.rb +112 -0
- data/lib/messhy/version.rb +1 -1
- data/lib/messhy/wireguard_config.rb +40 -0
- data/lib/messhy.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14432b60b44c17938ab995bb9e9ca0a360f1381bbfe4f746e3bcc6a07be2e658
|
|
4
|
+
data.tar.gz: d98203d9b327d971f61e81022611e0f54d9e08673d381ae57fa27b58a55dabeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c8db3c1b1b7f71680e565c0373b463f4c749d4be645a90f587970a8955ef95d1684dffe2bf46d4cbbaf4de763e4d21482ed7c9e8aa72d4a64c56190c1076038b
|
|
7
|
+
data.tar.gz: 2e59b8e950931e8f27a4f004a5e9e381176ced1cb670fd16f03045ea4ee0856a2f81e577a5fc07fc5b535d4bd45218989db72b896ec0ab18cfe003af864517aa
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Per-node `jump_host` routing for SSH bootstrap without copying private keys to the jump host.
|
|
15
|
+
- Strict known-hosts file configuration, live key import, and sequential
|
|
16
|
+
`wg syncconf` reconciliation for adding nodes without restarting active interfaces.
|
|
17
|
+
- Optional `lan` transports that route WireGuard directly over a shared named
|
|
18
|
+
private network and fall back to each peer's public `host` otherwise.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Rename the WireGuard identity from `private_ip` to `mesh_ip`. Existing
|
|
23
|
+
`private_ip` configuration is normalized at load time for compatibility.
|
|
24
|
+
|
|
10
25
|
## [0.8.3] - 2026-08-20
|
|
11
26
|
|
|
12
27
|
### Changed
|
data/README.md
CHANGED
|
@@ -38,15 +38,21 @@ production:
|
|
|
38
38
|
nodes:
|
|
39
39
|
db-primary:
|
|
40
40
|
host: 34.12.234.81
|
|
41
|
-
|
|
41
|
+
mesh_ip: 10.8.0.10
|
|
42
|
+
lan:
|
|
43
|
+
network: aws-virginia
|
|
44
|
+
ip: 10.70.1.10
|
|
42
45
|
|
|
43
46
|
db-standby:
|
|
44
47
|
host: 52.23.45.67
|
|
45
|
-
|
|
48
|
+
mesh_ip: 10.8.0.11
|
|
49
|
+
lan:
|
|
50
|
+
network: aws-virginia
|
|
51
|
+
ip: 10.70.1.11
|
|
46
52
|
|
|
47
53
|
app-1:
|
|
48
54
|
host: 18.156.78.90
|
|
49
|
-
|
|
55
|
+
mesh_ip: 10.8.0.20
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
2. **Setup mesh**:
|
|
@@ -159,6 +165,22 @@ messhy setup --skip-node=app-1 # Skip specific node
|
|
|
159
165
|
messhy setup --only-node=db-primary # Setup single node
|
|
160
166
|
```
|
|
161
167
|
|
|
168
|
+
For an existing mesh, import its live key material once through trusted SSH,
|
|
169
|
+
then reconcile additions or peer changes without restarting active WireGuard
|
|
170
|
+
interfaces:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
messhy import-keys --environment=production
|
|
174
|
+
messhy reconcile --environment=production --dry-run
|
|
175
|
+
messhy reconcile --environment=production
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`import-keys` writes the same mode-`0600` local secret files as initial setup
|
|
179
|
+
and never prints key material. `reconcile` validates each candidate config,
|
|
180
|
+
applies active peer changes with `wg syncconf`, and restores the previous config
|
|
181
|
+
if a live sync fails. Set `ssh_known_hosts_file` to a reviewed file when the
|
|
182
|
+
mesh should not depend on the operator's global SSH configuration.
|
|
183
|
+
|
|
162
184
|
### Status & Monitoring
|
|
163
185
|
|
|
164
186
|
```bash
|
|
@@ -232,10 +254,15 @@ interface.
|
|
|
232
254
|
### Node Configuration
|
|
233
255
|
|
|
234
256
|
Each node requires:
|
|
235
|
-
|
|
236
|
-
- `
|
|
257
|
+
|
|
258
|
+
- `host`: Public IP or hostname used for SSH and as the WireGuard endpoint fallback
|
|
259
|
+
- `mesh_ip`: Stable IP inside the WireGuard network
|
|
237
260
|
|
|
238
261
|
Optional per-node overrides:
|
|
262
|
+
|
|
263
|
+
- `lan`: A shared private transport with `network` and `ip`. Peers on the same
|
|
264
|
+
named network use the LAN IP as their WireGuard endpoint; all other peers use
|
|
265
|
+
`host`.
|
|
239
266
|
- `ssh_user` / `ssh_port`: Override SSH access details (defaults to top-level `user` and port 22)
|
|
240
267
|
- `ssh_key`: Override SSH key for a specific node
|
|
241
268
|
- `listen_port`: WireGuard UDP port (defaults to top-level `listen_port`)
|
data/lib/messhy/cli.rb
CHANGED
|
@@ -26,6 +26,23 @@ module Messhy
|
|
|
26
26
|
handle_ssh_error(e, config)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
desc 'reconcile', 'Apply mesh changes without restarting active WireGuard interfaces'
|
|
30
|
+
option :dry_run, type: :boolean, default: false
|
|
31
|
+
def reconcile
|
|
32
|
+
config = load_config
|
|
33
|
+
Installer.new(config, dry_run: options[:dry_run]).reconcile
|
|
34
|
+
rescue SSHKit::Runner::ExecuteError => e
|
|
35
|
+
handle_ssh_error(e, config)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc 'import-keys', 'Import existing mesh key material into the local secrets directory'
|
|
39
|
+
def import_keys
|
|
40
|
+
config = load_config
|
|
41
|
+
Installer.new(config).import_keys
|
|
42
|
+
rescue SSHKit::Runner::ExecuteError => e
|
|
43
|
+
handle_ssh_error(e, config)
|
|
44
|
+
end
|
|
45
|
+
|
|
29
46
|
desc 'dns', 'Setup mesh DNS (dnsmasq)'
|
|
30
47
|
option :dry_run, type: :boolean, default: false
|
|
31
48
|
option :skip_node, type: :string
|
|
@@ -107,7 +124,7 @@ module Messhy
|
|
|
107
124
|
def list
|
|
108
125
|
config = load_config
|
|
109
126
|
config.each_node do |name, node_config|
|
|
110
|
-
puts "#{name}: #{node_config['host']} (#{node_config['
|
|
127
|
+
puts "#{name}: #{node_config['host']} (#{node_config['mesh_ip']})"
|
|
111
128
|
end
|
|
112
129
|
end
|
|
113
130
|
|
|
@@ -123,7 +140,7 @@ module Messhy
|
|
|
123
140
|
|
|
124
141
|
puts "Node: #{name}"
|
|
125
142
|
puts "Host: #{node_config['host']}"
|
|
126
|
-
puts "
|
|
143
|
+
puts "Mesh IP: #{node_config['mesh_ip']}"
|
|
127
144
|
puts "Region: #{node_config['region']}" if node_config['region']
|
|
128
145
|
|
|
129
146
|
health_checker = HealthChecker.new(config)
|
data/lib/messhy/configuration.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Messhy
|
|
|
10
10
|
:dns,
|
|
11
11
|
:user,
|
|
12
12
|
:ssh_key,
|
|
13
|
+
:ssh_known_hosts_file,
|
|
13
14
|
:mtu,
|
|
14
15
|
:listen_port,
|
|
15
16
|
:keepalive,
|
|
@@ -20,10 +21,11 @@ module Messhy
|
|
|
20
21
|
env_config = config_hash[environment] || {}
|
|
21
22
|
|
|
22
23
|
@network = env_config['network'] || '10.8.0.0/24'
|
|
23
|
-
@nodes = env_config['nodes'] || {}
|
|
24
|
+
@nodes = normalize_nodes(env_config['nodes'] || {})
|
|
24
25
|
@dns = env_config['dns'] || {}
|
|
25
26
|
@user = env_config['user'] || 'ubuntu'
|
|
26
27
|
@ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
|
|
28
|
+
@ssh_known_hosts_file = optional_path(env_config['ssh_known_hosts_file'])
|
|
27
29
|
@mtu = env_config['mtu'] || 1280
|
|
28
30
|
@listen_port = env_config['listen_port'] || 51_820
|
|
29
31
|
@keepalive = env_config['keepalive'] || 25
|
|
@@ -47,6 +49,16 @@ module Messhy
|
|
|
47
49
|
@nodes[name]
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
def peer_endpoint_for(node_name, peer_name)
|
|
53
|
+
node = node_config(node_name)
|
|
54
|
+
peer = node_config(peer_name)
|
|
55
|
+
|
|
56
|
+
return peer['host'] unless node['lan'] && peer['lan']
|
|
57
|
+
return peer['host'] unless node.dig('lan', 'network') == peer.dig('lan', 'network')
|
|
58
|
+
|
|
59
|
+
peer.dig('lan', 'ip')
|
|
60
|
+
end
|
|
61
|
+
|
|
50
62
|
def each_node(&)
|
|
51
63
|
@nodes.each(&)
|
|
52
64
|
end
|
|
@@ -65,10 +77,7 @@ module Messhy
|
|
|
65
77
|
def validate!
|
|
66
78
|
raise Error, 'No nodes defined' if @nodes.empty?
|
|
67
79
|
|
|
68
|
-
@nodes.each
|
|
69
|
-
raise Error, "Node #{name} missing 'host'" unless config['host']
|
|
70
|
-
raise Error, "Node #{name} missing 'private_ip'" unless config['private_ip']
|
|
71
|
-
end
|
|
80
|
+
@nodes.each { |name, config| validate_node!(name, config) }
|
|
72
81
|
|
|
73
82
|
if dns_enabled?
|
|
74
83
|
raise Error, 'DNS domain is required when dns is enabled' if dns_domains.empty?
|
|
@@ -83,6 +92,13 @@ module Messhy
|
|
|
83
92
|
true
|
|
84
93
|
end
|
|
85
94
|
|
|
95
|
+
def jump_host_config(name)
|
|
96
|
+
jump_host = node_config(name)&.dig('jump_host')
|
|
97
|
+
return unless jump_host
|
|
98
|
+
|
|
99
|
+
node_config(jump_host) || raise(Error, "Node #{name} jump_host not found: #{jump_host}")
|
|
100
|
+
end
|
|
101
|
+
|
|
86
102
|
def verify_host_key_mode
|
|
87
103
|
case @verify_host_key
|
|
88
104
|
when true, 'always', :always
|
|
@@ -149,5 +165,48 @@ module Messhy
|
|
|
149
165
|
|
|
150
166
|
@dns['auto_records'] == true
|
|
151
167
|
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def optional_path(path)
|
|
172
|
+
File.expand_path(path) if path
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalize_nodes(nodes)
|
|
176
|
+
nodes.to_h do |name, config|
|
|
177
|
+
[name, normalize_node(name, config)]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def normalize_node(name, config)
|
|
182
|
+
config = config.dup
|
|
183
|
+
legacy_mesh_ip = config.delete('private_ip')
|
|
184
|
+
|
|
185
|
+
if config['mesh_ip'] && legacy_mesh_ip && config['mesh_ip'] != legacy_mesh_ip
|
|
186
|
+
raise Error, "Node #{name} has conflicting 'mesh_ip' and deprecated 'private_ip' values"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
config['mesh_ip'] ||= legacy_mesh_ip
|
|
190
|
+
config
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_node!(name, config)
|
|
194
|
+
raise Error, "Node #{name} missing 'host'" unless config['host']
|
|
195
|
+
raise Error, "Node #{name} missing 'mesh_ip'" unless config['mesh_ip']
|
|
196
|
+
|
|
197
|
+
validate_lan!(name, config['lan']) if config['lan']
|
|
198
|
+
|
|
199
|
+
jump_host = config['jump_host']
|
|
200
|
+
return unless jump_host
|
|
201
|
+
|
|
202
|
+
raise Error, "Node #{name} cannot use itself as jump_host" if jump_host == name
|
|
203
|
+
raise Error, "Node #{name} jump_host not found: #{jump_host}" unless node_config(jump_host)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def validate_lan!(name, lan)
|
|
207
|
+
raise Error, "Node #{name} 'lan' must be a mapping" unless lan.is_a?(Hash)
|
|
208
|
+
raise Error, "Node #{name} lan missing 'network'" if lan['network'].to_s.empty?
|
|
209
|
+
raise Error, "Node #{name} lan missing 'ip'" if lan['ip'].to_s.empty?
|
|
210
|
+
end
|
|
152
211
|
end
|
|
153
212
|
end
|
data/lib/messhy/dns_manager.rb
CHANGED
|
@@ -31,14 +31,14 @@ module Messhy
|
|
|
31
31
|
interface = config.dns_interface
|
|
32
32
|
ttl = config.dns_ttl
|
|
33
33
|
server_nodes = config.dns_server_nodes
|
|
34
|
-
server_ips = server_nodes.map { |name| config.node_config(name)['
|
|
34
|
+
server_ips = server_nodes.map { |name| config.node_config(name)['mesh_ip'] }
|
|
35
35
|
|
|
36
36
|
records = build_records(domain)
|
|
37
37
|
|
|
38
38
|
server_nodes.each do |node|
|
|
39
39
|
next if @skip && node == @skip
|
|
40
40
|
|
|
41
|
-
server_ip = config.node_config(node)['
|
|
41
|
+
server_ip = config.node_config(node)['mesh_ip']
|
|
42
42
|
conf = build_dnsmasq_conf(domain, interface, server_ip, records, ttl)
|
|
43
43
|
|
|
44
44
|
if dry_run
|
|
@@ -97,7 +97,7 @@ module Messhy
|
|
|
97
97
|
config.each_node do |name, node_config|
|
|
98
98
|
hostname = "#{sanitize_dns_label(name)}.#{domain}"
|
|
99
99
|
records[hostname] ||= []
|
|
100
|
-
records[hostname] << node_config['
|
|
100
|
+
records[hostname] << node_config['mesh_ip']
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
@@ -119,7 +119,7 @@ module Messhy
|
|
|
119
119
|
return '' if target.nil?
|
|
120
120
|
|
|
121
121
|
node = config.node_config(target.to_s)
|
|
122
|
-
return node['
|
|
122
|
+
return node['mesh_ip'] if node
|
|
123
123
|
|
|
124
124
|
target.to_s
|
|
125
125
|
end
|
|
@@ -43,7 +43,7 @@ module Messhy
|
|
|
43
43
|
peers = status.scan(/peer: (.+?)$/).flatten
|
|
44
44
|
|
|
45
45
|
if peers.any?
|
|
46
|
-
puts "✓ #{node_name} (#{node_config['
|
|
46
|
+
puts "✓ #{node_name} (#{node_config['mesh_ip']})#{label_display} - connected to #{peers.size} peers"
|
|
47
47
|
|
|
48
48
|
# Show basic peer info
|
|
49
49
|
status.split('peer:').drop(1).each do |peer_block|
|
|
@@ -54,10 +54,10 @@ module Messhy
|
|
|
54
54
|
puts " └─ Peer: #{endpoint} - #{stats[:received]} rx, #{stats[:sent]} tx"
|
|
55
55
|
end
|
|
56
56
|
else
|
|
57
|
-
puts "✗ #{node_name} (#{node_config['
|
|
57
|
+
puts "✗ #{node_name} (#{node_config['mesh_ip']})#{label_display} - 0 peers (DOWN)"
|
|
58
58
|
end
|
|
59
59
|
rescue StandardError => e
|
|
60
|
-
puts "✗ #{node_name} (#{node_config['
|
|
60
|
+
puts "✗ #{node_name} (#{node_config['mesh_ip']})#{label_display} - Error: #{e.message}"
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -69,12 +69,12 @@ module Messhy
|
|
|
69
69
|
if node_or_ip =~ /^\d+\.\d+\.\d+\.\d+$/
|
|
70
70
|
# It's an IP
|
|
71
71
|
target_ip = node_or_ip
|
|
72
|
-
target_node = config.nodes.find { |_, cfg| cfg['
|
|
72
|
+
target_node = config.nodes.find { |_, cfg| cfg['mesh_ip'] == target_ip }&.first
|
|
73
73
|
else
|
|
74
74
|
# It's a node name
|
|
75
75
|
target_node = node_or_ip
|
|
76
76
|
node_config = config.node_config(target_node)
|
|
77
|
-
target_ip = node_config['
|
|
77
|
+
target_ip = node_config['mesh_ip'] if node_config
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
unless target_ip
|
|
@@ -115,7 +115,7 @@ module Messhy
|
|
|
115
115
|
|
|
116
116
|
tested_pairs.add(pair_key)
|
|
117
117
|
test_count += 1
|
|
118
|
-
target_ip = target_config['
|
|
118
|
+
target_ip = target_config['mesh_ip']
|
|
119
119
|
|
|
120
120
|
print "[#{test_count}/#{total_tests}] Testing #{source_name} → #{target_name} (#{target_ip})... "
|
|
121
121
|
$stdout.flush
|
|
@@ -132,7 +132,7 @@ module Messhy
|
|
|
132
132
|
|
|
133
133
|
if success
|
|
134
134
|
puts '✓'
|
|
135
|
-
elsif handshake_recent?(source_name, target_config['
|
|
135
|
+
elsif handshake_recent?(source_name, target_config['mesh_ip'], status_cache)
|
|
136
136
|
puts '✓ (handshake)'
|
|
137
137
|
else
|
|
138
138
|
puts '✗ (ICMP/TCP may be blocked, and no recent WireGuard handshake)'
|
|
@@ -183,11 +183,11 @@ module Messhy
|
|
|
183
183
|
'/etc/dnsmasq.d/active_postgres.conf 2>/dev/null || true').strip
|
|
184
184
|
|
|
185
185
|
status_icon = service == 'active' ? '✓' : '✗'
|
|
186
|
-
puts "#{status_icon} #{node_name} (#{node_config['
|
|
186
|
+
puts "#{status_icon} #{node_name} (#{node_config['mesh_ip']})#{label_display} - dnsmasq #{service}"
|
|
187
187
|
puts " └─ records: messhy=#{messhy_records.to_i} active_postgres=#{ap_records.to_i}"
|
|
188
188
|
end
|
|
189
189
|
rescue StandardError => e
|
|
190
|
-
puts "✗ #{node_name} (#{node_config['
|
|
190
|
+
puts "✗ #{node_name} (#{node_config['mesh_ip']})#{label_display} - DNS check failed: #{e.message}"
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
@@ -214,7 +214,7 @@ module Messhy
|
|
|
214
214
|
next if tested_pairs.include?(pair_key)
|
|
215
215
|
|
|
216
216
|
tested_pairs.add(pair_key)
|
|
217
|
-
target_ip = config.node_config(target)['
|
|
217
|
+
target_ip = config.node_config(target)['mesh_ip']
|
|
218
218
|
latency = @ssh_executor.measure_latency(source, target_ip)
|
|
219
219
|
latencies[[source, target]] = latency
|
|
220
220
|
latencies[[target, source]] = latency
|
|
@@ -257,7 +257,7 @@ module Messhy
|
|
|
257
257
|
def show_node_stats(node_name)
|
|
258
258
|
node_config = config.node_config(node_name)
|
|
259
259
|
|
|
260
|
-
puts "==> Stats for #{node_name} (#{node_config['
|
|
260
|
+
puts "==> Stats for #{node_name} (#{node_config['mesh_ip']})"
|
|
261
261
|
|
|
262
262
|
begin
|
|
263
263
|
status = @ssh_executor.get_wireguard_status(node_name)
|
data/lib/messhy/installer.rb
CHANGED
|
@@ -11,14 +11,63 @@ module Messhy
|
|
|
11
11
|
class Installer
|
|
12
12
|
attr_reader :config, :dry_run, :ssh_executor
|
|
13
13
|
|
|
14
|
-
def initialize(config, dry_run: false)
|
|
14
|
+
def initialize(config, dry_run: false, ssh_executor: SSHExecutor.new(config))
|
|
15
15
|
@config = config
|
|
16
16
|
@dry_run = dry_run
|
|
17
|
-
@ssh_executor =
|
|
17
|
+
@ssh_executor = ssh_executor
|
|
18
18
|
@node_keys = load_existing_keys
|
|
19
19
|
@psk_map = load_existing_psks
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def import_keys
|
|
23
|
+
puts '==> Importing existing WireGuard key material'
|
|
24
|
+
config.validate!
|
|
25
|
+
|
|
26
|
+
imported_psks = {}
|
|
27
|
+
config.each_node do |node_name, node_config|
|
|
28
|
+
snapshot = WireguardConfig.parse(ssh_executor.read_wireguard_config(node_name))
|
|
29
|
+
validate_imported_address!(node_name, node_config, snapshot)
|
|
30
|
+
import_node_key(node_name, snapshot)
|
|
31
|
+
import_peer_psks(node_name, snapshot, imported_psks)
|
|
32
|
+
puts " ✓ Imported #{node_name}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@psk_map.merge!(imported_psks)
|
|
36
|
+
persist_psk_map
|
|
37
|
+
puts "✓ Imported #{config.node_names.size} node keys and #{@psk_map.size} peer keys"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reconcile
|
|
41
|
+
puts '==> Reconciling WireGuard mesh without restarting active interfaces'
|
|
42
|
+
config.validate!
|
|
43
|
+
|
|
44
|
+
puts "\n==> Installing missing WireGuard tools..."
|
|
45
|
+
install_wireguard_on_all_nodes
|
|
46
|
+
puts "\n==> Ensuring key material exists..."
|
|
47
|
+
generate_all_keys
|
|
48
|
+
|
|
49
|
+
configs = MeshBuilder.new(config, @node_keys, @psk_map || {}).build_all_configs
|
|
50
|
+
puts "\n==> Applying configurations sequentially..."
|
|
51
|
+
configs.each do |node_name, config_content|
|
|
52
|
+
if dry_run
|
|
53
|
+
puts " [DRY RUN] Would reconcile #{node_name}"
|
|
54
|
+
else
|
|
55
|
+
ssh_executor.reconcile_config(node_name, config_content)
|
|
56
|
+
puts " ✓ Reconciled #{node_name}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "\n==> Verifying mesh connectivity..."
|
|
61
|
+
verify_mesh
|
|
62
|
+
|
|
63
|
+
if config.dns_enabled?
|
|
64
|
+
puts "\n==> Reconciling mesh DNS..."
|
|
65
|
+
DnsManager.new(config, ssh_executor: ssh_executor, dry_run: dry_run).setup
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts "\n✓ WireGuard mesh reconciled"
|
|
69
|
+
end
|
|
70
|
+
|
|
22
71
|
def setup(skip: nil)
|
|
23
72
|
puts '==> Setting up WireGuard mesh network'
|
|
24
73
|
puts "Environment: #{config.environment}"
|
|
@@ -130,6 +179,36 @@ module Messhy
|
|
|
130
179
|
|
|
131
180
|
private
|
|
132
181
|
|
|
182
|
+
def validate_imported_address!(node_name, node_config, snapshot)
|
|
183
|
+
address = snapshot.interface['Address'].to_s.split('/').first
|
|
184
|
+
return if address == node_config['mesh_ip']
|
|
185
|
+
|
|
186
|
+
raise Error, "Node #{node_name} WireGuard address #{address.inspect} does not match #{node_config['mesh_ip']}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def import_node_key(node_name, snapshot)
|
|
190
|
+
private_key = snapshot.interface['PrivateKey']
|
|
191
|
+
raise Error, "Node #{node_name} has no WireGuard private key" if private_key.to_s.empty?
|
|
192
|
+
|
|
193
|
+
keypair = { private_key: private_key, public_key: ssh_executor.wireguard_public_key(node_name) }
|
|
194
|
+
@node_keys[node_name] = keypair
|
|
195
|
+
store_keypair(node_name, keypair)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def import_peer_psks(node_name, snapshot, imported_psks)
|
|
199
|
+
snapshot.peers.each do |peer|
|
|
200
|
+
peer_name = peer['Name']
|
|
201
|
+
psk = peer['PresharedKey']
|
|
202
|
+
next if peer_name.to_s.empty? || psk.to_s.empty?
|
|
203
|
+
|
|
204
|
+
pair_key = [node_name, peer_name].sort.join('-')
|
|
205
|
+
existing = imported_psks[pair_key]
|
|
206
|
+
raise Error, "Conflicting WireGuard peer key for #{pair_key}" if existing && existing != psk
|
|
207
|
+
|
|
208
|
+
imported_psks[pair_key] = psk
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
133
212
|
def generate_all_keys(skip: nil)
|
|
134
213
|
config.each_node do |node_name, _|
|
|
135
214
|
next if skip && node_name == skip
|
data/lib/messhy/mesh_builder.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Messhy
|
|
|
25
25
|
template = ERB.new(File.read(template_path), trim_mode: '-')
|
|
26
26
|
|
|
27
27
|
# Prepare data for template
|
|
28
|
-
interface_ip = node_config['
|
|
28
|
+
interface_ip = node_config['mesh_ip']
|
|
29
29
|
prefix_length = config.network_prefix_length
|
|
30
30
|
private_key = keys[:private_key]
|
|
31
31
|
listen_port = node_config['listen_port'] || config.listen_port
|
|
@@ -39,7 +39,7 @@ module Messhy
|
|
|
39
39
|
dns_domain = config.dns_domain
|
|
40
40
|
dns_interface = config.dns_interface
|
|
41
41
|
dns_server_ips = config.dns_server_nodes.filter_map do |name|
|
|
42
|
-
config.node_config(name)&.dig('
|
|
42
|
+
config.node_config(name)&.dig('mesh_ip')
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -56,13 +56,15 @@ module Messhy
|
|
|
56
56
|
# Get symmetric PSK for this peer pair
|
|
57
57
|
pair_key = [node_name, peer_name].sort.join('-')
|
|
58
58
|
psk = psk_map[pair_key]
|
|
59
|
+
endpoint = config.peer_endpoint_for(node_name, peer_name)
|
|
60
|
+
peer_listen_port = peer_config['listen_port'] || config.listen_port
|
|
59
61
|
|
|
60
62
|
peers << {
|
|
61
63
|
name: peer_name,
|
|
62
64
|
public_key: peer_keys[:public_key],
|
|
63
65
|
preshared_key: psk,
|
|
64
|
-
allowed_ips: "#{peer_config['
|
|
65
|
-
endpoint: "#{
|
|
66
|
+
allowed_ips: "#{peer_config['mesh_ip']}/32",
|
|
67
|
+
endpoint: "#{endpoint}:#{peer_listen_port}",
|
|
66
68
|
keepalive: config.keepalive
|
|
67
69
|
}
|
|
68
70
|
end
|
data/lib/messhy/ssh_executor.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'sshkit'
|
|
4
4
|
require 'sshkit/dsl'
|
|
5
|
+
require 'net/ssh/proxy/command'
|
|
6
|
+
require 'shellwords'
|
|
5
7
|
require 'stringio'
|
|
6
8
|
|
|
7
9
|
module Messhy
|
|
@@ -108,6 +110,31 @@ module Messhy
|
|
|
108
110
|
end
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
def read_wireguard_config(node_name)
|
|
114
|
+
content = nil
|
|
115
|
+
execute_on_node(node_name) do
|
|
116
|
+
content = capture(:sudo, :cat, '/etc/wireguard/wg0.conf')
|
|
117
|
+
end
|
|
118
|
+
content
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def wireguard_public_key(node_name)
|
|
122
|
+
public_key = nil
|
|
123
|
+
execute_on_node(node_name) do
|
|
124
|
+
public_key = capture(:sudo, :wg, 'show', 'wg0', 'public-key').strip
|
|
125
|
+
end
|
|
126
|
+
public_key
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reconcile_config(node_name, config_content)
|
|
130
|
+
temp_file = '/tmp/messhy-wg0.conf'
|
|
131
|
+
script = reconcile_script(temp_file)
|
|
132
|
+
execute_on_node(node_name) do
|
|
133
|
+
upload! StringIO.new(config_content), temp_file
|
|
134
|
+
execute :sudo, :bash, '-lc', Shellwords.escape(script)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
111
138
|
def upload_and_start_configs(configs)
|
|
112
139
|
hosts = configs.filter_map do |node_name, config_content|
|
|
113
140
|
node_config = config.node_config(node_name)
|
|
@@ -277,6 +304,8 @@ module Messhy
|
|
|
277
304
|
verify_host_key: config.verify_host_key_mode
|
|
278
305
|
}
|
|
279
306
|
|
|
307
|
+
options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file
|
|
308
|
+
|
|
280
309
|
if File.exist?(config.ssh_key)
|
|
281
310
|
options[:keys] = [config.ssh_key]
|
|
282
311
|
options[:keys_only] = true
|
|
@@ -293,16 +322,65 @@ module Messhy
|
|
|
293
322
|
ssh_port = node_config['ssh_port'] || node_config['port']
|
|
294
323
|
host.port = ssh_port if ssh_port
|
|
295
324
|
|
|
325
|
+
host.ssh_options = (host.ssh_options || {}).merge(strict_ssh_options)
|
|
326
|
+
|
|
296
327
|
if node_config['ssh_key']
|
|
297
328
|
keys = Array(node_config['ssh_key']).map { |path| File.expand_path(path) }
|
|
298
329
|
merged = (host.ssh_options || {}).merge(keys: keys, keys_only: true)
|
|
299
330
|
host.ssh_options = merged
|
|
300
331
|
end
|
|
301
332
|
|
|
333
|
+
if (jump_host_config = config.jump_host_config(node_name))
|
|
334
|
+
merged = (host.ssh_options || {}).merge(proxy: jump_proxy(jump_host_config))
|
|
335
|
+
host.ssh_options = merged
|
|
336
|
+
end
|
|
337
|
+
|
|
302
338
|
manage_property(host.properties, :node_name, node_name)
|
|
303
339
|
host
|
|
304
340
|
end
|
|
305
341
|
|
|
342
|
+
def strict_ssh_options
|
|
343
|
+
options = { verify_host_key: config.verify_host_key_mode }
|
|
344
|
+
options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file
|
|
345
|
+
options
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def jump_proxy(jump_host_config)
|
|
349
|
+
jump_user = jump_host_config['ssh_user'] || jump_host_config['user'] || config.user
|
|
350
|
+
jump_port = jump_host_config['ssh_port'] || jump_host_config['port']
|
|
351
|
+
jump_key = jump_host_config['ssh_key'] || config.ssh_key
|
|
352
|
+
command = [
|
|
353
|
+
'ssh',
|
|
354
|
+
'-F', '/dev/null',
|
|
355
|
+
'-o', 'BatchMode=yes',
|
|
356
|
+
'-o', 'ForwardAgent=no',
|
|
357
|
+
'-o', "StrictHostKeyChecking=#{open_ssh_host_key_mode}"
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
command.push('-o', "UserKnownHostsFile=#{config.ssh_known_hosts_file}") if config.ssh_known_hosts_file
|
|
361
|
+
|
|
362
|
+
if jump_key && File.exist?(File.expand_path(jump_key))
|
|
363
|
+
command.push('-i', File.expand_path(jump_key), '-o', 'IdentitiesOnly=yes')
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
command.push('-p', jump_port.to_s) if jump_port
|
|
367
|
+
command.push('-W', '%h:%p', "#{jump_user}@#{jump_host_config.fetch('host')}")
|
|
368
|
+
|
|
369
|
+
command_line = Shellwords.join(command).gsub('\\%h', '%h').gsub('\\%p', '%p')
|
|
370
|
+
Net::SSH::Proxy::Command.new(command_line)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def open_ssh_host_key_mode
|
|
374
|
+
case config.verify_host_key_mode
|
|
375
|
+
when :accept_new
|
|
376
|
+
'accept-new'
|
|
377
|
+
when :never
|
|
378
|
+
'no'
|
|
379
|
+
else
|
|
380
|
+
'yes'
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
306
384
|
def manage_property(properties, key, value = nil)
|
|
307
385
|
if value.nil?
|
|
308
386
|
# Fetch mode
|
|
@@ -312,5 +390,39 @@ module Messhy
|
|
|
312
390
|
properties.respond_to?(:set) ? properties.set(key, value) : properties[key] = value
|
|
313
391
|
end
|
|
314
392
|
end
|
|
393
|
+
|
|
394
|
+
def reconcile_script(temp_file)
|
|
395
|
+
<<~SCRIPT
|
|
396
|
+
set -euo pipefail
|
|
397
|
+
target=/etc/wireguard/wg0.conf
|
|
398
|
+
candidate=/etc/wireguard/wg0.next.conf
|
|
399
|
+
previous=/etc/wireguard/wg0.conf.previous
|
|
400
|
+
stripped=/tmp/messhy-wg0.stripped
|
|
401
|
+
|
|
402
|
+
install -o root -g root -m 600 #{temp_file} "$candidate"
|
|
403
|
+
wg-quick strip "$candidate" > "$stripped"
|
|
404
|
+
|
|
405
|
+
if systemctl is-active --quiet wg-quick@wg0; then
|
|
406
|
+
cp -p "$target" "$previous"
|
|
407
|
+
mv "$candidate" "$target"
|
|
408
|
+
if ! wg syncconf wg0 "$stripped"; then
|
|
409
|
+
cp -p "$previous" "$target"
|
|
410
|
+
wg-quick strip "$target" > "$stripped"
|
|
411
|
+
wg syncconf wg0 "$stripped"
|
|
412
|
+
exit 1
|
|
413
|
+
fi
|
|
414
|
+
else
|
|
415
|
+
test ! -f "$target" || cp -p "$target" "$previous"
|
|
416
|
+
mv "$candidate" "$target"
|
|
417
|
+
systemctl enable wg-quick@wg0
|
|
418
|
+
if ! systemctl start wg-quick@wg0; then
|
|
419
|
+
test ! -f "$previous" || cp -p "$previous" "$target"
|
|
420
|
+
exit 1
|
|
421
|
+
fi
|
|
422
|
+
fi
|
|
423
|
+
|
|
424
|
+
rm -f "$stripped" #{temp_file}
|
|
425
|
+
SCRIPT
|
|
426
|
+
end
|
|
315
427
|
end
|
|
316
428
|
end
|
data/lib/messhy/version.rb
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Messhy
|
|
4
|
+
class WireguardConfig
|
|
5
|
+
attr_reader :interface, :peers
|
|
6
|
+
|
|
7
|
+
def self.parse(content)
|
|
8
|
+
new(content).tap(&:parse!)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(content)
|
|
12
|
+
@content = content
|
|
13
|
+
@interface = {}
|
|
14
|
+
@peers = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse!
|
|
18
|
+
section = nil
|
|
19
|
+
peer_name = nil
|
|
20
|
+
|
|
21
|
+
@content.each_line do |line|
|
|
22
|
+
stripped = line.strip
|
|
23
|
+
if (match = stripped.match(/\A# Peer: (.+)\z/))
|
|
24
|
+
peer_name = match[1]
|
|
25
|
+
elsif stripped == '[Interface]'
|
|
26
|
+
section = interface
|
|
27
|
+
elsif stripped == '[Peer]'
|
|
28
|
+
section = {}
|
|
29
|
+
section['Name'] = peer_name if peer_name
|
|
30
|
+
peers << section
|
|
31
|
+
peer_name = nil
|
|
32
|
+
elsif section && (match = stripped.match(/\A([^#=]+?)\s*=\s*(.+)\z/))
|
|
33
|
+
section[match[1].strip] = match[2].strip
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/messhy.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: messhy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- BoringCache
|
|
@@ -91,6 +91,7 @@ files:
|
|
|
91
91
|
- lib/messhy/railtie.rb
|
|
92
92
|
- lib/messhy/ssh_executor.rb
|
|
93
93
|
- lib/messhy/version.rb
|
|
94
|
+
- lib/messhy/wireguard_config.rb
|
|
94
95
|
- lib/messhy/wireguard_status_parser.rb
|
|
95
96
|
- lib/tasks/messhy.rake
|
|
96
97
|
- templates/wg0.conf.erb
|