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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6cc5f92c8b8a35dfb82b3639ae13d18e962e6f67ff042b845b4e545ed53e9be3
4
- data.tar.gz: 8165c8602fdcbb714fe9382dbadd217b399ee94d7c0ab987379c78964af7a166
3
+ metadata.gz: 14432b60b44c17938ab995bb9e9ca0a360f1381bbfe4f746e3bcc6a07be2e658
4
+ data.tar.gz: d98203d9b327d971f61e81022611e0f54d9e08673d381ae57fa27b58a55dabeb
5
5
  SHA512:
6
- metadata.gz: 0d86591c5ccd0e3276785836a0250f1e23d991d10c8c4d30920d852245506cfd1d405904c9adf657d16289c65571c802f87bf7111db16bccf0c7b71024675e79
7
- data.tar.gz: 7ce9ada0585d20b2cff7a108b4c5e68843b3489ceeab3aa21a572b7ab56910bd0a6fbe28e95a30f2f0b2dc3f317684ed123a8d576008e46e706a2c3f107e5d0b
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
- ### Problems It Solves
10
-
11
- **Without messhy:**
12
- - Database replication over public IPs (insecure)
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
- private_ip: 10.8.0.10
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
- private_ip: 10.8.0.11
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
- private_ip: 10.8.0.20
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
- - `host`: Public IP or hostname
247
- - `private_ip`: Private VPN IP (must be within network range)
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
- ## Performance
304
+ ## Capacity and performance
289
305
 
290
- ### Benchmarks (compared to no VPN)
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
- | Metric | No VPN | WireGuard | Overhead |
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
- **WireGuard is FAST!** Negligible overhead for most workloads.
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 3.0+
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
- Built with ❤️ for the Rails community by [Gaurav](https://github.com/yourusername)
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['private_ip']})"
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 "Private IP: #{node_config['private_ip']}"
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)
@@ -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 do |name, config|
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
@@ -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)['private_ip'] }
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)['private_ip']
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") + "\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['private_ip']
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 { |ips| ips.uniq }
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['private_ip'] if 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") + "\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['private_ip']})#{label_display} - connected to #{peers.size} peers"
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['private_ip']})#{label_display} - 0 peers (DOWN)"
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['private_ip']})#{label_display} - Error: #{e.message}"
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['private_ip'] == target_ip }&.first
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['private_ip'] if 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['private_ip']
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['private_ip'], status_cache)
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
- "/etc/dnsmasq.d/messhy.conf 2>/dev/null || true").strip
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
- "/etc/dnsmasq.d/active_postgres.conf 2>/dev/null || true").strip
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['private_ip']})#{label_display} - dnsmasq #{service}"
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['private_ip']})#{label_display} - DNS check failed: #{e.message}"
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)['private_ip']
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['private_ip']})"
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)
@@ -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 = SSHExecutor.new(config)
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
@@ -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['private_ip']
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 { |name|
42
- config.node_config(name)&.dig('private_ip')
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['private_ip']}/32",
65
- endpoint: "#{peer_config['host']}:#{peer_config['listen_port'] || config.listen_port}",
66
+ allowed_ips: "#{peer_config['mesh_ip']}/32",
67
+ endpoint: "#{endpoint}:#{peer_listen_port}",
66
68
  keepalive: config.keepalive
67
69
  }
68
70
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Messhy
4
- VERSION = '0.8.2'
4
+ VERSION = '0.9.0'
5
5
  end
@@ -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
- matches = desc.scan(/(\d+)\s+(second|minute|hour|day)s?/i)
24
- return nil if matches.empty?
23
+ total = 0
24
+ words = desc.downcase.delete(',').split
25
25
 
26
- matches.sum do |value, unit|
27
- TIME_UNITS_IN_SECONDS[unit.downcase] * value.to_i
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[/latest handshake:\s*(.+)/, 1]
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
- rx = peer_block.match(/transfer: (.+?) received/)&.[](1) || '0 B'
42
- tx = peer_block.match(/received, (.+?) sent/)&.[](1) || '0 B'
43
- { received: rx, sent: tx }
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[/endpoint: (.+?)$/, 1]
54
+ line_value(peer_block, 'endpoint: ')
48
55
  end
49
56
 
50
57
  def extract_allowed_ips(peer_block)
51
- peer_block[/allowed ips: (.+?)$/, 1]
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'messhy/version'
4
4
  require_relative 'messhy/configuration'
5
+ require_relative 'messhy/wireguard_config'
5
6
  require_relative 'messhy/wireguard_status_parser'
6
7
  require_relative 'messhy/installer'
7
8
  require_relative 'messhy/mesh_builder'
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.8.2
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.3'
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.3'
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.21'
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.21'
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.3'
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.3'
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: 3.4.0
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: 3.6.9
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: []