From 2c6838fc8d43d1186f4ef9766267cf1991a6b7c3 Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Tue, 26 Mar 2024 05:12:52 +0100 Subject: [PATCH 1/6] F #68: Add VR/WG support - Add onewg VPN admin tool - Add Service::WireGuard feature to VR - Adjust Service::Failover accordingly - Add basic JSON schema validation --- appliances/VRouter/Failover/execute.rb | 3 + appliances/VRouter/NAT4/main.rb | 2 +- appliances/VRouter/WireGuard/main.rb | 152 +++++++++ appliances/VRouter/WireGuard/onewg | 432 +++++++++++++++++++++++++ 4 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 appliances/VRouter/WireGuard/main.rb create mode 100755 appliances/VRouter/WireGuard/onewg diff --git a/appliances/VRouter/Failover/execute.rb b/appliances/VRouter/Failover/execute.rb index e64babcd..da73913b 100644 --- a/appliances/VRouter/Failover/execute.rb +++ b/appliances/VRouter/Failover/execute.rb @@ -28,6 +28,9 @@ module Failover fallback: 'NO' }, 'one-dhcp4' => { _ENABLED: 'ONEAPP_VNF_DHCP4_ENABLED', + fallback: 'NO' }, + + 'one-wg' => { _ENABLED: 'ONEAPP_VNF_WG_ENABLED', fallback: 'NO' } } diff --git a/appliances/VRouter/NAT4/main.rb b/appliances/VRouter/NAT4/main.rb index d1a1d6d3..dba0945b 100644 --- a/appliances/VRouter/NAT4/main.rb +++ b/appliances/VRouter/NAT4/main.rb @@ -12,7 +12,7 @@ module NAT4 ONEAPP_VNF_NAT4_ENABLED = env :ONEAPP_VNF_NAT4_ENABLED, 'NO' - ONEAPP_VNF_NAT4_INTERFACES_OUT = env :ONEAPP_VNF_NAT4_INTERFACES_OUT, '' # nil -> none, empty -> all + ONEAPP_VNF_NAT4_INTERFACES_OUT = env :ONEAPP_VNF_NAT4_INTERFACES_OUT, nil # nil -> none, empty -> all def parse_env @interfaces_out ||= parse_interfaces ONEAPP_VNF_NAT4_INTERFACES_OUT diff --git a/appliances/VRouter/WireGuard/main.rb b/appliances/VRouter/WireGuard/main.rb new file mode 100644 index 00000000..7774dab6 --- /dev/null +++ b/appliances/VRouter/WireGuard/main.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'erb' +require 'ipaddr' +require 'yaml' +require_relative '../vrouter.rb' + +begin + require 'json-schema' +rescue LoadError + # NOTE: This handles the install stage. +end + +module Service +module WireGuard + extend self + + DEPENDS_ON = %w[Service::Failover] + + 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 + + ONEAPP_VNF_WG_CFG_LOCATION = env :ONEAPP_VNF_WG_CFG_LOCATION, '/dev/sr0:/onewg.yml' + + def parse_env + @interfaces_out ||= parse_interfaces ONEAPP_VNF_WG_INTERFACES_OUT + @mgmt ||= detect_mgmt_nics + @interfaces ||= @interfaces_out.keys - @mgmt + + iso_path, cfg_path = ONEAPP_VNF_WG_CFG_LOCATION.split(%[:]) + + schema = YAML.load bash("#{File.dirname(__FILE__)}/onewg schema show", chomp: true) + + document = YAML.load bash("isoinfo -i #{iso_path} -R -x #{cfg_path}", chomp: true) + + if JSON::Validator.validate(schema, document) + { cfg: document } + else + msg :error, 'YAML config looks invalid!' + { cfg: nil } + end + end + + 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 + + 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[:cfg]&.each do |dev, opts| + unless @interfaces.include?(opts['interface_out']) + msg :error, "Forbidden outgoing interface: #{opts['interface_out']}" + next + end + + subnet = IPAddr.new(opts['peer_subnet']) + + peers = opts['peers'].to_h.each_with_object({}) do |(k, v), acc| + next if v['public_key'].nil? && v['private_key'].nil? + + v['public_key'] ||= bash("wg pubkey <<< #{v['private_key']}", chomp: true) + + acc[k] = v + end + + file "#{basedir}/#{dev}.conf", ERB.new(<<~PEER, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true + [Interface] + Address = <%= subnet.succ.to_s %>/<%= subnet.prefix %> + ListenPort = <%= opts['server_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 + + 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 + + def bootstrap + msg :info, 'WireGuard::bootstrap' + end +end +end diff --git a/appliances/VRouter/WireGuard/onewg b/appliances/VRouter/WireGuard/onewg new file mode 100755 index 00000000..97aa3e95 --- /dev/null +++ b/appliances/VRouter/WireGuard/onewg @@ -0,0 +1,432 @@ +#!/usr/bin/env ruby + +require 'ipaddr' +require 'optparse' +require 'yaml' + +begin + require '/etc/one-appliance/lib/helpers.rb' +rescue LoadError + require_relative '../../lib/helpers.rb' +end + +begin + require 'json-schema' + $use_schema = true +rescue LoadError + $use_schema = false +end + +SCHEMA = <<~SCHEMA +--- +type: object +additionalProperties: + type: object + required: [endpoint, peer_subnet, server_port, private_key, interface_out, peers] + properties: + endpoint: + type: string + minLength: 3 + pattern: "^[-.A-Za-z0-9]+:[0-9]+$" + peer_subnet: + type: string + minLength: 4 + pattern: "^([.0-9]+|[:A-Fa-f0-9]+)/[0-9]+$" + server_port: + type: integer + minimum: 1 + maximum: 65535 + private_key: + type: string + minLength: 44 + maxLength: 44 + pattern: "^[-+/A-Za-z0-9]*={0,3}$" + interface_out: + type: string + minLength: 1 + peers: + type: object + additionalProperties: + type: object + required: [address, preshared_key, allowed_ips] + oneOf: + - required: [public_key] + - required: [private_key] + properties: + address: + type: string + minLength: 4 + pattern: "^([.0-9]+|[:A-Fa-f0-9]+)/[0-9]+$" + preshared_key: + type: string + minLength: 44 + maxLength: 44 + pattern: "^[-+/A-Za-z0-9]*={0,3}$" + public_key: + type: string + minLength: 44 + maxLength: 44 + pattern: "^[-+/A-Za-z0-9]*={0,3}$" + private_key: + type: string + minLength: 44 + maxLength: 44 + pattern: "^[-+/A-Za-z0-9]*={0,3}$" + allowed_ips: + type: array + minItems: 1 + items: + type: string + minLength: 4 + pattern: "^([.0-9]+|[:A-Fa-f0-9]+)/[0-9]+$" +SCHEMA + +$opts = { + parsed: { + cmd: nil, + dev: 'wg0', + endpoint: nil, + path: '/var/tmp/onewg.yml', + peer: nil, + peers: nil, + pubkey: nil, + subnet: nil, + use_schema: $use_schema + }, + cmds: { + '' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] " + opts.on('-c PATH', '--config PATH', 'Config file path') do |v| + $opts[:parsed][:path] = v + end + ['', 'Available commands: show, init, schema, peer', ''].each { |s| opts.separator(s) } + end + }, + 'show' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] show [options]" + end, + args: -> do + $opts[:parsed][:cmd] = :show + end + }, + 'init' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] init [options] " + opts.on('-e ENDPOINT', '--endpoint ENDPOINT', 'VPN endpoint (A.B.C.D:E)') do |v| + $opts[:parsed][:endpoint] = v + end + opts.on('-s SUBNET', '--subnet SUBNET', 'Peer subnet (A.B.C.D/E)') do |v| + $opts[:parsed][:subnet] = v + end + end, + args: -> do + $opts[:parsed][:cmd] = :init + if ($opts[:parsed][:dev] = ARGV[0]).nil? || $opts[:parsed][:dev].empty? + $stderr.puts 'Missing argument: device' + exit(-1) + end + end + }, + 'schema' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] schema [options] " + ['', 'Available subcommands: show, check', ''].each { |s| opts.separator(s) } + end, + subcmds: { + 'show' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] schema [options] show [options]" + end, + args: -> do + $opts[:parsed][:cmd] = :schema_show + end + }, + 'check' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] schema [options] check [options]" + end, + args: -> do + $opts[:parsed][:cmd] = :schema_check + end + } + } + }, + 'peer' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] peer [options] " + ['', 'Available subcommands: add, del, get', ''].each { |s| opts.separator(s) } + end, + subcmds: { + 'add' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] peer [options] add [options] [peers]" + opts.on('-p PUBKEY', '--pubkey PUBKEY', 'Use peer pubkey only') do |v| + $opts[:parsed][:pubkey] = v + end + end, + args: -> do + $opts[:parsed][:cmd] = :peer_add + if ($opts[:parsed][:dev] = ARGV[0]).nil? || $opts[:parsed][:dev].empty? + $stderr.puts 'Missing argument: device' + exit(-1) + end + if ($opts[:parsed][:peers] = ARGV[1..].reject(&:empty?)).empty? + $stderr.puts 'Missing argument: peer' + exit(-1) + end + end + }, + 'del' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] peer [options] del [options] [peers]" + end, + args: -> do + $opts[:parsed][:cmd] = :peer_del + if ($opts[:parsed][:dev] = ARGV[0]).nil? || $opts[:parsed][:dev].empty? + $stderr.puts 'Missing argument: device' + exit(-1) + end + if ($opts[:parsed][:peers] = ARGV[1..].reject(&:empty?)).empty? + $stderr.puts 'Missing argument: peer' + exit(-1) + end + end + }, + 'get' => { + parser: OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] peer [options] get [options] " + end, + args: -> do + $opts[:parsed][:cmd] = :peer_get + if ($opts[:parsed][:dev] = ARGV[0]).nil? || $opts[:parsed][:dev].empty? + $stderr.puts 'Missing argument: device' + exit(-1) + end + if ($opts[:parsed][:peer] = ARGV[1]).nil? || $opts[:parsed][:peer].empty? + $stderr.puts 'Missing argument: peer' + exit(-1) + end + end + } + } + } + }, + exec: -> do + $opts.dig(:cmds, '', :parser).order! + + if $opts.dig(:cmds, arg1 = ARGV.shift).nil? + $stdout.puts $opts.dig(:cmds, '', :parser).help + exit(-1) + end + + if $opts.dig(:cmds, arg1, :subcmds).nil? + $opts.dig(:cmds, arg1, :parser).parse! + $opts.dig(:cmds, arg1, :args).call + else + $opts.dig(:cmds, arg1, :parser).order! + + if $opts.dig(:cmds, arg1, :subcmds, arg2 = ARGV.shift).nil? + $stdout.puts $opts.dig(:cmds, arg1, :parser).help + exit(-1) + end + + $opts.dig(:cmds, arg1, :subcmds, arg2, :parser).parse! + $opts.dig(:cmds, arg1, :subcmds, arg2, :args).call + end + + $opts[:parsed][:cmd] + end +} + +def show(opts = $opts[:parsed]) + text = !opts[:path].empty? && File.exist?(opts[:path]) ? File.read(opts[:path]) : '' + $stdout.puts text +end + +def init(opts = $opts[:parsed]) + document = !opts[:path].empty? && File.exist?(opts[:path]) ? YAML.load_file(opts[:path]) : {} + + unless document[opts[:dev]].nil? + $stderr.puts "Already initialized: #{opts[:dev]}" + exit(-1) + end + + unless opts[:endpoint].nil? + host, port = opts[:endpoint].split(%[:]) + + server_port = port.to_i unless port.nil? + + endpoint = "#{host}:#{server_port}" + else + server_port = document.each_with_object([]) do |(_, v), acc| + acc << v['server_port'].to_i unless v['server_port'].nil? + end.then do |ports| + ports.empty? ? 51820 : (ports.max + 1) + end + + endpoint = "10.11.12.13:#{server_port}" + end + + unless opts[:subnet].nil? + peer_subnet = opts[:subnet] + else + peer_subnet = document.each_with_object([]) do |(_, v), acc| + acc << $1.to_i if v['peer_subnet'] =~ %r{^192\.168\.(\d+)\.0/24} + end.then do |octets| + "192.168.#{octets.empty? ? 144 : (octets.max + 1)}.0/24" + end + end + + document[opts[:dev]] = { + 'endpoint' => endpoint, + 'peer_subnet' => peer_subnet, + 'server_port' => server_port, + 'private_key' => bash('wg genkey', chomp: true), + 'interface_out' => 'eth0', + 'peers' => {} + } + + file opts[:path], YAML.dump(document), overwrite: true +end + +def schema_show(opts = $opts[:parsed]) + $stdout.puts SCHEMA +end + +def schema_check(opts = $opts[:parsed]) + unless opts[:use_schema] + $stderr.puts "The json-schema ruby gem is missing! Please install it if you want to use config schema validation." + exit(-1) + end + + schema = YAML.load(SCHEMA) + + document = !opts[:path].empty? && File.exist?(opts[:path]) ? YAML.load_file(opts[:path]) : {} + + unless JSON::Validator.validate(schema, document) + $stderr.puts "Config schema validation failed!" + exit(-1) + end +end + +def peer_add(opts = $opts[:parsed]) + document = !opts[:path].empty? && File.exist?(opts[:path]) ? YAML.load_file(opts[:path]) : {} + + if document[opts[:dev]].nil? + $stderr.puts "Not found: #{opts[:dev]}" + exit(-1) + end + + if (subnet = document.dig(opts[:dev], 'peer_subnet')).nil? + $stderr.puts "Missing peer subnet: #{opts[:dev]}" + exit(-1) + end.then do + subnet = IPAddr.new(subnet) + end + + peers = (document[opts[:dev]]['peers'] ||= {}) + + opts[:peers].each do |peer| + unless peers[peer].nil? + $stderr.puts "Already added: #{opts[:dev]} #{peer}" + next + end + + # Collect all peer addresses for the subnet. + addrs = peers.each_with_object([]) do |(_, v), acc| + addr = IPAddr.new(v['address'].split(%[/])[0]) + acc << addr.to_i if subnet.include?(addr) + end.sort + + # Find min/max addresses in the subnet. + min = subnet.to_range.first.to_i + max = subnet.to_range.last.to_i + + # Prepare address lists to be zipped (shifted by 1). + lhs = [min + 1] + addrs # skips the first usable address (for example 10.0.0.1) + rhs = addrs + [max] + + # Extract ranges containing only unused addresses. + unused = lhs.zip(rhs) + .select { |(a, b)| b - a > 1 } # discard ranges with less than 2 addresses + .map { |(a, b)| ((a + 1)..(b - 1)) } # create ranges from tuples + + # Find the lowest available address. + lowest = IPAddr.new(unused.first.min, subnet.family) + + peers[peer] = { + 'address' => "#{lowest.to_s}/#{subnet.prefix}", + 'preshared_key' => bash('wg genpsk', chomp: true), + 'allowed_ips' => %w[0.0.0.0/0] + } + + if !opts[:pubkey].nil? && !opts[:pubkey].empty? + peers[peer]['public_key'] = opts[:pubkey] + else + peers[peer]['private_key'] = bash('wg genkey', chomp: true) + end + end + + file opts[:path], YAML.dump(document), overwrite: true +end + +def peer_del(opts = $opts[:parsed]) + document = !opts[:path].empty? && File.exist?(opts[:path]) ? YAML.load_file(opts[:path]) : {} + + if document[opts[:dev]].nil? + $stderr.puts "Not found: #{opts[:dev]}" + exit(-1) + end + + peers = (document[opts[:dev]]['peers'] ||= {}) + + opts[:peers].each do |peer| + if peers[peer].nil? + $stderr.puts "Not found: #{opts[:dev]} #{peer}" + next + end + peers.delete peer + end + + file opts[:path], YAML.dump(document), overwrite: true +end + +def peer_get(opts = $opts[:parsed]) + document = !opts[:path].empty? && File.exist?(opts[:path]) ? YAML.load_file(opts[:path]) : {} + + if document[opts[:dev]].nil? + $stderr.puts "Not found: #{opts[:dev]}" + exit(-1) + end + + peers = (document[opts[:dev]]['peers'] ||= {}) + + if peers[opts[:peer]].nil? + $stderr.puts "Not found: #{opts[:dev]} #{opts[:peer]}" + exit(-1) + end + + if (private_key = document.dig(opts[:dev], 'private_key')).nil? + $stderr.puts "Invalid server key: #{opts[:dev]}" + exit(-1) + end + + $stdout.puts <<~PEER + [Interface] + Address = #{peers.dig(opts[:peer], 'address')} + ListenPort = #{document.dig(opts[:dev], 'server_port').to_s} + PrivateKey = #{peers.dig(opts[:peer], 'private_key') || '>>> MISSING! <<<'} + + [Peer] + Endpoint = #{document.dig(opts[:dev], 'endpoint')} + PublicKey = #{bash("wg pubkey <<< '#{private_key}'", chomp: true)} + PresharedKey = #{peers.dig(opts[:peer], 'preshared_key')} + AllowedIPs = #{peers.dig(opts[:peer], 'allowed_ips').join(%[,])} + PEER +end + +if caller.empty? + method($opts[:exec].call).call +end From 98bae06702db1e67e48656d42ee97d0b542eadb2 Mon Sep 17 00:00:00 2001 From: "Ruben S. Montero" Date: Mon, 6 May 2024 00:31:56 +0200 Subject: [PATCH 2/6] F #68: Generate configuration files from configure action --- appliances/VRouter/WireGuard/main2.rb | 258 ++++++++++++++++++++++++++ appliances/VRouter/vrouter.rb | 16 ++ 2 files changed, 274 insertions(+) create mode 100644 appliances/VRouter/WireGuard/main2.rb diff --git a/appliances/VRouter/WireGuard/main2.rb b/appliances/VRouter/WireGuard/main2.rb new file mode 100644 index 00000000..e5bfa9db --- /dev/null +++ b/appliances/VRouter/WireGuard/main2.rb @@ -0,0 +1,258 @@ +# 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] + + pp @addrs_in + pp @nets_in + pp iin + pp iout + + 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 diff --git a/appliances/VRouter/vrouter.rb b/appliances/VRouter/vrouter.rb index a904d1b8..13b38f51 100644 --- a/appliances/VRouter/vrouter.rb +++ b/appliances/VRouter/vrouter.rb @@ -163,6 +163,7 @@ def parse_interfaces(interfaces, pattern: /^[!]?eth\d+$/) acc[vip] << nic end end + unless (nics = vips[parts[:addr].downcase]).nil? nics.each do |nic| parts[:name] = nic @@ -207,6 +208,21 @@ def nics_to_addrs(nics = detect_nics) end end +def nics_to_subnets(nics = detect_nics) + ENV.each_with_object({}) do |(name, v), acc| + next if v.empty? + case name + when /^ETH(\d+)_IP$/ + next unless nics.include?(nic = "eth#{$1}") + ip = v.split(%[/])[0] + subnet = IPAddr.new("#{ip}/#{infer_pfxlen($1.to_i, v)}") + + acc[nic] ||= [] + acc[nic] << "#{subnet}/#{subnet.prefix}" + end + end +end + def addrs_to_nics(nics = detect_nics) ENV.each_with_object({}) do |(name, v), acc| next if v.empty? From 08626ed108acb86965a3b0b97a58f9f6e1b91f55 Mon Sep 17 00:00:00 2001 From: "Ruben S. Montero" Date: Mon, 6 May 2024 00:50:16 +0200 Subject: [PATCH 3/6] Remove debug lines --- appliances/VRouter/WireGuard/main2.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/appliances/VRouter/WireGuard/main2.rb b/appliances/VRouter/WireGuard/main2.rb index e5bfa9db..3dbd2532 100644 --- a/appliances/VRouter/WireGuard/main2.rb +++ b/appliances/VRouter/WireGuard/main2.rb @@ -122,11 +122,6 @@ def parse_env @nets_in ||= nics_to_subnets([iin]) @net_in ||= @nets_in[iin] - pp @addrs_in - pp @nets_in - pp iin - pp iout - if @net_in.nil? || @net_in[0].empty? || @addr_in.nil? || @addr_in[0].empty? raise "Wrong configuration for private (in) interface: #{iin}" end From 0a1d6bd95abbd862664e8a5cdf0a9e73b2089d97 Mon Sep 17 00:00:00 2001 From: "Ruben S. Montero" Date: Mon, 6 May 2024 19:35:32 +0200 Subject: [PATCH 4/6] 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 From 4d23b66ca7bf634b56e197129304952a6fac2817 Mon Sep 17 00:00:00 2001 From: "Ruben S. Montero" Date: Wed, 8 May 2024 10:43:50 +0200 Subject: [PATCH 5/6] Add WG peer subnet, improve conf management --- appliances/VRouter/WireGuard/main.rb | 171 +++++++++++++++++---------- 1 file changed, 106 insertions(+), 65 deletions(-) diff --git a/appliances/VRouter/WireGuard/main.rb b/appliances/VRouter/WireGuard/main.rb index 2621e6bd..82e05bdc 100644 --- a/appliances/VRouter/WireGuard/main.rb +++ b/appliances/VRouter/WireGuard/main.rb @@ -15,56 +15,65 @@ module WireGuard class Peer @@peers = 0 - def initialize(subnet, ip) - @subnet = IPAddr.new(subnet) + def initialize(conf) + @wgpeer = {} + + #------------------------------------------------------------------- + # Peer index + # Address: Peer IP address in the peer subnet. + # subnet + 0 = peer network address + # subnet + 1 = WG server IP + # subnet + 2 + N = IP for Nth peer + #------------------------------------------------------------------- + @peer = @@peers + @@peers = @@peers + 1 - raise "Peer IP #{ip} not in peer subnet #{subnet}" unless @subnet.include? ip + addr = IPAddr.new(conf[:subnet].to_i + @peer + 2, Socket::AF_INET) - @ip = IPAddr.new(ip) + @wgpeer[:address] = "#{addr}/#{conf[:subnet].prefix}" - @peer = @@peers - @@peers = @@peers + 1 + #------------------------------------------------------------------- + # Keys + #------------------------------------------------------------------- + @wgpeer[:shared] = bash('wg genpsk', chomp: true) + @wgpeer[:private] = bash('wg genkey', chomp: true) + @wgpeer[:public] = bash("wg pubkey <<< '#{private_k}'", chomp: true) - shared_k = bash('wg genpsk', chomp: true) - private_k = bash('wg genkey', chomp: true) - public_k = bash("wg pubkey <<< '#{private_k}'", chomp: true) + @wgpeer[:allowedips] = conf[:allowedips] - @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] - } + #------------------------------------------------------------------- + # Server Information + #------------------------------------------------------------------- + @wgpeer[:server_addr] = conf[:server_addr] + @wgpeer[:server_public] = conf[:server_public] + @wgpeer[:listenport] = conf[:listenport] end - def to_s_client(opts) + def to_s_client <<~PEER [Interface] - Address = #{@wgpeer['address']} - PrivateKey = #{@wgpeer['private_key']} + Address = #{@wgpeer[:address]} + PrivateKey = #{@wgpeer[:private]} [Peer] - Endpoint = #{opts['server_addr']}:#{opts['listen_port']} - PublicKey = #{@wgpeer['public_key']} - PresharedKey = #{@wgpeer['preshared_key']} - AllowedIPs = #{@wgpeer['allowed_ips'].join(%[,])} + Endpoint = #{@wgpeer[:server_addr]}:#{@wgpeer[:listenport]} + PublicKey = #{@wgpeer[:server_public]} + PresharedKey = #{@wgpeer[:shared]} + AllowedIPs = #{@wgpeer[:allowedips]} PEER end def to_s_server <<~PEER [Peer] - PresharedKey = #{@wgpeer['preshared_key']} - PublicKey = #{@wgpeer['public_key']} - AllowedIPs = #{@wgpeer['address'].split(%[/])[0]}/32 + PresharedKey = #{@wgpeer[:shared]} + PublicKey = #{@wgpeer[:public]} + 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}" + def to_template + "ONEAPP_VNF_WG_PEER#{@peer}=#{Base64.strict_encode64(to_s_client)}" end end @@ -100,11 +109,15 @@ def to_template(opts) # 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, '' + # Number of peers, it will generate peer configuration and associated keys + ONEAPP_VNF_WG_PEERS = env :ONEAPP_VNF_WG_PEERS, 5 + + # Subnet used to interconnect WG peers these address should not be part + # of an OpenNebula virtual network + ONEAPP_VNF_WG_SUBNET = env :ONEAPP_VNF_WG_SUBNET, '169.254.33.0/24' + + # WG device name, defaults to wg0 + ONEAPP_VNF_WG_DEVICE = env :ONEAPP_VNF_WG_DEVICE, 'wg0' #Base folder to store WG configuration ETC_DIR = '/etc/wireguard' @@ -117,39 +130,63 @@ def wg_environment raise "Forbidden ONEAPP_VNF_WG_INTERFACE_OUT interface: #{iout}" if mgmt.include?(iout) + conf = {} + #----------------------------------------------------------------------- - # Get IP address information for INTERFACE_IN + # Endpoint: IP address peers will use to connect to the WG + # server + # conf[:server_addr] + # conf[:server_prefix] #----------------------------------------------------------------------- eps = detect_endpoints - raise "Cannot find address information for #{iin}" if eps[iin].nil? + raise "Cannot find address information for #{iout}" if eps[iout].nil? - rc = iin.match /eth(\d+)/i + rc = iout.match /eth(\d+)/i - raise "Wrong format for ONEAPP_VNF_WG_INTERFACE_IN: #{iin}" if rc.nil? + raise "Wrong format for ONEAPP_VNF_WG_INTERFACE_IN: #{iout}" if rc.nil? - addr_in = eps[iin]["ETH#{rc[1]}_EP0"] + addr_in = eps[iout]["ETH#{rc[1]}_EP0"] - raise "Cannot get IP address for #{iin}" if addr_in.nil? || addr_in.empty? + raise "Cannot get IP address for #{iout}" if addr_in.nil? || addr_in.empty? - server_addr, server_prefix = addr_in.split('/') + conf[:server_addr], conf[:server_prefix] = addr_in.split('/') + #----------------------------------------------------------------------- + # AllowedIPs: IP addresses (CIDR) from which traffic is allowed + # and to which traffic is directed. This is the OpenNebula virtual + # network address space. + # conf[:subnet] + #----------------------------------------------------------------------- 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? + conf[:allowedips] = net_in[0] + + #----------------------------------------------------------------------- + # Server keys + # conf[:server_private] + # conf[:server_public] + #----------------------------------------------------------------------- + conf[:server_private] = bash('wg genkey', chomp: true) + conf[:server_public] = bash("wg pubkey <<< '#{conf[:server_private]}'", + chomp: true) + + #----------------------------------------------------------------------- + # Misc. configuration parameters #----------------------------------------------------------------------- - # Return configuration for the WG device + conf[:listenport] = ONEAPP_VNF_WG_LISTEN_PORT + conf[:subnet] = IPAddr.new(ONEAPP_VNF_WG_SUBNET) + conf[:dev] = ONEAPP_VNF_WG_DEVICE + conf[:num_peers] = ONEAPP_VNF_WG_PEERS + #----------------------------------------------------------------------- - { - '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 } - } + # Return configuration + # TODO Support multiple devices + #----------------------------------------------------------------------- + conf end # -------------------------------------------------------------------------- @@ -160,7 +197,7 @@ 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' + #puts bash 'gem install --no-document json-schema' file "#{initdir}/one-wg", <<~SERVICE, mode: 'u=rwx,g=rx,o=' #!/sbin/openrc-run @@ -215,21 +252,25 @@ def execute ids = onegate_vmids conf64 = '' + vm64 = -1 tstamp = 0 ids.each do |vmid| t, c = onegate_conf(vmid) - conf64 = c if (tstamp == 0 || t > tstamp) && c && !c.empty? + if (tstamp == 0 || t > tstamp) && c && !c.empty? + conf64 = c + vm64 = vmid + end end if !conf64.empty? # ------------------------------------------------------------------ # Reuse existing configuration file in virtual router # ------------------------------------------------------------------ - msg :info, '[WireGuard::execute] Using existing configuration' + msg :info, "[WireGuard::execute] Using configuration found in VM #{vm64}" - file "#{ETC_DIR}/#{ONEAPP_VNF_WG_DEVICE}.conf", + file "#{ETC_DIR}/#{conf[:dev]}.conf", Base64.strict_decode64(conf64), mode: 'u=rw,g=r,o=', overwrite: true @@ -241,8 +282,8 @@ def execute # ------------------------------------------------------------------ peers = [] - opts['peers'].each do |ip| - p = Peer.new opts['peer_subnet'], ip + opts[:num_peers].to_i.times do |ip| + p = Peer.new opts peers << p rescue StandardError => e msg :error, e.message @@ -251,15 +292,15 @@ def execute conf = ERB.new(<<~CONF, trim_mode: '-').result(binding) [Interface] - Address = <%= opts['server_addr'] %> - ListenPort = <%= opts['listen_port'] %> - PrivateKey = <%= opts['private_key'] %> + Address = <%= "#{conf[:subnet].succ}/#{conf[:subnet].prefix}" %> + ListenPort = <%= conf[:listenport] %> + PrivateKey = <%= conf[:server_private] %> <% peers.each do |p| %> <%= p.to_s_server %> <% end %> CONF - file "#{ETC_DIR}/#{ONEAPP_VNF_WG_DEVICE}.conf", + file "#{ETC_DIR}/#{conf[:dev]}.conf", conf, mode: 'u=rw,g=r,o=', overwrite: true @@ -270,7 +311,7 @@ def execute info = [] peers.each do |p| - info << p.to_template(opts) + info << p.to_template end info << "ONEAPP_VNF_WG_SERVER=#{Base64.strict_encode64(conf)}" @@ -288,11 +329,11 @@ def execute end end - msg :info, "[WireGuard::execute] bringing up #{ONEAPP_VNF_WG_DEVICE}" + msg :info, "[WireGuard::execute] bringing up #{conf[:dev]}" bash <<~BASH - wg-quick up '#{ONEAPP_VNF_WG_DEVICE}' - echo 1 > '/proc/sys/net/ipv4/conf/#{ONEAPP_VNF_WG_DEVICE}/forwarding' + wg-quick up '#{conf[:dev]}' + echo 1 > '/proc/sys/net/ipv4/conf/#{conf[:dev]}/forwarding' BASH end From bcabe10a83dd82adc44ef4bbe13d34d42f967cbc Mon Sep 17 00:00:00 2001 From: "Ruben S. Montero" Date: Wed, 8 May 2024 10:55:40 +0200 Subject: [PATCH 6/6] Fix var names --- appliances/VRouter/WireGuard/main.rb | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/appliances/VRouter/WireGuard/main.rb b/appliances/VRouter/WireGuard/main.rb index 82e05bdc..05d5eff3 100644 --- a/appliances/VRouter/WireGuard/main.rb +++ b/appliances/VRouter/WireGuard/main.rb @@ -37,7 +37,7 @@ def initialize(conf) #------------------------------------------------------------------- @wgpeer[:shared] = bash('wg genpsk', chomp: true) @wgpeer[:private] = bash('wg genkey', chomp: true) - @wgpeer[:public] = bash("wg pubkey <<< '#{private_k}'", chomp: true) + @wgpeer[:public] = bash("wg pubkey <<< '#{@wgpeer[:private]}'", chomp: true) @wgpeer[:allowedips] = conf[:allowedips] @@ -180,8 +180,11 @@ def wg_environment conf[:listenport] = ONEAPP_VNF_WG_LISTEN_PORT conf[:subnet] = IPAddr.new(ONEAPP_VNF_WG_SUBNET) conf[:dev] = ONEAPP_VNF_WG_DEVICE - conf[:num_peers] = ONEAPP_VNF_WG_PEERS - + conf[:num_peers] = begin + Integer(ONEAPP_VNF_WG_PEERS) + rescue + 5 + end #----------------------------------------------------------------------- # Return configuration # TODO Support multiple devices @@ -270,7 +273,7 @@ def execute # ------------------------------------------------------------------ msg :info, "[WireGuard::execute] Using configuration found in VM #{vm64}" - file "#{ETC_DIR}/#{conf[:dev]}.conf", + file "#{ETC_DIR}/#{opts[:dev]}.conf", Base64.strict_decode64(conf64), mode: 'u=rw,g=r,o=', overwrite: true @@ -292,15 +295,15 @@ def execute conf = ERB.new(<<~CONF, trim_mode: '-').result(binding) [Interface] - Address = <%= "#{conf[:subnet].succ}/#{conf[:subnet].prefix}" %> - ListenPort = <%= conf[:listenport] %> - PrivateKey = <%= conf[:server_private] %> + Address = <%= "#{opts[:subnet].succ}/#{opts[:subnet].prefix}" %> + ListenPort = <%= opts[:listenport] %> + PrivateKey = <%= opts[:server_private] %> <% peers.each do |p| %> <%= p.to_s_server %> <% end %> CONF - file "#{ETC_DIR}/#{conf[:dev]}.conf", + file "#{ETC_DIR}/#{opts[:dev]}.conf", conf, mode: 'u=rw,g=r,o=', overwrite: true @@ -329,11 +332,11 @@ def execute end end - msg :info, "[WireGuard::execute] bringing up #{conf[:dev]}" + msg :info, "[WireGuard::execute] bringing up #{opts[:dev]}" bash <<~BASH - wg-quick up '#{conf[:dev]}' - echo 1 > '/proc/sys/net/ipv4/conf/#{conf[:dev]}/forwarding' + wg-quick up '#{opts[:dev]}' + echo 1 > '/proc/sys/net/ipv4/conf/#{opts[:dev]}/forwarding' BASH end