diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-25 10:43:43 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-25 10:43:43 +0200 |
| commit | ef53a98c39c26d69b4bfd3a4e925050b220a02c9 (patch) | |
| tree | d6e747f4a9eea844f498b3f807567d3a5330694e /lib/hyperstack/wireguard.rb | |
| parent | 917c3d9a777d343b422599f291f242f4bf025ba0 (diff) | |
hyperstack: split 3335-line monolith into lib/hyperstack/ modules
Extracts all classes from hyperstack.rb into focused library files:
- lib/hyperstack/config.rb — ConfigLoader + Config (TOML loading, validation)
- lib/hyperstack/state.rb — StateStore + PrefixedOutput (JSON state, threaded output)
- lib/hyperstack/client.rb — HyperstackClient (REST API + retry logic)
- lib/hyperstack/wireguard.rb — LocalWireGuard (wg1.conf peer management, /etc/hosts)
- lib/hyperstack/provisioning.rb — ProvisioningScripts + RemoteProvisioner (SSH bootstrap)
- lib/hyperstack/manager.rb — Manager (VM lifecycle orchestration)
- lib/hyperstack/watcher.rb — VllmWatcher (Prometheus + GPU dashboard)
- lib/hyperstack/cli.rb — CLI (OptionParser command dispatch)
hyperstack.rb becomes a 46-line entry point with require_relative calls.
All files pass `ruby -c` syntax check and `--help` runs correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'lib/hyperstack/wireguard.rb')
| -rw-r--r-- | lib/hyperstack/wireguard.rb | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/lib/hyperstack/wireguard.rb b/lib/hyperstack/wireguard.rb new file mode 100644 index 0000000..4e909ba --- /dev/null +++ b/lib/hyperstack/wireguard.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'open3' + +module HyperstackVM + # Manages the local WireGuard interface config and /etc/hosts entries. + # Reads and writes the wg1.conf peer blocks and restarts the service when needed. + # Uses sudo for privileged file operations when direct write access is unavailable. + class LocalWireGuard + def initialize(interface_name:, config_path:) + @interface_name = interface_name + @config_path = config_path + end + + def status + endpoints = configured_endpoints + { + 'service_state' => service_state, + 'config_path' => @config_path, + 'endpoint' => endpoints.last, + 'endpoints' => endpoints, + 'config_readable' => !config_contents.nil? + } + end + + def remove_peers_by_allowed_ips(allowed_ips, dry_run: false) + targets = Array(allowed_ips).map(&:to_s).map(&:strip).reject(&:empty?).uniq + return [] if targets.empty? + + content = config_contents + raise Error, "Unable to read #{@config_path} for peer cleanup." if content.nil? + + updated, removed = prune_peer_blocks(content, targets) + return [] if removed.empty? + return removed if dry_run + + write_config(updated) + restart_service_if_active + @config_contents = updated + removed + end + + def remove_hostnames(hostnames, dry_run: false) + targets = Array(hostnames).map(&:to_s).map(&:strip).reject(&:empty?).uniq + return [] if targets.empty? + + content = hosts_contents + raise Error, 'Unable to read /etc/hosts for hostname cleanup.' if content.nil? + + updated, removed = prune_hosts_entries(content, targets) + return [] if removed.empty? + return removed if dry_run + + write_hosts(updated) + @hosts_contents = updated + removed + end + + private + + def service_state + stdout, _stderr, status = Open3.capture3('systemctl', 'is-active', "wg-quick@#{@interface_name}") + value = stdout.to_s.strip + return value unless value.empty? + return 'active' if status.success? + + 'unknown' + end + + def configured_endpoint + configured_endpoints.last + end + + def configured_endpoints + content = config_contents + return [] if content.nil? + + parse_wireguard_peers(content).filter_map { |peer| peer['Endpoint'] }.uniq + end + + def parse_wireguard_peers(content) + current_section = nil + current_peer = nil + peers = [] + + content.each_line do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?('#') + + if stripped.start_with?('[') && stripped.end_with?(']') + peers << current_peer if current_section == 'Peer' && current_peer && !current_peer.empty? + current_section = stripped[1..-2] + current_peer = current_section == 'Peer' ? {} : nil + next + end + + key, value = stripped.split('=', 2).map { |part| part&.strip } + next unless current_section == 'Peer' && key && value + + current_peer[key] = value + end + + peers << current_peer if current_section == 'Peer' && current_peer && !current_peer.empty? + peers + end + + def prune_peer_blocks(content, allowed_ips) + kept = [] + removed = [] + + parse_wireguard_blocks(content).each do |block| + if block[:section] == 'Peer' && allowed_ips.include?(block[:values]['AllowedIPs'].to_s.strip) + removed << block[:values] + else + kept << block[:lines].join + end + end + + [kept.join, removed] + end + + def parse_wireguard_blocks(content) + blocks = [] + current_section = nil + current_lines = [] + + content.each_line do |line| + stripped = line.strip + if stripped.start_with?('[') && stripped.end_with?(']') + blocks << wireguard_block(current_section, current_lines) unless current_lines.empty? + current_section = stripped[1..-2] + current_lines = [line] + else + current_lines << line + end + end + + blocks << wireguard_block(current_section, current_lines) unless current_lines.empty? + blocks + end + + def wireguard_block(section, lines) + { + section: section, + lines: lines.dup, + values: parse_wireguard_section_values(section, lines) + } + end + + def parse_wireguard_section_values(section, lines) + return {} unless section == 'Peer' + + lines.each_with_object({}) do |line, values| + stripped = line.strip + next if stripped.empty? || stripped.start_with?('#') || stripped.start_with?('[') + + key, value = stripped.split('=', 2).map { |part| part&.strip } + values[key] = value if key && value + end + end + + def write_config(content) + File.write(@config_path, content) + rescue Errno::EACCES + _stdout, stderr, status = Open3.capture3('sudo', '-n', 'tee', @config_path, stdin_data: content) + raise Error, "Failed to update #{@config_path}: #{stderr.to_s.strip}" unless status.success? + + _stdout, stderr, status = Open3.capture3('sudo', '-n', 'chmod', '600', @config_path) + raise Error, "Failed to chmod #{@config_path}: #{stderr.to_s.strip}" unless status.success? + end + + def restart_service_if_active + return unless service_state == 'active' + + _stdout, stderr, status = Open3.capture3('sudo', '-n', 'systemctl', 'restart', "wg-quick@#{@interface_name}") + raise Error, "Failed to restart wg-quick@#{@interface_name}: #{stderr.to_s.strip}" unless status.success? + end + + def config_contents + return @config_contents if defined?(@config_contents) + + @config_contents = File.read(@config_path) + rescue Errno::EACCES, Errno::ENOENT + stdout, _stderr, status = Open3.capture3('sudo', '-n', 'cat', @config_path) + @config_contents = status.success? ? stdout : nil + end + + def hosts_contents + return @hosts_contents if defined?(@hosts_contents) + + @hosts_contents = File.read('/etc/hosts') + rescue Errno::EACCES, Errno::ENOENT + stdout, _stderr, status = Open3.capture3('sudo', '-n', 'cat', '/etc/hosts') + @hosts_contents = status.success? ? stdout : nil + end + + def prune_hosts_entries(content, hostnames) + removed = [] + updated = content.each_line.filter_map do |line| + rewritten, line_removed = prune_host_line(line, hostnames) + removed.concat(line_removed) + rewritten + end + [updated.join, removed.uniq] + end + + def prune_host_line(line, hostnames) + stripped = line.strip + return [line, []] if stripped.empty? || stripped.start_with?('#') + + body, comment = line.split('#', 2) + tokens = body.split(/\s+/) + return [line, []] if tokens.empty? + + ip = tokens.shift + removed = tokens & hostnames + return [line, []] if removed.empty? + + remaining = tokens - hostnames + return [nil, removed] if remaining.empty? + + rewritten = ([ip] + remaining).join("\t") + rewritten = "#{rewritten} # #{comment.strip}" if comment && !comment.strip.empty? + ["#{rewritten}\n", removed] + end + + def write_hosts(content) + File.write('/etc/hosts', content) + rescue Errno::EACCES + _stdout, stderr, status = Open3.capture3('sudo', '-n', 'tee', '/etc/hosts', stdin_data: content) + raise Error, "Failed to update /etc/hosts: #{stderr.to_s.strip}" unless status.success? + end + end +end |
