From 0a1d6bd95abbd862664e8a5cdf0a9e73b2089d97 Mon Sep 17 00:00:00 2001 From: "Ruben S. Montero" Date: Mon, 6 May 2024 19:35:32 +0200 Subject: [PATCH] F #68: Manage multiple instances and reboots Configuration (server-side) is stored in virtual router VMs using: - ONEAPP_VNF_WG_SERVER wg0.conf file base64 encoded - ONEAPP_VNF_WG_SERVER_TIMESTAMP when file was generated Virtual router will reuse this configuration if present in any of the virtual router VMs. State is managed through onegate, thus required to run the WG service --- appliances/VRouter/WireGuard/main.rb | 301 +++++++++++++++++++++----- appliances/VRouter/WireGuard/main2.rb | 253 ---------------------- 2 files changed, 244 insertions(+), 310 deletions(-) delete mode 100644 appliances/VRouter/WireGuard/main2.rb diff --git a/appliances/VRouter/WireGuard/main.rb b/appliances/VRouter/WireGuard/main.rb index 7774dab6..2621e6bd 100644 --- a/appliances/VRouter/WireGuard/main.rb +++ b/appliances/VRouter/WireGuard/main.rb @@ -3,45 +3,159 @@ require 'erb' require 'ipaddr' require 'yaml' +require 'base64' require_relative '../vrouter.rb' -begin - require 'json-schema' -rescue LoadError - # NOTE: This handles the install stage. -end - module Service module WireGuard extend self + # This class represents a WG peer and includes function to render and publish + # its configuration to the virtual router VM template + class Peer + @@peers = 0 + + def initialize(subnet, ip) + @subnet = IPAddr.new(subnet) + + raise "Peer IP #{ip} not in peer subnet #{subnet}" unless @subnet.include? ip + + @ip = IPAddr.new(ip) + + @peer = @@peers + @@peers = @@peers + 1 + + shared_k = bash('wg genpsk', chomp: true) + private_k = bash('wg genkey', chomp: true) + public_k = bash("wg pubkey <<< '#{private_k}'", chomp: true) + + @wgpeer = { + 'address' => "#{@ip.to_s}/#{@subnet.prefix}", + 'preshared_key' => shared_k, + 'private_key' => private_k, + 'public_key' => public_k, + 'allowed_ips' => %w[0.0.0.0/0] + } + end + + def to_s_client(opts) + <<~PEER + [Interface] + Address = #{@wgpeer['address']} + PrivateKey = #{@wgpeer['private_key']} + + [Peer] + Endpoint = #{opts['server_addr']}:#{opts['listen_port']} + PublicKey = #{@wgpeer['public_key']} + PresharedKey = #{@wgpeer['preshared_key']} + AllowedIPs = #{@wgpeer['allowed_ips'].join(%[,])} + PEER + end + + def to_s_server + <<~PEER + [Peer] + PresharedKey = #{@wgpeer['preshared_key']} + PublicKey = #{@wgpeer['public_key']} + AllowedIPs = #{@wgpeer['address'].split(%[/])[0]}/32 + PEER + end + + def to_template(opts) + peer_conf64 = Base64.strict_encode64(to_s_client(opts)) + + "ONEAPP_VNF_WG_PEER#{@peer}=#{peer_conf64}" + end + end + + DEPENDS_ON = %w[Service::Failover] + # -------------------------------------------------------------------------- + # WireGuard Configuration parameters. + # -------------------------------------------------------------------------- + # + # ONEAPP_VNF_WG_ENABLED = "YES" + # ONEAPP_VNF_WG_INTERFACE_OUT = "eth0" + # ONEAPP_VNF_WG_INTERFACE_IN = "eth1" + # ONEAPP_VNF_WG_LISTEN_PORT = "51820" + # ONEAPP_VNF_WG_DEVICE = "wg0" + # ONEAPP_VNG_WG_PEERS = "10.0.0.1 10.0.0.2 10.0.0.3 10.0.0.4" + # -------------------------------------------------------------------------- + # The VM ID of the Virtual Router + VM_ID = env :VM_ID, nil + + # Enables the service ONEAPP_VNF_WG_ENABLED = env :ONEAPP_VNF_WG_ENABLED, 'NO' - ONEAPP_VNF_WG_INTERFACES_OUT = env :ONEAPP_VNF_WG_INTERFACES_OUT, nil # nil -> none, empty -> all + # The NIC to connect clients, its IP will be the service endpoint (MANDATORY) + ONEAPP_VNF_WG_INTERFACE_OUT = env :ONEAPP_VNF_WG_INTERFACE_OUT, nil - ONEAPP_VNF_WG_CFG_LOCATION = env :ONEAPP_VNF_WG_CFG_LOCATION, '/dev/sr0:/onewg.yml' + # The NIC to connect to the private subnet (MANDATORY) + ONEAPP_VNF_WG_INTERFACE_IN = env :ONEAPP_VNF_WG_INTERFACE_IN, nil - def parse_env - @interfaces_out ||= parse_interfaces ONEAPP_VNF_WG_INTERFACES_OUT - @mgmt ||= detect_mgmt_nics - @interfaces ||= @interfaces_out.keys - @mgmt + # Listen port number, defaults to 51820 + ONEAPP_VNF_WG_LISTEN_PORT = env :ONEAPP_VNF_WG_LISTEN_PORT, 51820 - iso_path, cfg_path = ONEAPP_VNF_WG_CFG_LOCATION.split(%[:]) + # WG device name, defaults to wg0 + ONEAPP_VNF_WG_DEVICE = env :ONEAPP_VNF_WG_DEVICE, 'wg0' - schema = YAML.load bash("#{File.dirname(__FILE__)}/onewg schema show", chomp: true) + # Peers by IP address, each address MUST no be assigned to any VM (i.e. put + # on hold or exclude from VNET AR's) (MANDATORY) + # For example 5 PEERS: + # ONEAPP_VNG_WG_PEERS = "10.0.0.1 10.0.0.2 10.0.0.3 10.0.0.4 10.0.0.5" + ONEAPP_VNF_WG_PEERS = env :ONEAPP_VNF_WG_PEERS, '' - document = YAML.load bash("isoinfo -i #{iso_path} -R -x #{cfg_path}", chomp: true) + #Base folder to store WG configuration + ETC_DIR = '/etc/wireguard' - if JSON::Validator.validate(schema, document) - { cfg: document } - else - msg :error, 'YAML config looks invalid!' - { cfg: nil } - end + def wg_environment + iout = ONEAPP_VNF_WG_INTERFACE_OUT + iin = ONEAPP_VNF_WG_INTERFACE_IN + + mgmt = detect_mgmt_nics + + raise "Forbidden ONEAPP_VNF_WG_INTERFACE_OUT interface: #{iout}" if mgmt.include?(iout) + + #----------------------------------------------------------------------- + # Get IP address information for INTERFACE_IN + #----------------------------------------------------------------------- + eps = detect_endpoints + + raise "Cannot find address information for #{iin}" if eps[iin].nil? + + rc = iin.match /eth(\d+)/i + + raise "Wrong format for ONEAPP_VNF_WG_INTERFACE_IN: #{iin}" if rc.nil? + + addr_in = eps[iin]["ETH#{rc[1]}_EP0"] + + raise "Cannot get IP address for #{iin}" if addr_in.nil? || addr_in.empty? + + server_addr, server_prefix = addr_in.split('/') + + nets_in = nics_to_subnets([iin]) + net_in = nets_in[iin] + + raise "Cannot get net addres for #{iin}" if nets_in[iin].nil? || net_in[0].empty? + + #----------------------------------------------------------------------- + # Return configuration for the WG device + #----------------------------------------------------------------------- + { + 'listen_port' => ONEAPP_VNF_WG_LISTEN_PORT, + 'iface_out' => iout, + 'server_addr' => server_addr, + 'private_key' => bash('wg genkey', chomp: true), + 'peer_subnet' => net_in[0], + 'peers' => ONEAPP_VNF_WG_PEERS.split(' ').map {|p| p.chomp } + } end + # -------------------------------------------------------------------------- + # SERIVCE INTERFACE: install, configure and bootstrap methods + # -------------------------------------------------------------------------- + # Installs WireGuard service. Log set to /var/log/one-appliance/one-wg.log def install(initdir: '/etc/init.d') msg :info, 'WireGuard::install' @@ -72,63 +186,120 @@ def install(initdir: '/etc/init.d') toggle [:update] end - def configure(basedir: '/etc/wireguard') + # Configure WG service, just return and postpone to execute + def configure(basedir: ETC_DIR) msg :info, 'WireGuard::configure' + # NOTE: We always disable it at re-contexting / reboot in case an user enables it manually. unless ONEAPP_VNF_WG_ENABLED - # NOTE: We always disable it at re-contexting / reboot in case an user enables it manually. toggle [:stop, :disable] return end + end - parse_env[:cfg]&.each do |dev, opts| - unless @interfaces.include?(opts['interface_out']) - msg :error, "Forbidden outgoing interface: #{opts['interface_out']}" - next - end + def bootstrap + msg :info, 'WireGuard::bootstrap' + end + + def bootstrap + msg :info, 'WireGuard::bootstrap' + end - subnet = IPAddr.new(opts['peer_subnet']) + # -------------------------------------------------------------------------- + # WG helper functions + # -------------------------------------------------------------------------- + def execute + msg :info, 'WireGuard::execute' + + opts = wg_environment + + ids = onegate_vmids + conf64 = '' + tstamp = 0 + + ids.each do |vmid| + t, c = onegate_conf(vmid) - peers = opts['peers'].to_h.each_with_object({}) do |(k, v), acc| - next if v['public_key'].nil? && v['private_key'].nil? + conf64 = c if (tstamp == 0 || t > tstamp) && c && !c.empty? + end - v['public_key'] ||= bash("wg pubkey <<< #{v['private_key']}", chomp: true) + if !conf64.empty? + # ------------------------------------------------------------------ + # Reuse existing configuration file in virtual router + # ------------------------------------------------------------------ + msg :info, '[WireGuard::execute] Using existing configuration' - acc[k] = v + file "#{ETC_DIR}/#{ONEAPP_VNF_WG_DEVICE}.conf", + Base64.strict_decode64(conf64), + mode: 'u=rw,g=r,o=', + overwrite: true + else + msg :info, '[WireGuard::execute] Generating a new configuration' + + # ------------------------------------------------------------------ + # Generate a new configuration + # ------------------------------------------------------------------ + peers = [] + + opts['peers'].each do |ip| + p = Peer.new opts['peer_subnet'], ip + peers << p + rescue StandardError => e + msg :error, e.message + next end - file "#{basedir}/#{dev}.conf", ERB.new(<<~PEER, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true + conf = ERB.new(<<~CONF, trim_mode: '-').result(binding) [Interface] - Address = <%= subnet.succ.to_s %>/<%= subnet.prefix %> - ListenPort = <%= opts['server_port'] %> + Address = <%= opts['server_addr'] %> + ListenPort = <%= opts['listen_port'] %> PrivateKey = <%= opts['private_key'] %> - <%- peers.each do |k, v| -%> - [Peer] - PresharedKey = <%= v['preshared_key'] %> - PublicKey = <%= v['public_key'] %> - AllowedIPs = <%= v['address'].split(%[/])[0] %>/32 - <%- end -%> - PEER - end - end + <% peers.each do |p| %> + <%= p.to_s_server %> + <% end %> + CONF + + file "#{ETC_DIR}/#{ONEAPP_VNF_WG_DEVICE}.conf", + conf, + mode: 'u=rw,g=r,o=', + overwrite: true + + # ------------------------------------------------------------------ + # Save configuration to virtual router VMs + # ------------------------------------------------------------------ + info = [] + + peers.each do |p| + info << p.to_template(opts) + end - def execute - msg :info, 'WireGuard::execute' + info << "ONEAPP_VNF_WG_SERVER=#{Base64.strict_encode64(conf)}" + info << "ONEAPP_VNF_WG_SERVER_TIMESTAMP=#{Time.now.to_i}" - parse_env[:cfg]&.each do |dev, _| - bash <<~BASH - wg-quick up '#{dev}' - echo 1 > '/proc/sys/net/ipv4/conf/#{dev}/forwarding' - BASH + data = info.join("\n") + + ids.each do |vmid| + msg :info, "[WireGuard::execute] Updating VM #{vmid}" + + bash "onegate vm update #{vmid} --data \"#{data}\"" + rescue StandardError => e + msg :error, e.message + next + end end + + msg :info, "[WireGuard::execute] bringing up #{ONEAPP_VNF_WG_DEVICE}" + + bash <<~BASH + wg-quick up '#{ONEAPP_VNF_WG_DEVICE}' + echo 1 > '/proc/sys/net/ipv4/conf/#{ONEAPP_VNF_WG_DEVICE}/forwarding' + BASH end def cleanup msg :info, 'WireGuard::cleanup' - parse_env[:cfg]&.each do |dev, _| - bash "wg-quick down '#{dev}'" - end + bash "wg-quick down '#{ONEAPP_VNF_WG_DEVICE}'" end def toggle(operations) @@ -145,8 +316,24 @@ def toggle(operations) end end - def bootstrap - msg :info, 'WireGuard::bootstrap' + # Get the vm ids of the virtual router. Used to get/set WG configuration + def onegate_vmids + vr = onegate_vrouter_show + + vr['VROUTER']['VMS']['ID'] + rescue + [VM_ID] end + + # Get configuration from the VM template + def onegate_conf(vm_id) + vm = onegate_vm_show(vm_id) + utmp = vm['VM']['USER_TEMPLATE'] + + [utmp['ONEAPP_VNF_WG_SERVER_TIMESTAMP'], utmp['ONEAPP_VNF_WG_SERVER']] + rescue + [0, ''] + end + end end diff --git a/appliances/VRouter/WireGuard/main2.rb b/appliances/VRouter/WireGuard/main2.rb deleted file mode 100644 index 3dbd2532..00000000 --- a/appliances/VRouter/WireGuard/main2.rb +++ /dev/null @@ -1,253 +0,0 @@ -# frozen_string_literal: true - -require 'erb' -require 'ipaddr' -require 'yaml' -require 'base64' -require_relative '../vrouter.rb' - -module Service -module WireGuard - extend self - - # This class represents a WG peer and includes function to render and publish - # its configuration to the virtual router VM template - class Peer - @@peers = 0 - - def initialize(subnet, ip) - @subnet = IPAddr.new(subnet) - - raise "Peer IP #{ip} not in peer subnet #{subnet}" unless @subnet.include? ip - - @ip = IPAddr.new(ip) - - @peer = @@peers - @@peers = @@peers + 1 - - shared_k = bash('wg genpsk', chomp: true) - private_k = bash('wg genkey', chomp: true) - public_k = bash("wg pubkey <<< '#{private_k}'", chomp: true) - - @wgpeer = { - 'address' => "#{@ip.to_s}/#{@subnet.prefix}", - 'preshared_key' => shared_k, - 'private_key' => private_k, - 'public_key' => public_k, - 'allowed_ips' => %w[0.0.0.0/0] - } - end - - def to_s_client(opts) - <<~PEER - [Interface] - Address = #{@wgpeer['address']} - PrivateKey = #{@wgpeer['private_key']} - - [Peer] - Endpoint = #{opts['server_addr']}:#{opts['listen_port']} - PublicKey = #{@wgpeer['public_key']} - PresharedKey = #{@wgpeer['preshared_key']} - AllowedIPs = #{@wgpeer['allowed_ips'].join(%[,])} - PEER - end - - def to_s_server - <<~PEER - [Peer] - PresharedKey = #{@wgpeer['preshared_key']} - PublicKey = #{@wgpeer['public_key']} - AllowedIPs = #{@wgpeer['address'].split(%[/])[0]}/32 - PEER - end - - def update(opts) - conf = "ONEAPP_VNF_WG_PEER#{@peer}='#{Base64.strict_encode64(to_s_client(opts))}'" - - bash "onegate vm update #{VM_ID} --data #{conf}" - rescue StandardError => e - msg :error, e.message - end - end - - - DEPENDS_ON = %w[Service::Failover] - - # -------------------------------------------------------------------------- - # WireGuard Configuration parameters. - # -------------------------------------------------------------------------- - # - # ONEAPP_VNF_WG_ENABLED = "YES" - # ONEAPP_VNF_WG_INTERFACE_OUT = "eth0" - # ONEAPP_VNF_WG_INTERFACE_IN = "eth1" - # ONEAPP_VNF_WG_LISTEN_PORT = "51820" - # ONEAPP_VNF_WG_DEVICE = "wg0" - # ONEAPP_VNG_WG_PEERS = "10.0.0.1 10.0.0.2 10.0.0.3 10.0.0.4" - # -------------------------------------------------------------------------- - # The VM ID of the Virtual Router - VM_ID = env :VM_ID, nil - - # Enables the service - ONEAPP_VNF_WG_ENABLED = env :ONEAPP_VNF_WG_ENABLED, 'NO' - - # The NIC to connect clients, its IP will be the service endpoint (MANDATORY) - ONEAPP_VNF_WG_INTERFACE_OUT = env :ONEAPP_VNF_WG_INTERFACE_OUT, nil - - # The NIC to connect to the private subnet (MANDATORY) - ONEAPP_VNF_WG_INTERFACE_IN = env :ONEAPP_VNF_WG_INTERFACE_IN, nil - - # Listen port number, defaults to 51820 - ONEAPP_VNF_WG_LISTEN_PORT = env :ONEAPP_VNF_WG_LISTEN_PORT, 51820 - - # WG device name, defaults to wg0 - ONEAPP_VNF_WG_DEVICE = env :ONEAPP_VNF_WG_DEVICE, 'wg0' - - # Peers by IP address, each address MUST no be assigned to any VM (i.e. put - # on hold or exclude from VNET AR's) (MANDATORY) - # For example 5 PEERS: - # ONEAPP_VNG_WG_PEERS = "10.0.0.1 10.0.0.2 10.0.0.3 10.0.0.4 10.0.0.5" - ONEAPP_VNF_WG_PEERS = env :ONEAPP_VNF_WG_PEERS, '' - - def parse_env - iout = ONEAPP_VNF_WG_INTERFACE_OUT - iin = ONEAPP_VNF_WG_INTERFACE_IN - - mgmt = detect_mgmt_nics - - raise "Forbidden public (out) interface: #{iout}" if mgmt.include?(iout) - - @addrs_in ||= nics_to_addrs([iin]) - @addr_in ||= @addrs_in[iin] - - @nets_in ||= nics_to_subnets([iin]) - @net_in ||= @nets_in[iin] - - if @net_in.nil? || @net_in[0].empty? || @addr_in.nil? || @addr_in[0].empty? - raise "Wrong configuration for private (in) interface: #{iin}" - end - - { - ONEAPP_VNF_WG_DEVICE => { - 'listen_port' => ONEAPP_VNF_WG_LISTEN_PORT, - 'iface_out' => iout, - 'server_addr' => @addr_in[0], - 'private_key' => bash('wg genkey', chomp: true), - 'peer_subnet' => @net_in[0], - 'peers' => ONEAPP_VNF_WG_PEERS.split(' ').map {|p| p.chomp } - } - } - end - - # -------------------------------------------------------------------------- - # SERIVCE INTERFACE: install, configure and bootstrap methods - # -------------------------------------------------------------------------- - # Installs WireGuard service. Log set to /var/log/one-appliance/one-wg.log - def install(initdir: '/etc/init.d') - msg :info, 'WireGuard::install' - - puts bash 'apk --no-cache add cdrkit ruby wireguard-tools-wg-quick' - puts bash 'gem install --no-document json-schema' - - file "#{initdir}/one-wg", <<~SERVICE, mode: 'u=rwx,g=rx,o=' - #!/sbin/openrc-run - - source /run/one-context/one_env - - command="/usr/bin/ruby" - command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__}" - - depend() { - after net firewall keepalived - } - - start() { - $command $command_args -e Service::WireGuard.execute 1>>/var/log/one-appliance/one-wg.log 2>&1 - } - - stop() { - $command $command_args -e Service::WireGuard.cleanup 1>>/var/log/one-appliance/one-wg.log 2>&1 - } - SERVICE - - toggle [:update] - end - - # Configure WG service - def configure(basedir: '/etc/wireguard') - msg :info, 'WireGuard::configure' - - unless ONEAPP_VNF_WG_ENABLED - # NOTE: We always disable it at re-contexting / reboot in case an user enables it manually. - toggle [:stop, :disable] - return - end - - parse_env.each do |dev, opts| - peers = [] - - opts['peers'].each do |ip| - p = Peer.new opts['peer_subnet'], ip - peers << p - rescue StandardError => e - msg :error, e.message - next - end - - file "#{basedir}/#{dev}.conf", ERB.new(<<~CONF, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true - [Interface] - Address = <%= opts['server_addr'] %> - ListenPort = <%= opts['listen_port'] %> - PrivateKey = <%= opts['private_key'] %> - <% peers.each do |p| %> - <%= p.to_s_server %> - <% end %> - CONF - - peers.each do |p| - p.update opts - end - end - end - - def bootstrap - msg :info, 'WireGuard::bootstrap' - end - - # -------------------------------------------------------------------------- - # WG helper functions - # -------------------------------------------------------------------------- - def execute - msg :info, 'WireGuard::execute' - - parse_env[:cfg]&.each do |dev, _| - bash <<~BASH - wg-quick up '#{dev}' - echo 1 > '/proc/sys/net/ipv4/conf/#{dev}/forwarding' - BASH - end - end - - def cleanup - msg :info, 'WireGuard::cleanup' - - parse_env[:cfg]&.each do |dev, _| - bash "wg-quick down '#{dev}'" - end - end - - def toggle(operations) - operations.each do |op| - msg :info, "WireGuard::toggle([:#{op}])" - case op - when :disable - puts bash 'rc-update del one-wg default ||:' - when :update - puts bash 'rc-update -u' - else - puts bash "rc-service one-wg #{op.to_s}" - end - end - end - -end -end