summaryrefslogtreecommitdiff
path: root/lib/hyperstack/wireguard_setup.rb
blob: 82a9dba9cb2299fabe7f50868cca903024c670d6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# frozen_string_literal: true

require 'ipaddr'
require 'open3'

module HyperstackVM
  # Validates and runs the local WireGuard setup script for a VM.
  class WireGuardSetup
    def initialize(config:, ssh_runner:, local_wireguard:, out:, wg_setup_pre: nil, wg_setup_post: nil)
      @config = config
      @ssh_runner = ssh_runner
      @local_wireguard = local_wireguard
      @out = out
      @wg_setup_pre  = wg_setup_pre
      @wg_setup_post = wg_setup_post
    end

    def run(state)
      @wg_setup_pre&.call
      if setup_needed?(state)
        execute(state['public_ip'])
        state['wireguard_setup_at'] = Time.now.utc.iso8601
      end
      @wg_setup_post&.call
    end

    def setup_needed?(state)
      return false unless @config.wireguard_auto_setup?
      public_ip = state['public_ip'].to_s.strip
      return true if public_ip.empty?
      expected = "#{public_ip}:#{@config.wireguard_udp_port}"
      !endpoints.include?(expected)
    end

    private

    def endpoints
      Array(@local_wireguard.status['endpoints']).compact.uniq
    end

    def execute(host)
      validate_script!
      retries = 3
      retries.times do |attempt|
        info "Running WireGuard auto-setup via #{@config.wireguard_setup_script} #{host}..."
        status = run_script(host)
        return if status.success?
        if attempt == retries - 1
          raise Error, "WireGuard setup failed after #{retries} attempts (exit #{status.exitstatus})."
        end
        delay = (attempt + 1) * 15
        warn_out "WireGuard setup attempt #{attempt + 1}/#{retries} failed (exit #{status.exitstatus}), retrying in #{delay}s..."
        sleep delay
      end
    end

    def run_script(host)
      env = {
        'HYPERSTACK_SSH_PORT' => @config.ssh_port.to_s,
        'HYPERSTACK_SSH_CONNECT_TIMEOUT' => @config.ssh_connect_timeout.to_s,
        'HYPERSTACK_SSH_KNOWN_HOSTS_PATH' => @config.ssh_known_hosts_path,
        'HYPERSTACK_SSH_PRIVATE_KEY_PATH' => (File.exist?(@config.ssh_private_key_path) ? @config.ssh_private_key_path : '')
      }
      Open3.popen2e(env, 'bash', @config.wireguard_setup_script, host,
                    @config.wireguard_gateway_ip,
                    @config.wireguard_gateway_hostname) do |stdin, output, wait_thr|
        stdin.sync = true
        stdin.puts
        stdin.close
        output.each { |line| @out.print(line) }
        wait_thr.value
      end
    end

    def validate_script!
      script_path = @config.wireguard_setup_script
      raise Error, "WireGuard setup script not found: #{script_path}" unless File.exist?(script_path)

      mismatches = []
      mismatches << "ssh.username must be 'ubuntu'" unless @config.ssh_username == 'ubuntu'
      mismatches << "local_client.interface_name must be 'wg1'" unless @config.local_interface_name == 'wg1'
      mismatches << 'network.wireguard_udp_port must be 56710' unless @config.wireguard_udp_port == 56_710
      unless @config.wireguard_subnet == '192.168.3.0/24'
        mismatches << "network.wireguard_subnet must be '192.168.3.0/24'"
      end

      begin
        subnet = IPAddr.new(@config.wireguard_subnet)
        server_ip = IPAddr.new(@config.wireguard_gateway_ip)
        unless subnet.include?(server_ip)
          mismatches << "wireguard_server_ip #{@config.wireguard_gateway_ip.inspect} is outside #{@config.wireguard_subnet}"
        end
      rescue IPAddr::InvalidAddressError => e
        mismatches << "Invalid wireguard_server_ip: #{e.message}"
      end

      return if mismatches.empty?
      raise Error, "Configured WireGuard settings do not match #{script_path}: #{mismatches.join('; ')}"
    end

    def info(msg)
      @out.puts(msg)
    end

    def warn_out(msg)
      @out.puts("WARN: #{msg}")
    end
  end
end