messhy 0.5.0 → 0.6.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: 5baf6451ca408d1f0072e1f10316eb683cb96a8ff81726febc60e0a3e30b85ac
4
- data.tar.gz: e7d1079753416d0288c015daf01c8fb7701de4fc5c6f6eaa4fc65143cb480191
3
+ metadata.gz: 2789748ffdef7b5d7d46a0c58eed766cdfeb3f0a7f9acd69ffcc6d99def48c6d
4
+ data.tar.gz: 640f81d5ad62ca4d736fa5643080c91d68a616a23c18feba060fd7c5c91a8031
5
5
  SHA512:
6
- metadata.gz: dd8ac97816e7670ce7e593663f4cb507340ce21f741728a9734f1c472ab3e151ac9d9540b626560de891e6b71ca3eab7abaa0d58148fc45b3f5096c43353e967
7
- data.tar.gz: 47eb6507f60dbed0d9a7b3e8733b3abee88241a863e76d7618fe729bcc7053846fe71202ee94bf89720d85e36bb52ca9d885d05853166d79a4197570cb8bc23a
6
+ metadata.gz: 8bb76d383440f0ee413e92524d5dbc275c70f4765d9f971da21ba272753a30b7d479e7ca199a4dad13d46174a456c59af3056fe4887cb6ac1c2821c463bb1e5f
7
+ data.tar.gz: a142538a31aacffc7dd0a80854ae09c9c98bf8430f82200e58ae8bf3c7ad4d17b6a642b17374baf23752bd428a6874084ccdb1772fa45168d5f99c2350e95ded
data/README.md CHANGED
@@ -72,6 +72,42 @@ messhy setup --environment=production
72
72
  messhy status
