messhy 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ require 'rails/generators'
2
+
3
+ module Messhy
4
+ module Generators
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ def create_config_file
9
+ template 'mesh.yml.erb', 'config/mesh.yml'
10
+ ensure_secrets_gitignored
11
+ end
12
+
13
+ def show_instructions
14
+ puts "\nāœ… Messhy installed!"
15
+ puts "\nšŸ“ Next steps:"
16
+ puts ' 1. Either:'
17
+ puts ' a) Edit config/mesh.yml with your server IPs, OR'
18
+ puts ' b) Use Terraform to auto-generate config/mesh.yml'
19
+ puts ' 2. Deploy VPN mesh: rails messhy:setup'
20
+ puts ' 3. Check health: rails messhy:health'
21
+ puts "\nšŸ“¦ WireGuard private keys will be stored in .secrets/wireguard (gitignored)."
22
+ puts ' Copy those YAML files into 1Password or your preferred vault after setup.'
23
+ puts "\nšŸ“š See config/mesh.example.yml in gem for examples"
24
+ end
25
+
26
+ private
27
+
28
+ def ensure_secrets_gitignored
29
+ gitignore_path = '.gitignore'
30
+ entries = [".secrets/\n", "**/.secrets/\n"]
31
+ header = "\n# Added by messhy - keep WireGuard secrets out of git\n"
32
+
33
+ if File.exist?(gitignore_path)
34
+ existing_lines = File.read(gitignore_path).lines.map(&:strip)
35
+ missing_entries = entries.reject { |line| existing_lines.include?(line.strip) }
36
+ return if missing_entries.empty?
37
+
38
+ append_to_file gitignore_path do
39
+ header + missing_entries.join
40
+ end
41
+ else
42
+ create_file gitignore_path, header + entries.join
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require_relative 'wireguard_status_parser'
5
+
6
+ module Messhy
7
+ class HealthChecker
8
+ include WireguardStatusParser
9
+
10
+ HANDSHAKE_STALENESS_LIMIT = 180 # seconds
11
+
12
+ attr_reader :config
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ @ssh_executor = SSHExecutor.new(config)
17
+ end
18
+
19
+ def show_status
20
+ puts '==> WireGuard Mesh Status'
21
+ puts "Environment: #{config.environment}"
22
+ puts
23
+
24
+ config.each_node do |node_name, _node_config|
25
+ show_node_status(node_name)
26
+ puts
27
+ end
28
+ end
29
+
30
+ def show_node_status(node_name)
31
+ node_config = config.node_config(node_name)
32
+
33
+ begin
34
+ status = @ssh_executor.get_wireguard_status(node_name)
35
+
36
+ # Parse status output
37
+ peers = status.scan(/peer: (.+?)$/).flatten
38
+
39
+ if peers.any?
40
+ puts "āœ“ #{node_name} (#{node_config['private_ip']}) - connected to #{peers.size} peers"
41
+
42
+ # Show basic peer info
43
+ status.split('peer:').drop(1).each do |peer_block|
44
+ endpoint = extract_endpoint(peer_block)
45
+ next unless endpoint
46
+
47
+ stats = extract_transfer_stats(peer_block)
48
+ puts " └─ Peer: #{endpoint} - #{stats[:received]} rx, #{stats[:sent]} tx"
49
+ end
50
+ else
51
+ puts "āœ— #{node_name} (#{node_config['private_ip']}) - 0 peers (DOWN)"
52
+ end
53
+ rescue StandardError => e
54
+ puts "āœ— #{node_name} (#{node_config['private_ip']}) - Error: #{e.message}"
55
+ end
56
+ end
57
+
58
+ def ping_node(node_or_ip)
59
+ # Determine if input is node name or IP
60
+ target_node = nil
61
+ target_ip = nil
62
+
63
+ if node_or_ip =~ /^\d+\.\d+\.\d+\.\d+$/
64
+ # It's an IP
65
+ target_ip = node_or_ip
66
+ target_node = config.nodes.find { |_, cfg| cfg['private_ip'] == target_ip }&.first
67
+ else
68
+ # It's a node name
69
+ target_node = node_or_ip
70
+ node_config = config.node_config(target_node)
71
+ target_ip = node_config['private_ip'] if node_config
72
+ end
73
+
74
+ unless target_ip
75
+ puts "Node or IP not found: #{node_or_ip}"
76
+ return
77
+ end
78
+
79
+ puts "Pinging #{target_node || target_ip} (#{target_ip})..."
80
+
81
+ # Try pinging from each other node
82
+ config.each_node do |source_node, _|
83
+ next if source_node == target_node # Skip pinging self
84
+
85
+ success = @ssh_executor.ping_node_from(source_node, target_ip)
86
+ status = success ? 'āœ“' : 'āœ—'
87
+ puts " #{status} from #{source_node}"
88
+ end
89
+ end
90
+
91
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
92
+ def test_all
93
+ puts '==> Testing mesh connectivity...'
94
+ puts
95
+ puts 'Note: This test may take a while. WireGuard status shows all peers connected.'
96
+ puts
97
+
98
+ all_ok = true
99
+ tested_pairs = Set.new
100
+ test_count = 0
101
+ total_tests = config.node_names.size * (config.node_names.size - 1) / 2
102
+
103
+ status_cache = {}
104
+ config.each_node do |source_name, _source_config|
105
+ config.each_node do |target_name, target_config|
106
+ next if source_name == target_name
107
+
108
+ pair_key = [source_name, target_name].sort.join('-')
109
+ next if tested_pairs.include?(pair_key)
110
+
111
+ tested_pairs.add(pair_key)
112
+ test_count += 1
113
+ target_ip = target_config['private_ip']
114
+
115
+ print "[#{test_count}/#{total_tests}] Testing #{source_name} → #{target_name} (#{target_ip})... "
116
+ $stdout.flush
117
+
118
+ success = false
119
+ begin
120
+ Timeout.timeout(3) do
121
+ success = @ssh_executor.ping_node_from(source_name, target_ip) ||
122
+ @ssh_executor.test_tcp_connectivity(source_name, target_ip, 22)
123
+ end
124
+ rescue StandardError
125
+ success = false
126
+ end
127
+
128
+ if success
129
+ puts 'āœ“'
130
+ elsif handshake_recent?(source_name, target_config['private_ip'], status_cache)
131
+ puts 'āœ“ (handshake)'
132
+ else
133
+ puts 'āœ— (ICMP/TCP may be blocked, and no recent WireGuard handshake)'
134
+ all_ok = false
135
+ end
136
+ $stdout.flush
137
+ end
138
+ end
139
+
140
+ puts
141
+ puts 'Note: When ICMP/TCP probes fail, we fall back to recent WireGuard handshakes.'
142
+ puts 'If a pair still reports a failure, there has been no recent handshake—check UDP 51820 and ' \
143
+ 'keepalive/route settings.'
144
+ end
145
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
146
+
147
+ def show_stats(node: nil)
148
+ if node
149
+ show_node_stats(node)
150
+ else
151
+ config.each_node do |node_name, _|
152
+ show_node_stats(node_name)
153
+ puts
154
+ end
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def handshake_recent?(source_name, target_ip, status_cache)
161
+ status = status_cache[source_name] ||= @ssh_executor.get_wireguard_status(source_name)
162
+ peer_block = WireguardStatusParser.extract_peer_block(status, target_ip)
163
+ return false unless peer_block
164
+
165
+ seconds = WireguardStatusParser.extract_handshake_time(peer_block)
166
+ return false unless seconds
167
+
168
+ seconds <= HANDSHAKE_STALENESS_LIMIT
169
+ rescue StandardError
170
+ false
171
+ end
172
+
173
+ def show_node_stats(node_name)
174
+ node_config = config.node_config(node_name)
175
+
176
+ puts "==> Stats for #{node_name} (#{node_config['private_ip']})"
177
+
178
+ begin
179
+ status = @ssh_executor.get_wireguard_status(node_name)
180
+
181
+ # Parse and display stats
182
+ status.split('peer:').drop(1).each_with_index do |peer_block, index|
183
+ puts "\nPeer ##{index + 1}:"
184
+
185
+ endpoint = extract_endpoint(peer_block)
186
+ puts " Endpoint: #{endpoint}" if endpoint
187
+
188
+ allowed_ips = extract_allowed_ips(peer_block)
189
+ puts " Allowed IPs: #{allowed_ips}" if allowed_ips
190
+
191
+ handshake = peer_block[/latest handshake: (.+?)$/, 1]
192
+ puts " Last handshake: #{handshake}" if handshake
193
+
194
+ stats = extract_transfer_stats(peer_block)
195
+ puts " Received: #{stats[:received]}"
196
+ puts " Sent: #{stats[:sent]}"
197
+ end
198
+ rescue StandardError => e
199
+ puts "Error: #{e.message}"
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module Messhy
7
+ class HostTrustManager
8
+ DEFAULT_TIMEOUT = 5
9
+ DEFAULT_KEY_TYPES = %w[ed25519 ecdsa rsa].freeze
10
+
11
+ # rubocop:disable Metrics/ParameterLists
12
+ def initialize(config,
13
+ known_hosts_path: File.expand_path('~/.ssh/known_hosts'),
14
+ timeout: DEFAULT_TIMEOUT,
15
+ key_types: DEFAULT_KEY_TYPES,
16
+ hash_hosts: false,
17
+ replace_existing: false)
18
+ @config = config
19
+ @known_hosts_path = File.expand_path(known_hosts_path)
20
+ @timeout = timeout
21
+ @key_types = Array(key_types).join(',')
22
+ @hash_hosts = hash_hosts
23
+ @replace_existing = replace_existing
24
+ end
25
+ # rubocop:enable Metrics/ParameterLists
26
+
27
+ def trust_all_hosts
28
+ ensure_ssh_keyscan!
29
+ ensure_known_hosts_dir!
30
+
31
+ existing_entries = load_known_host_lines
32
+ trusted = []
33
+ failed = []
34
+
35
+ @config.each_node do |node_name, node_config|
36
+ host = node_config['host']
37
+ next unless host
38
+
39
+ port = node_config['ssh_port'] || node_config['port']
40
+ label = port ? "#{host}:#{port}" : host
41
+
42
+ puts "==> Fetching host key for #{node_name} (#{label})"
43
+ remove_host_entries(host, port) if @replace_existing
44
+ output = scan_host(host, port)
45
+
46
+ if output
47
+ append_unique_entries(output, existing_entries)
48
+ trusted << label
49
+ puts " āœ“ Added #{label} to #{@known_hosts_path}"
50
+ else
51
+ warn " āœ— Failed to scan #{label}"
52
+ failed << label
53
+ end
54
+ end
55
+
56
+ summary(trusted, failed)
57
+ failed.empty?
58
+ end
59
+
60
+ private
61
+
62
+ def ensure_ssh_keyscan!
63
+ return if system('command -v ssh-keyscan >/dev/null 2>&1')
64
+
65
+ raise Error, 'ssh-keyscan command not found. Install OpenSSH client utilities.'
66
+ end
67
+
68
+ def ensure_known_hosts_dir!
69
+ FileUtils.mkdir_p(File.dirname(@known_hosts_path))
70
+ FileUtils.touch(@known_hosts_path) unless File.exist?(@known_hosts_path)
71
+ end
72
+
73
+ def load_known_host_lines
74
+ return Set.new unless File.exist?(@known_hosts_path)
75
+
76
+ Set.new(File.readlines(@known_hosts_path).map(&:strip))
77
+ end
78
+
79
+ def scan_host(host, port = nil)
80
+ cmd = ['ssh-keyscan', '-T', @timeout.to_s]
81
+ cmd << '-H' if @hash_hosts
82
+ cmd += ['-p', port.to_s] if port
83
+ cmd += ['-t', @key_types, host]
84
+ stdout, stderr, status = Open3.capture3(*cmd)
85
+ return stdout unless status.exitstatus != 0 || stdout.strip.empty?
86
+
87
+ if stderr.strip.empty?
88
+ warn " Connection timeout or host unreachable (timeout: #{@timeout}s)"
89
+ warn ' Check firewall rules, network connectivity, and SSH service'
90
+ else
91
+ warn " ssh-keyscan error: #{stderr.strip}"
92
+ end
93
+ nil
94
+ end
95
+
96
+ def remove_host_entries(host, port = nil)
97
+ return unless system('command -v ssh-keygen >/dev/null 2>&1')
98
+
99
+ label = port ? "[#{host}]:#{port}" : host
100
+ stdout, stderr, status = Open3.capture3('ssh-keygen', '-R', label, '-f', @known_hosts_path)
101
+ if status.success?
102
+ trimmed = stdout.strip
103
+ puts " Removed existing known_hosts entry for #{label}" unless trimmed.empty?
104
+ else
105
+ warn " ssh-keygen -R error for #{label}: #{stderr.strip}" unless stderr.strip.empty?
106
+ end
107
+ end
108
+
109
+ def append_unique_entries(output, existing_entries)
110
+ File.open(@known_hosts_path, 'a') do |file|
111
+ output.each_line do |line|
112
+ normalized = line.strip
113
+ next if normalized.empty? || existing_entries.include?(normalized)
114
+
115
+ file.puts line
116
+ existing_entries.add(normalized)
117
+ end
118
+ end
119
+ end
120
+
121
+ def summary(trusted, failed)
122
+ puts "\n==> Host trust summary"
123
+ puts " Trusted: #{trusted.count}"
124
+ puts " Failed: #{failed.count}"
125
+ return if failed.empty?
126
+
127
+ puts
128
+ puts ' āŒ Hosts that could not be scanned:'
129
+ failed.each { |host| puts " - #{host}" }
130
+ puts
131
+ puts ' šŸ”§ Troubleshooting steps:'
132
+ puts ' 1. Verify the hosts are online and reachable'
133
+ puts ' 2. Check firewall rules allow SSH connections (port 22 by default)'
134
+ puts ' 3. Ensure SSH service is running on the remote hosts'
135
+ puts ' 4. Try increasing the timeout with: --timeout 10'
136
+ puts ' 5. Test manual SSH connection: ssh -i <ssh_key> <user>@<host>'
137
+ end
138
+ end
139
+ end