From 2c6838fc8d43d1186f4ef9766267cf1991a6b7c3 Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Tue, 26 Mar 2024 05:12:52 +0100 Subject: [PATCH] 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