73
73
  ```
74
74
 
75
+ When mesh DNS is enabled, `messhy status` also prints DNS server health and record counts.
76
+
77
+ ## Mesh DNS (optional)
78
+
79
+ Messhy can set up a lightweight internal DNS (dnsmasq) for the mesh so you can
80
+ use stable hostnames instead of raw IPs. It installs dnsmasq on designated nodes
81
+ and configures all mesh nodes to resolve a private domain over `wg0`.
82
+
83
+ Example config:
84
+
85
+ ```yaml
86
+ production:
87
+ <<: *shared
88
+ dns:
89
+ enabled: true
90
+ provider: dnsmasq
91
+ domain: mesh.internal
92
+ interface: wg0
93
+ servers:
94
+ - app-us-1
95
+ - app-eu-1
96
+ auto_records: true
97
+ records:
98
+ db-primary.mesh.internal:
99
+ - db-primary
100
+ - db-standby-1
101
+ db-replica.mesh.internal:
102
+ - db-standby-1
103
+ ```
104
+
105
+ Apply DNS without touching WireGuard:
106
+
107
+ ```bash
108
+ messhy dns --environment=production
109
+ ```
110
+
75
111
  ## Secret Management
76
112
 
77
113
  `messhy setup` stores generated WireGuard key pairs inside `.secrets/wireguard/*.yml` with `0600` permissions. Each node gets its own YAML file (`.secrets/wireguard/<node>.yml`) and all peer pre‑shared keys live in `.secrets/wireguard/psks.yml`. The directory is gitignored by default, and the Rails generator ensures the ignore rules are present in your application. After provisioning, copy the YAML files into 1Password (or another vault) and remove them from disk if you do not want long‑lived local copies.
@@ -187,6 +223,22 @@ See `config/mesh.example.yml` for a complete example.
187
223
  - `mtu`: MTU size (default: `1280` for reliability)
188
224
  - `listen_port`: WireGuard port (default: `51820`)
189
225
  - `keepalive`: Keepalive interval in seconds (default: `25`)
226
+ - `dns`: Optional mesh DNS configuration (see below)
227
+
228
+ ### DNS Options
229
+
230
+ When `dns.enabled: true`, messhy installs dnsmasq on the specified `dns.servers`
231
+ and configures all mesh nodes to resolve the `dns.domain` over the WireGuard
232
+ interface.
233
+
234
+ - `dns.enabled`: Enable mesh DNS
235
+ - `dns.provider`: `dnsmasq` (only provider today)
236
+ - `dns.domain`: Internal DNS domain (default: `mesh`)
237
+ - `dns.interface`: WireGuard interface (default: `wg0`)
238
+ - `dns.servers`: Node names that will run dnsmasq
239
+ - `dns.auto_records`: Auto-create `<node>.<domain>` for every node (default: `true`)
240
+ - `dns.records`: Extra records (values can be IPs or node names)
241
+ - `dns.ttl`: Local DNS TTL seconds (default: `30`)
190
242
 
191
243
  ### Node Configuration
192
244
 
data/lib/messhy/cli.rb CHANGED
@@ -26,6 +26,16 @@ module Messhy
26
26
  handle_ssh_error(e, config)
27
27
  end
28
28
 
29
+ desc 'dns', 'Setup mesh DNS (dnsmasq)'
30
+ option :dry_run, type: :boolean, default: false
31
+ option :skip_node, type: :string
32
+ def dns
33
+ config = load_config
34
+ DnsManager.new(config, dry_run: options[:dry_run], skip: options[:skip_node]).setup
35
+ rescue SSHKit::Runner::ExecuteError => e
36
+ handle_ssh_error(e, config)
37
+ end
38
+
29
39
  desc 'keygen', 'Generate WireGuard keys without deploying configs'
30
40
  option :skip_node, type: :string
31
41
  def keygen
@@ -7,6 +7,7 @@ module Messhy
7
7
  attr_reader :environment,
8
8
  :network,
9
9
  :nodes,
10
+ :dns,
10
11
  :user,
11
12
  :ssh_key,
12
13
  :mtu,
@@ -20,6 +21,7 @@ module Messhy
20
21
 
21
22
  @network = env_config['network'] || '10.8.0.0/24'
22
23
  @nodes = env_config['nodes'] || {}
24
+ @dns = env_config['dns'] || {}
23
25
  @user = env_config['user'] || 'ubuntu'
24
26
  @ssh_key = File.expand_path(env_config['ssh_key'] || '~/.ssh/id_rsa')
25
27
  @mtu = env_config['mtu'] || 1280
@@ -68,6 +70,16 @@ module Messhy
68
70
  raise Error, "Node #{name} missing 'private_ip'" unless config['private_ip']
69
71
  end
70
72
 
73
+ if dns_enabled?
74
+ raise Error, 'DNS domain is required when dns is enabled' if dns_domain.to_s.strip.empty?
75
+ raise Error, 'DNS servers are required when dns is enabled' if dns_server_nodes.empty?
76
+ raise Error, "Unsupported DNS provider: #{dns_provider}" unless %w[dnsmasq].include?(dns_provider)
77
+
78
+ dns_server_nodes.each do |name|
79
+ raise Error, "DNS server node not found: #{name}" unless node_config(name)
80
+ end
81
+ end
82
+
71
83
  true
72
84
  end
73
85
 
@@ -83,5 +95,45 @@ module Messhy
83
95
  :always
84
96
  end
85
97
  end
98
+
99
+ def dns_enabled?
100
+ return false if @dns.nil? || @dns.empty?
101
+
102
+ @dns.key?('enabled') ? @dns['enabled'] == true : true
103
+ end
104
+
105
+ def dns_provider
106
+ value = @dns['provider'] || 'dnsmasq'
107
+ value.to_s.strip
108
+ end
109
+
110
+ def dns_domain
111
+ value = @dns['domain'] || 'mesh'
112
+ value.to_s.strip
113
+ end
114
+
115
+ def dns_interface
116
+ value = @dns['interface'] || 'wg0'
117
+ value.to_s.strip
118
+ end
119
+
120
+ def dns_ttl
121
+ value = @dns['ttl'] || 30
122
+ value.to_i
123
+ end
124
+
125
+ def dns_server_nodes
126
+ Array(@dns['servers']).map(&:to_s).reject(&:empty?)
127
+ end
128
+
129
+ def dns_records
130
+ @dns['records'] || {}
131
+ end
132
+
133
+ def dns_auto_records?
134
+ return true unless @dns.key?('auto_records')
135
+
136
+ @dns['auto_records'] == true
137
+ end
86
138
  end
87
139
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Messhy
6
+ class DnsManager
7
+ attr_reader :config, :ssh_executor, :dry_run
8
+
9
+ def initialize(config, ssh_executor: SSHExecutor.new(config), dry_run: false, skip: nil)
10
+ @config = config
11
+ @ssh_executor = ssh_executor
12
+ @dry_run = dry_run
13
+ @skip = skip
14
+ end
15
+
16
+ def setup
17
+ return unless config.dns_enabled?
18
+
19
+ case config.dns_provider
20
+ when 'dnsmasq'
21
+ setup_dnsmasq
22
+ else
23
+ raise Error, "Unsupported DNS provider: #{config.dns_provider}"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def setup_dnsmasq
30
+ domain = config.dns_domain
31
+ interface = config.dns_interface
32
+ ttl = config.dns_ttl
33
+ server_nodes = config.dns_server_nodes
34
+ server_ips = server_nodes.map { |name| config.node_config(name)['private_ip'] }
35
+
36
+ records = build_records(domain)
37
+
38
+ server_nodes.each do |node|
39
+ next if @skip && node == @skip
40
+
41
+ server_ip = config.node_config(node)['private_ip']
42
+ conf = build_dnsmasq_conf(domain, interface, server_ip, records, ttl)
43
+
44
+ if dry_run
45
+ puts "[DRY RUN] Would install dnsmasq and write /etc/dnsmasq.d/messhy.conf on #{node}"
46
+ next
47
+ end
48
+
49
+ ssh_executor.execute_on_node(node) do
50
+ execute :sudo, 'apt-get', 'update', '-qq'
51
+ execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq', 'dnsmasq'
52
+ upload! StringIO.new(conf), '/tmp/messhy-dns.conf'
53
+ execute :sudo, 'mv', '/tmp/messhy-dns.conf', '/etc/dnsmasq.d/messhy.conf'
54
+ execute :sudo, 'chown', 'root:root', '/etc/dnsmasq.d/messhy.conf'
55
+ execute :sudo, 'chmod', '644', '/etc/dnsmasq.d/messhy.conf'
56
+ execute :sudo, 'systemctl', 'enable', 'dnsmasq'
57
+ execute :sudo, 'systemctl', 'restart', 'dnsmasq'
58
+ end
59
+ end
60
+
61
+ config.each_node do |node_name, _|
62
+ next if @skip && node_name == @skip
63
+
64
+ if dry_run
65
+ puts "[DRY RUN] Would configure DNS on #{node_name} (#{interface}) for #{domain} -> #{server_ips.join(', ')}"
66
+ next
67
+ end
68
+
69
+ configure_client_dns(node_name, interface, domain, server_ips)
70
+ end
71
+ end
72
+
73
+ def configure_client_dns(node_name, interface, domain, server_ips)
74
+ ssh_executor.execute_on_node(node_name) do
75
+ if test('which', 'resolvectl', raise_on_error: false)
76
+ execute :sudo, 'systemctl', 'enable', '--now', 'systemd-resolved', raise_on_error: false
77
+ execute :sudo, 'resolvectl', 'dns', interface, *server_ips
78
+ execute :sudo, 'resolvectl', 'domain', interface, "~#{domain}"
79
+ execute :sudo, 'resolvectl', 'flush-caches', raise_on_error: false
80
+ elsif test('[ -d /etc/resolvconf/resolv.conf.d ]', raise_on_error: false)
81
+ head_path = '/etc/resolvconf/resolv.conf.d/head'
82
+ content = server_ips.map { |ip| "nameserver #{ip}" }.join("\n") + "\n"
83
+ upload! StringIO.new(content), '/tmp/messhy-resolv.conf'
84
+ execute :sudo, 'mv', '/tmp/messhy-resolv.conf', head_path
85
+ execute :sudo, 'chmod', '644', head_path
86
+ execute :sudo, 'resolvconf', '-u'
87
+ else
88
+ info 'resolvectl not found; skipping DNS config'
89
+ end
90
+ end
91
+ end
92
+
93
+ def build_records(domain)
94
+ records = {}
95
+
96
+ if config.dns_auto_records?
97
+ config.each_node do |name, node_config|
98
+ hostname = "#{sanitize_dns_label(name)}.#{domain}"
99
+ records[hostname] ||= []
100
+ records[hostname] << node_config['private_ip']
101
+ end
102
+ end
103
+
104
+ config.dns_records.each do |hostname, targets|
105
+ fqdn = normalize_hostname(hostname, domain)
106
+ Array(targets).each do |target|
107
+ ip = resolve_target(target)
108
+ next if ip.to_s.strip.empty?
109
+
110
+ records[fqdn] ||= []
111
+ records[fqdn] << ip
112
+ end
113
+ end
114
+
115
+ records.transform_values { |ips| ips.uniq }
116
+ end
117
+
118
+ def resolve_target(target)
119
+ return '' if target.nil?
120
+
121
+ node = config.node_config(target.to_s)
122
+ return node['private_ip'] if node
123
+
124
+ target.to_s
125
+ end
126
+
127
+ def normalize_hostname(name, domain)
128
+ value = name.to_s.strip
129
+ return '' if value.empty?
130
+
131
+ return value if value.include?('.')
132
+
133
+ "#{sanitize_dns_label(value)}.#{domain}"
134
+ end
135
+
136
+ def sanitize_dns_label(value)
137
+ value.to_s.downcase.gsub(/[^a-z0-9-]/, '-')
138
+ end
139
+
140
+ def build_dnsmasq_conf(domain, interface, server_ip, records, ttl)
141
+ lines = []
142
+ lines << '# Managed by messhy'
143
+ lines << 'domain-needed'
144
+ lines << 'bogus-priv'
145
+ lines << "local=/#{domain}/"
146
+ lines << "domain=#{domain}"
147
+ lines << 'expand-hosts'
148
+ lines << 'cache-size=1000'
149
+ lines << "local-ttl=#{ttl}"
150
+ lines << "interface=#{interface}"
151
+ lines << 'bind-interfaces'
152
+ lines << 'listen-address=127.0.0.1'
153
+ lines << "listen-address=#{server_ip}"
154
+ lines << ''
155
+
156
+ records.sort.each do |hostname, ips|
157
+ Array(ips).each do |ip|
158
+ lines << "address=/#{hostname}/#{ip}"
159
+ end
160
+ end
161
+
162
+ lines.join("\n") + "\n"
163
+ end
164
+ end
165
+ end
@@ -26,11 +26,15 @@ module Messhy
26
26
  puts
27
27
  end
28
28
 
29
+ show_dns_status if config.dns_enabled?
30
+
29
31
  show_latency_matrix
30
32
  end
31
33
 
32
34
  def show_node_status(node_name)
33
35
  node_config = config.node_config(node_name)
36
+ label = node_config['label']
37
+ label_display = label.to_s.strip.empty? ? '' : " (#{label})"
34
38
 
35
39
  begin
36
40
  status = @ssh_executor.get_wireguard_status(node_name)
@@ -39,7 +43,7 @@ module Messhy
39
43
  peers = status.scan(/peer: (.+?)$/).flatten
40
44
 
41
45
  if peers.any?
42
- puts "✓ #{node_name} (#{node_config['private_ip']}) - connected to #{peers.size} peers"
46
+ puts "✓ #{node_name} (#{node_config['private_ip']})#{label_display} - connected to #{peers.size} peers"
43
47
 
44
48
  # Show basic peer info
45
49
  status.split('peer:').drop(1).each do |peer_block|
@@ -50,10 +54,10 @@ module Messhy
50
54
  puts " └─ Peer: #{endpoint} - #{stats[:received]} rx, #{stats[:sent]} tx"
51
55
  end
52
56
  else
53
- puts "✗ #{node_name} (#{node_config['private_ip']}) - 0 peers (DOWN)"
57
+ puts "✗ #{node_name} (#{node_config['private_ip']})#{label_display} - 0 peers (DOWN)"
54
58
  end
55
59
  rescue StandardError => e
56
- puts "✗ #{node_name} (#{node_config['private_ip']}) - Error: #{e.message}"
60
+ puts "✗ #{node_name} (#{node_config['private_ip']})#{label_display} - Error: #{e.message}"
57
61
  end
58
62
  end
59
63
 
@@ -157,6 +161,41 @@ module Messhy
157
161
  end
158
162
  end
159
163
 
164
+ def show_dns_status
165
+ puts '==> Mesh DNS Status'
166
+ puts "Domain: #{config.dns_domain}"
167
+ puts "Servers: #{config.dns_server_nodes.join(', ')}"
168
+ puts
169
+
170
+ config.dns_server_nodes.each do |node_name|
171
+ node_config = config.node_config(node_name)
172
+ next unless node_config
173
+
174
+ label = node_config['label']
175
+ label_display = label.to_s.strip.empty? ? '' : " (#{label})"
176
+
177
+ begin
178
+ @ssh_executor.execute_on_node(node_name) do
179
+ service = capture(:systemctl, 'is-active', 'dnsmasq', raise_on_non_zero_exit: false).strip
180
+ messhy_records = capture(:bash, '-c',
181
+ "sudo awk 'BEGIN{c=0} /^address=\\//{c++} END{print c}' " \
182
+ "/etc/dnsmasq.d/messhy.conf 2>/dev/null || true").strip
183
+ ap_records = capture(:bash, '-c',
184
+ "sudo awk 'BEGIN{c=0} /^address=\\//{c++} END{print c}' " \
185
+ "/etc/dnsmasq.d/active_postgres.conf 2>/dev/null || true").strip
186
+
187
+ status_icon = service == 'active' ? '✓' : '✗'
188
+ puts "#{status_icon} #{node_name} (#{node_config['private_ip']})#{label_display} - dnsmasq #{service}"
189
+ puts " └─ records: messhy=#{messhy_records.to_i} active_postgres=#{ap_records.to_i}"
190
+ end
191
+ rescue StandardError => e
192
+ puts "✗ #{node_name} (#{node_config['private_ip']})#{label_display} - DNS check failed: #{e.message}"
193
+ end
194
+ end
195
+
196
+ puts
197
+ end
198
+
160
199
  private
161
200
 
162
201
  def show_latency_matrix
@@ -57,6 +57,11 @@ module Messhy
57
57
  puts "\n==> Verifying mesh connectivity..."
58
58
  verify_mesh(skip: skip)
59
59
 
60
+ if config.dns_enabled?
61
+ puts "\n==> Setting up mesh DNS..."
62
+ DnsManager.new(config, ssh_executor: ssh_executor, dry_run: dry_run, skip: skip).setup
63
+ end
64
+
60
65
  puts "\n✓ WireGuard mesh setup complete!"
61
66
  end
62
67
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Messhy
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/messhy.rb CHANGED
@@ -8,6 +8,7 @@ require_relative 'messhy/mesh_builder'
8
8
  require_relative 'messhy/ssh_executor'
9
9
  require_relative 'messhy/health_checker'
10
10
  require_relative 'messhy/host_trust_manager'
11
+ require_relative 'messhy/dns_manager'
11
12
  require_relative 'messhy/cli'
12
13
 
13
14
  module Messhy
@@ -24,4 +24,9 @@ namespace :messhy do
24
24
  task :trust_hosts do
25
25
  system('bundle exec messhy trust-hosts')
26
26
  end
27
+
28
+ desc 'Setup mesh DNS (dnsmasq)'
29
+ task :dns do
30
+ system('bundle exec messhy dns')
31
+ end
27
32
  end
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BoringCache
@@ -122,6 +122,7 @@ files:
122
122
  - lib/messhy.rb
123
123
  - lib/messhy/cli.rb
124
124
  - lib/messhy/configuration.rb
125
+ - lib/messhy/dns_manager.rb
125
126
  - lib/messhy/generators/messhy/install_generator.rb
126
127
  - lib/messhy/health_checker.rb
127
128
  - lib/messhy/host_trust_manager.rb
@@ -156,7 +157,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
157
  - !ruby/object:Gem::Version
157
158
  version: '0'
158
159
  requirements: []
159
- rubygems_version: 3.7.2
160
+ rubygems_version: 3.6.9
160
161
  specification_version: 4
161
162
  summary: WireGuard VPN mesh for Ruby & Rails apps
162
163
  test_files: []