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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1c3b2d1ed2cc9058e323f73a2df9e9a9543709583cead2e9d9a23632c91a18f
4
- data.tar.gz: c10668cc8cfab172b6dd6b267265fbc310baa4d31323661efb6b2fc32062d152
3
+ metadata.gz: 14432b60b44c17938ab995bb9e9ca0a360f1381bbfe4f746e3bcc6a07be2e658
4
+ data.tar.gz: d98203d9b327d971f61e81022611e0f54d9e08673d381ae57fa27b58a55dabeb
5
5
  SHA512:
6
- metadata.gz: 5f1f2154ec3f236ae9aeb741ffa338af96a9ac4a4ba29db327da04bc2e21c719bde98bd1b1e572cb1d415a1fadf64c5d3f54e2807fd27f2909fddf3dcb18af12
7
- data.tar.gz: 4ee6e07ed4089a669ab5d11e7c1c074a845fff98782a42c282088c62289d96707df99ab287c4001065d0c097601e75cd1388019afaf60ee195f6be4fc4662ec9
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
- 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
42
45
 
43
46
  db-standby:
44
47
  host: 52.23.45.67
45
- 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
46
52
 
47
53
  app-1:
48
54
  host: 18.156.78.90
49
- private_ip: 10.8.0.20
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
- - `host`: Public IP or hostname
236
- - `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
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['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
@@ -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
 
@@ -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['private_ip'] if 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['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
@@ -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['private_ip']
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['private_ip'], status_cache)
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['private_ip']})#{label_display} - dnsmasq #{service}"
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['private_ip']})#{label_display} - DNS check failed: #{e.message}"
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)['private_ip']
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['private_ip']})"
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)
@@ -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
@@ -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('private_ip')
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['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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Messhy
4
- VERSION = '0.8.3'
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
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.3
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