messhy 0.8.2 → 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 +66 -0
- data/README.md +54 -30
- data/SECURITY.md +21 -0
- data/lib/messhy/cli.rb +19 -2
- data/lib/messhy/configuration.rb +64 -5
- data/lib/messhy/dns_manager.rb +7 -7
- data/lib/messhy/health_checker.rb +14 -16
- data/lib/messhy/installer.rb +81 -2
- data/lib/messhy/mesh_builder.rb +8 -6
- data/lib/messhy/ssh_executor.rb +113 -3
- data/lib/messhy/version.rb +1 -1
- data/lib/messhy/wireguard_config.rb +40 -0
- data/lib/messhy/wireguard_status_parser.rb +25 -10
- data/lib/messhy.rb +1 -0
- metadata +13 -52
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
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
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
|
+
|
|
25
|
+
## [0.8.3] - 2026-08-20
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Require Ruby 4.0 or newer.
|
|
30
|
+
- Align runtime and development dependencies with the maintained toolchain.
|
|
31
|
+
- Add blocking test, lint, dependency-audit, and package-build CI checks.
|
|
32
|
+
- Add automated Bundler and GitHub Actions dependency updates.
|
|
33
|
+
- Document key custody, SSH trust, and full-mesh security boundaries.
|
|
34
|
+
|
|
35
|
+
## [0.1.0] - 2025-01-21
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- Initial release of Messhy
|
|
39
|
+
- Full WireGuard VPN mesh network setup across any VMs
|
|
40
|
+
- Every node connects directly to every other node (full mesh topology)
|
|
41
|
+
- Automated WireGuard installation and configuration
|
|
42
|
+
- Dynamic key generation and distribution
|
|
43
|
+
- Automatic peer configuration across all nodes
|
|
44
|
+
- Rails integration with generators and rake tasks
|
|
45
|
+
- Standalone Ruby support (no Rails dependency required)
|
|
46
|
+
- SSH-based deployment (no Python, Ansible, or Kubernetes)
|
|
47
|
+
- Health checking and status monitoring
|
|
48
|
+
- WireGuard status parsing and connection verification
|
|
49
|
+
- Host trust management for SSH connections
|
|
50
|
+
- Support for both public and private IP addresses
|
|
51
|
+
- YAML-based configuration with ERB support
|
|
52
|
+
- Secret management via environment variables or command execution
|
|
53
|
+
|
|
54
|
+
### Technical Details
|
|
55
|
+
- Uses SSHKit for SSH operations
|
|
56
|
+
- Thor-based CLI interface with `messhy` command
|
|
57
|
+
- Automatic IP allocation for VPN network (10.8.0.0/24 by default)
|
|
58
|
+
- UDP port configuration (default: 51820)
|
|
59
|
+
- Compatible with Ubuntu 20.04+ servers (Debian-based distributions)
|
|
60
|
+
- Ruby >= 3.4.0 required
|
|
61
|
+
|
|
62
|
+
### Commands
|
|
63
|
+
- `messhy setup` - Deploy WireGuard mesh network
|
|
64
|
+
- `messhy status` - Check mesh network health and connectivity
|
|
65
|
+
- `messhy add_node` - Add new node to existing mesh
|
|
66
|
+
- `messhy remove_node` - Remove node from mesh
|
data/README.md
CHANGED
|
@@ -6,21 +6,10 @@ A Ruby gem that sets up a full WireGuard VPN mesh across any VMs. Every node con
|
|
|
6
6
|
|
|
7
7
|
## Why messhy?
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- Complex VPN configurations
|
|
14
|
-
- NAT traversal issues
|
|
15
|
-
- Manual key management
|
|
16
|
-
- Cloud-specific networking (VPC, subnet, security groups)
|
|
17
|
-
|
|
18
|
-
✅ **With messhy:**
|
|
19
|
-
- Secure encrypted connections (WireGuard)
|
|
20
|
-
- Automatic key generation and distribution
|
|
21
|
-
- Works across any cloud/datacenter
|
|
22
|
-
- Zero application changes (just use 10.8.0.x IPs)
|
|
23
|
-
- Simple configuration
|
|
9
|
+
Messhy generates WireGuard keys and peer configuration, installs it over SSH,
|
|
10
|
+
and gives each node a stable private address. It is intended for small fleets
|
|
11
|
+
whose operators control every host and can explicitly allow WireGuard traffic
|
|
12
|
+
between peers.
|
|
24
13
|
|
|
25
14
|
## Installation
|
|
26
15
|
|
|
@@ -49,15 +38,21 @@ production:
|
|
|
49
38
|
nodes:
|
|
50
39
|
db-primary:
|
|
51
40
|
host: 34.12.234.81
|
|
52
|
-
|
|
41
|
+
mesh_ip: 10.8.0.10
|
|
42
|
+
lan:
|
|
43
|
+
network: aws-virginia
|
|
44
|
+
ip: 10.70.1.10
|
|
53
45
|
|
|
54
46
|
db-standby:
|
|
55
47
|
host: 52.23.45.67
|
|
56
|
-
|
|
48
|
+
mesh_ip: 10.8.0.11
|
|
49
|
+
lan:
|
|
50
|
+
network: aws-virginia
|
|
51
|
+
ip: 10.70.1.11
|
|
57
52
|
|
|
58
53
|
app-1:
|
|
59
54
|
host: 18.156.78.90
|
|
60
|
-
|
|
55
|
+
mesh_ip: 10.8.0.20
|
|
61
56
|
```
|
|
62
57
|
|
|
63
58
|
2. **Setup mesh**:
|
|
@@ -170,6 +165,22 @@ messhy setup --skip-node=app-1 # Skip specific node
|
|
|
170
165
|
messhy setup --only-node=db-primary # Setup single node
|
|
171
166
|
```
|
|
172
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
|
+
|
|
173
184
|
### Status & Monitoring
|
|
174
185
|
|
|
175
186
|
```bash
|
|
@@ -243,10 +254,15 @@ interface.
|
|
|
243
254
|
### Node Configuration
|
|
244
255
|
|
|
245
256
|
Each node requires:
|
|
246
|
-
|
|
247
|
-
- `
|
|
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
|
|
248
260
|
|
|
249
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`.
|
|
250
266
|
- `ssh_user` / `ssh_port`: Override SSH access details (defaults to top-level `user` and port 22)
|
|
251
267
|
- `ssh_key`: Override SSH key for a specific node
|
|
252
268
|
- `listen_port`: WireGuard UDP port (defaults to top-level `listen_port`)
|
|
@@ -285,17 +301,25 @@ iptables -A INPUT -p udp --dport 51820 -j ACCEPT
|
|
|
285
301
|
- Optimal routing (direct connections)
|
|
286
302
|
- Scales to ~50 nodes
|
|
287
303
|
|
|
288
|
-
##
|
|
304
|
+
## Capacity and performance
|
|
289
305
|
|
|
290
|
-
|
|
306
|
+
A full mesh creates one peer relationship between every pair of nodes, so the
|
|
307
|
+
configuration grows quadratically. Messhy is designed for small fleets (about
|
|
308
|
+
50 nodes or fewer), not large or frequently changing membership. Throughput,
|
|
309
|
+
latency, and CPU overhead depend on the hosts, network path, MTU, and workload;
|
|
310
|
+
benchmark the actual topology before relying on a capacity estimate.
|
|
291
311
|
|
|
292
|
-
|
|
293
|
-
|--------|--------|-----------|----------|
|
|
294
|
-
| Throughput | 1000 Mbps | 950 Mbps | 5% |
|
|
295
|
-
| Latency | 10ms | 10.5ms | +0.5ms |
|
|
296
|
-
| CPU usage | 2% | 3% | +1% |
|
|
312
|
+
## Security boundaries
|
|
297
313
|
|
|
298
|
-
|
|
314
|
+
- `trust-hosts` collects host keys with `ssh-keyscan`; verify fingerprints over
|
|
315
|
+
a separate trusted channel before relying on them.
|
|
316
|
+
- Anyone with a generated node private key can impersonate that node. Store
|
|
317
|
+
`.secrets/wireguard` in an encrypted vault and rotate keys after suspected
|
|
318
|
+
disclosure or membership changes.
|
|
319
|
+
- Messhy configures WireGuard, but it does not replace host firewalls, patching,
|
|
320
|
+
SSH hardening, or application-level authentication.
|
|
321
|
+
- Full-mesh membership grants direct network reachability. Only enroll hosts
|
|
322
|
+
that should be able to reach one another.
|
|
299
323
|
|
|
300
324
|
## Troubleshooting
|
|
301
325
|
|
|
@@ -330,7 +354,7 @@ messhy setup
|
|
|
330
354
|
|
|
331
355
|
## Requirements
|
|
332
356
|
|
|
333
|
-
- Ruby
|
|
357
|
+
- Ruby 4.0+
|
|
334
358
|
- WireGuard tools (`wg` command)
|
|
335
359
|
- Target servers with Linux kernel 5.6+ (WireGuard built-in)
|
|
336
360
|
- SSH key-based authentication
|
|
@@ -345,4 +369,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
345
369
|
|
|
346
370
|
---
|
|
347
371
|
|
|
348
|
-
|
|
372
|
+
Maintained by [BoringCache](https://github.com/boringcache).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
Please do not open a public issue for a potential vulnerability. Email
|
|
6
|
+
[security@boringcache.com](mailto:security@boringcache.com) or use
|
|
7
|
+
[GitHub private vulnerability reporting](https://github.com/boringcache/messhy/security/advisories/new).
|
|
8
|
+
|
|
9
|
+
Include the affected version, expected impact, and enough reproduction detail
|
|
10
|
+
for us to investigate. Do not include live credentials, private keys, customer
|
|
11
|
+
data, or production configuration.
|
|
12
|
+
|
|
13
|
+
## Supported Versions
|
|
14
|
+
|
|
15
|
+
Only the latest published release is supported. Security fixes are normally
|
|
16
|
+
shipped in a new release rather than backported.
|
|
17
|
+
|
|
18
|
+
## Scope
|
|
19
|
+
|
|
20
|
+
Reports about WireGuard key handling, SSH host verification, generated network
|
|
21
|
+
configuration, command execution, or release integrity are welcome.
|
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
|
|
@@ -79,7 +79,7 @@ module Messhy
|
|
|
79
79
|
execute :sudo, 'resolvectl', 'flush-caches', raise_on_error: false
|
|
80
80
|
elsif test('[ -d /etc/resolvconf/resolv.conf.d ]', raise_on_error: false)
|
|
81
81
|
head_path = '/etc/resolvconf/resolv.conf.d/head'
|
|
82
|
-
content = server_ips.map { |ip| "nameserver #{ip}" }.join("\n")
|
|
82
|
+
content = "#{server_ips.map { |ip| "nameserver #{ip}" }.join("\n")}\n"
|
|
83
83
|
upload! StringIO.new(content), '/tmp/messhy-resolv.conf'
|
|
84
84
|
execute :sudo, 'mv', '/tmp/messhy-resolv.conf', head_path
|
|
85
85
|
execute :sudo, 'chmod', '644', head_path
|
|
@@ -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
|
|
|
@@ -112,14 +112,14 @@ module Messhy
|
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
-
records.transform_values
|
|
115
|
+
records.transform_values(&:uniq)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def resolve_target(target)
|
|
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
|
|
@@ -159,7 +159,7 @@ module Messhy
|
|
|
159
159
|
end
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
lines.join("\n")
|
|
162
|
+
"#{lines.join("\n")}\n"
|
|
163
163
|
end
|
|
164
164
|
end
|
|
165
165
|
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
|
|
@@ -94,7 +94,6 @@ module Messhy
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
98
97
|
def test_all
|
|
99
98
|
puts '==> Testing mesh connectivity...'
|
|
100
99
|
puts
|
|
@@ -116,7 +115,7 @@ module Messhy
|
|
|
116
115
|
|
|
117
116
|
tested_pairs.add(pair_key)
|
|
118
117
|
test_count += 1
|
|
119
|
-
target_ip = target_config['
|
|
118
|
+
target_ip = target_config['mesh_ip']
|
|
120
119
|
|
|
121
120
|
print "[#{test_count}/#{total_tests}] Testing #{source_name} → #{target_name} (#{target_ip})... "
|
|
122
121
|
$stdout.flush
|
|
@@ -133,7 +132,7 @@ module Messhy
|
|
|
133
132
|
|
|
134
133
|
if success
|
|
135
134
|
puts '✓'
|
|
136
|
-
elsif handshake_recent?(source_name, target_config['
|
|
135
|
+
elsif handshake_recent?(source_name, target_config['mesh_ip'], status_cache)
|
|
137
136
|
puts '✓ (handshake)'
|
|
138
137
|
else
|
|
139
138
|
puts '✗ (ICMP/TCP may be blocked, and no recent WireGuard handshake)'
|
|
@@ -148,7 +147,6 @@ module Messhy
|
|
|
148
147
|
puts 'If a pair still reports a failure, there has been no recent handshake—check UDP 51820 and ' \
|
|
149
148
|
'keepalive/route settings.'
|
|
150
149
|
end
|
|
151
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
152
150
|
|
|
153
151
|
def show_stats(node: nil)
|
|
154
152
|
if node
|
|
@@ -179,17 +177,17 @@ module Messhy
|
|
|
179
177
|
service = capture(:systemctl, 'is-active', 'dnsmasq', raise_on_non_zero_exit: false).strip
|
|
180
178
|
messhy_records = capture(:bash, '-c',
|
|
181
179
|
"sudo awk 'BEGIN{c=0} /^address=\\//{c++} END{print c}' " \
|
|
182
|
-
|
|
180
|
+
'/etc/dnsmasq.d/messhy.conf 2>/dev/null || true').strip
|
|
183
181
|
ap_records = capture(:bash, '-c',
|
|
184
182
|
"sudo awk 'BEGIN{c=0} /^address=\\//{c++} END{print c}' " \
|
|
185
|
-
|
|
183
|
+
'/etc/dnsmasq.d/active_postgres.conf 2>/dev/null || true').strip
|
|
186
184
|
|
|
187
185
|
status_icon = service == 'active' ? '✓' : '✗'
|
|
188
|
-
puts "#{status_icon} #{node_name} (#{node_config['
|
|
186
|
+
puts "#{status_icon} #{node_name} (#{node_config['mesh_ip']})#{label_display} - dnsmasq #{service}"
|
|
189
187
|
puts " └─ records: messhy=#{messhy_records.to_i} active_postgres=#{ap_records.to_i}"
|
|
190
188
|
end
|
|
191
189
|
rescue StandardError => e
|
|
192
|
-
puts "✗ #{node_name} (#{node_config['
|
|
190
|
+
puts "✗ #{node_name} (#{node_config['mesh_ip']})#{label_display} - DNS check failed: #{e.message}"
|
|
193
191
|
end
|
|
194
192
|
end
|
|
195
193
|
|
|
@@ -216,7 +214,7 @@ module Messhy
|
|
|
216
214
|
next if tested_pairs.include?(pair_key)
|
|
217
215
|
|
|
218
216
|
tested_pairs.add(pair_key)
|
|
219
|
-
target_ip = config.node_config(target)['
|
|
217
|
+
target_ip = config.node_config(target)['mesh_ip']
|
|
220
218
|
latency = @ssh_executor.measure_latency(source, target_ip)
|
|
221
219
|
latencies[[source, target]] = latency
|
|
222
220
|
latencies[[target, source]] = latency
|
|
@@ -224,7 +222,7 @@ module Messhy
|
|
|
224
222
|
end
|
|
225
223
|
|
|
226
224
|
max_name_len = node_names.map(&:length).max
|
|
227
|
-
header = ' ' * (max_name_len + 2) + node_names.map { |n| n[0..7].rjust(8) }.join(' ')
|
|
225
|
+
header = (' ' * (max_name_len + 2)) + node_names.map { |n| n[0..7].rjust(8) }.join(' ')
|
|
228
226
|
puts header
|
|
229
227
|
puts '-' * header.length
|
|
230
228
|
|
|
@@ -259,7 +257,7 @@ module Messhy
|
|
|
259
257
|
def show_node_stats(node_name)
|
|
260
258
|
node_config = config.node_config(node_name)
|
|
261
259
|
|
|
262
|
-
puts "==> Stats for #{node_name} (#{node_config['
|
|
260
|
+
puts "==> Stats for #{node_name} (#{node_config['mesh_ip']})"
|
|
263
261
|
|
|
264
262
|
begin
|
|
265
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
|
|
@@ -38,9 +38,9 @@ module Messhy
|
|
|
38
38
|
if config.dns_enabled?
|
|
39
39
|
dns_domain = config.dns_domain
|
|
40
40
|
dns_interface = config.dns_interface
|
|
41
|
-
dns_server_ips = config.dns_server_nodes.filter_map
|
|
42
|
-
config.node_config(name)&.dig('
|
|
43
|
-
|
|
41
|
+
dns_server_ips = config.dns_server_nodes.filter_map do |name|
|
|
42
|
+
config.node_config(name)&.dig('mesh_ip')
|
|
43
|
+
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
is_dns_server = config.dns_enabled? && config.dns_server_nodes.include?(node_name)
|
|
@@ -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)
|
|
@@ -176,9 +203,7 @@ module Messhy
|
|
|
176
203
|
latency = nil
|
|
177
204
|
execute_on_node(source_node) do
|
|
178
205
|
output = capture(:ping, '-c', '3', '-W', '2', '-I', 'wg0', target_ip, raise_on_non_zero_exit: false)
|
|
179
|
-
if output =~ %r{rtt min/avg/max/mdev = [\d.]+/([\d.]+)/}
|
|
180
|
-
latency = ::Regexp.last_match(1).to_f
|
|
181
|
-
end
|
|
206
|
+
latency = ::Regexp.last_match(1).to_f if output =~ %r{rtt min/avg/max/mdev = [\d.]+/([\d.]+)/}
|
|
182
207
|
end
|
|
183
208
|
latency
|
|
184
209
|
rescue StandardError
|
|
@@ -279,6 +304,8 @@ module Messhy
|
|
|
279
304
|
verify_host_key: config.verify_host_key_mode
|
|
280
305
|
}
|
|
281
306
|
|
|
307
|
+
options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file
|
|
308
|
+
|
|
282
309
|
if File.exist?(config.ssh_key)
|
|
283
310
|
options[:keys] = [config.ssh_key]
|
|
284
311
|
options[:keys_only] = true
|
|
@@ -295,16 +322,65 @@ module Messhy
|
|
|
295
322
|
ssh_port = node_config['ssh_port'] || node_config['port']
|
|
296
323
|
host.port = ssh_port if ssh_port
|
|
297
324
|
|
|
325
|
+
host.ssh_options = (host.ssh_options || {}).merge(strict_ssh_options)
|
|
326
|
+
|
|
298
327
|
if node_config['ssh_key']
|
|
299
328
|
keys = Array(node_config['ssh_key']).map { |path| File.expand_path(path) }
|
|
300
329
|
merged = (host.ssh_options || {}).merge(keys: keys, keys_only: true)
|
|
301
330
|
host.ssh_options = merged
|
|
302
331
|
end
|
|
303
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
|
+
|
|
304
338
|
manage_property(host.properties, :node_name, node_name)
|
|
305
339
|
host
|
|
306
340
|
end
|
|
307
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
|
+
|
|
308
384
|
def manage_property(properties, key, value = nil)
|
|
309
385
|
if value.nil?
|
|
310
386
|
# Fetch mode
|
|
@@ -314,5 +390,39 @@ module Messhy
|
|
|
314
390
|
properties.respond_to?(:set) ? properties.set(key, value) : properties[key] = value
|
|
315
391
|
end
|
|
316
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
|
|
317
427
|
end
|
|
318
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
|
|
@@ -20,35 +20,50 @@ module Messhy
|
|
|
20
20
|
def parse_handshake_seconds(desc)
|
|
21
21
|
return nil if desc.strip.casecmp('(none)').zero?
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
total = 0
|
|
24
|
+
words = desc.downcase.delete(',').split
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
words.each_cons(2) do |value, unit|
|
|
27
|
+
unit = unit.delete_suffix('s')
|
|
28
|
+
next unless integer_token?(value) && TIME_UNITS_IN_SECONDS.key?(unit)
|
|
29
|
+
|
|
30
|
+
total += TIME_UNITS_IN_SECONDS.fetch(unit) * value.to_i
|
|
28
31
|
end
|
|
32
|
+
|
|
33
|
+
total.positive? ? total : nil
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def extract_handshake_time(peer_block)
|
|
32
37
|
return nil unless peer_block
|
|
33
38
|
|
|
34
|
-
desc = peer_block
|
|
39
|
+
desc = line_value(peer_block, 'latest handshake: ')
|
|
35
40
|
return nil unless desc&.include?('ago')
|
|
36
41
|
|
|
37
42
|
parse_handshake_seconds(desc)
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def extract_transfer_stats(peer_block)
|
|
41
|
-
|
|
42
|
-
tx =
|
|
43
|
-
|
|
46
|
+
transfer = line_value(peer_block, 'transfer: ')
|
|
47
|
+
rx, tx = transfer&.split(' received, ', 2)
|
|
48
|
+
tx = tx&.delete_suffix(' sent')
|
|
49
|
+
|
|
50
|
+
{ received: rx || '0 B', sent: tx || '0 B' }
|
|
44
51
|
end
|
|
45
52
|
|
|
46
53
|
def extract_endpoint(peer_block)
|
|
47
|
-
peer_block
|
|
54
|
+
line_value(peer_block, 'endpoint: ')
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
def extract_allowed_ips(peer_block)
|
|
51
|
-
peer_block
|
|
58
|
+
line_value(peer_block, 'allowed ips: ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def line_value(block, prefix)
|
|
62
|
+
block.each_line(chomp: true).find { |line| line.start_with?(prefix) }&.delete_prefix(prefix)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def integer_token?(value)
|
|
66
|
+
!value.empty? && value.each_char.all? { |char| char.between?('0', '9') }
|
|
52
67
|
end
|
|
53
68
|
end
|
|
54
69
|
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
|
|
@@ -29,84 +29,42 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '1.
|
|
32
|
+
version: '1.4'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '1.
|
|
39
|
+
version: '1.4'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: sshkit
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '1.
|
|
46
|
+
version: '1.25'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '1.
|
|
53
|
+
version: '1.25'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: thor
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '1.
|
|
60
|
+
version: '1.5'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '1.
|
|
68
|
-
- !ruby/object:Gem::Dependency
|
|
69
|
-
name: minitest
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - "~>"
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '5.0'
|
|
75
|
-
type: :development
|
|
76
|
-
prerelease: false
|
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - "~>"
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: '5.0'
|
|
82
|
-
- !ruby/object:Gem::Dependency
|
|
83
|
-
name: rubocop
|
|
84
|
-
requirement: !ruby/object:Gem::Requirement
|
|
85
|
-
requirements:
|
|
86
|
-
- - "~>"
|
|
87
|
-
- !ruby/object:Gem::Version
|
|
88
|
-
version: '1.50'
|
|
89
|
-
type: :development
|
|
90
|
-
prerelease: false
|
|
91
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
-
requirements:
|
|
93
|
-
- - "~>"
|
|
94
|
-
- !ruby/object:Gem::Version
|
|
95
|
-
version: '1.50'
|
|
96
|
-
- !ruby/object:Gem::Dependency
|
|
97
|
-
name: simplecov
|
|
98
|
-
requirement: !ruby/object:Gem::Requirement
|
|
99
|
-
requirements:
|
|
100
|
-
- - "~>"
|
|
101
|
-
- !ruby/object:Gem::Version
|
|
102
|
-
version: '0.22'
|
|
103
|
-
type: :development
|
|
104
|
-
prerelease: false
|
|
105
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
-
requirements:
|
|
107
|
-
- - "~>"
|
|
108
|
-
- !ruby/object:Gem::Version
|
|
109
|
-
version: '0.22'
|
|
67
|
+
version: '1.5'
|
|
110
68
|
description: Sets up a full WireGuard VPN mesh across any VMs. Every node connects
|
|
111
69
|
directly to every other node for secure private networking.
|
|
112
70
|
email:
|
|
@@ -116,8 +74,10 @@ executables:
|
|
|
116
74
|
extensions: []
|
|
117
75
|
extra_rdoc_files: []
|
|
118
76
|
files:
|
|
77
|
+
- CHANGELOG.md
|
|
119
78
|
- LICENSE
|
|
120
79
|
- README.md
|
|
80
|
+
- SECURITY.md
|
|
121
81
|
- exe/messhy
|
|
122
82
|
- lib/messhy.rb
|
|
123
83
|
- lib/messhy/cli.rb
|
|
@@ -131,6 +91,7 @@ files:
|
|
|
131
91
|
- lib/messhy/railtie.rb
|
|
132
92
|
- lib/messhy/ssh_executor.rb
|
|
133
93
|
- lib/messhy/version.rb
|
|
94
|
+
- lib/messhy/wireguard_config.rb
|
|
134
95
|
- lib/messhy/wireguard_status_parser.rb
|
|
135
96
|
- lib/tasks/messhy.rake
|
|
136
97
|
- templates/wg0.conf.erb
|
|
@@ -138,10 +99,10 @@ homepage: https://github.com/boringcache/messhy
|
|
|
138
99
|
licenses:
|
|
139
100
|
- MIT
|
|
140
101
|
metadata:
|
|
141
|
-
homepage_uri: https://github.com/boringcache/messhy
|
|
142
102
|
source_code_uri: https://github.com/boringcache/messhy
|
|
143
103
|
documentation_uri: https://github.com/boringcache/messhy/blob/main/README.md
|
|
144
104
|
changelog_uri: https://github.com/boringcache/messhy/blob/main/CHANGELOG.md
|
|
105
|
+
bug_tracker_uri: https://github.com/boringcache/messhy/issues
|
|
145
106
|
rubygems_mfa_required: 'true'
|
|
146
107
|
rdoc_options: []
|
|
147
108
|
require_paths:
|
|
@@ -150,14 +111,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
150
111
|
requirements:
|
|
151
112
|
- - ">="
|
|
152
113
|
- !ruby/object:Gem::Version
|
|
153
|
-
version:
|
|
114
|
+
version: 4.0.0
|
|
154
115
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
155
116
|
requirements:
|
|
156
117
|
- - ">="
|
|
157
118
|
- !ruby/object:Gem::Version
|
|
158
119
|
version: '0'
|
|
159
120
|
requirements: []
|
|
160
|
-
rubygems_version:
|
|
121
|
+
rubygems_version: 4.0.16
|
|
161
122
|
specification_version: 4
|
|
162
123
|
summary: WireGuard VPN mesh for Ruby & Rails apps
|
|
163
124
|
test_files: []
|