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.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.md +296 -0
- data/exe/messhy +6 -0
- data/lib/messhy/cli.rb +280 -0
- data/lib/messhy/configuration.rb +87 -0
- data/lib/messhy/generators/messhy/install_generator.rb +47 -0
- data/lib/messhy/health_checker.rb +203 -0
- data/lib/messhy/host_trust_manager.rb +139 -0
- data/lib/messhy/installer.rb +334 -0
- data/lib/messhy/mesh_builder.rb +70 -0
- data/lib/messhy/railtie.rb +18 -0
- data/lib/messhy/ssh_executor.rb +305 -0
- data/lib/messhy/version.rb +5 -0
- data/lib/messhy/wireguard_status_parser.rb +54 -0
- data/lib/messhy.rb +21 -0
- data/lib/tasks/messhy.rake +32 -0
- data/templates/wg0.conf.erb +24 -0
- metadata +162 -0
|
@@ -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
|