# frozen_string_literal: true require 'json' require 'netaddr' require 'optimist' require 'resolv' require 'rest_client' require 'semantic_logger' require 'time' require_relative 'comment' module OpenVPNConfigurator class RouteGatherer include SemanticLogger::Loggable AWS_IP_RANGES_URL = 'https://ip-ranges.amazonaws.com/ip-ranges.json'.freeze def extend_template(options) template = read_file options[:input_path] old_output = read_file options[:output_path], nonexistent_behavior: :empty_string routes = gather_routes options reduced = reduce_routes routes rendered = render_routes reduced, client: !!options[:client] result = format "%s\n\n\n# Added by OpenVPN Configurator v%s at %s\n%s", template, VERSION, Time.now.iso8601(6), rendered if result != old_output logger.info "Output content changed, rewriting file #{options[:output_path].inspect}" write_file options[:output_path], result change_actions options else logger.info 'Output content unchanged, no actions taken.' end end private def aws_ip_ranges @aws_ip_ranges ||= begin logger.trace "Fetching AWS IP ranges from #{AWS_IP_RANGES_URL.inspect}" data = RestClient.get AWS_IP_RANGES_URL JSON.parse data, symbolize_names: true end end def change_actions(options) options[:restart_systemd].each { |service| restart_systemd service } end # @return [Hash] def gather_routes(options) result = {} options[:route_v4_aws_region].each { |region| result.merge! gather_v4_aws_region(region) } options[:route_v4_dns].each { |name| result.merge! gather_v4_dns(name) } result end def gather_v4_aws_region(region) networks = aws_ip_ranges[:prefixes].select { |p| p[:region] == region } routes = networks.map { |p| NetAddr.parse_net p[:ip_prefix] } { "route-v4-aws-region=#{region}" => routes } end def gather_v4_dns(name) { "route-v4-dns=#{name}" => resolve_v4(name).map { |a| NetAddr.parse_net "#{a}/32" } } end def read_file(path, nonexistent_behavior: :raise) File.read path rescue Errno::ENOENT => e case nonexistent_behavior when :empty_string '' else logger.error "Error reading file #{path.inspect}", e raise end end # rel determines the relationship to another IPv4Net. Returns: # * 1 if this IPv4Net is the supernet of other # * 0 if the two are equal # * -1 if this IPv4Net is a subnet of other # * nil if the networks are unrelated # @return [Hash] def reduce_routes(routes) seen_v4 = [] seen_v6 = [] reduction = false result = {} routes.each_pair do |name, entries| result[name] = [] entries.each do |entry| seen = case entry when NetAddr::IPv4Net seen_v4 when NetAddr::IPv6Net seen_v6 else nil end if seen covered = seen.find { |s| [1, 0].include? s.rel(entry) } if covered reduction = true result[name].push Comment.new("Route #{entry} already covered by route #{covered}") else seen.push entry result[name].push entry end else result[name].push entry end end end if reduction reduce_routes result else result end end # For tests, this method is stubbable # @return [Array] def resolve_v4(name) logger.trace "Performing DNS lookup for hostname #{name.inspect}" Resolv.getaddresses(name).map { |a| NetAddr.parse_ip a }.select { |a| a.is_a? NetAddr::IPv4 } end def restart_systemd(service_name) logger.info "Restarting systemd service #{service_name.inspect}" system 'systemctl', 'restart', service_name end def write_file(path, contents) File.write path, contents end # @param routes [Hash>] { "Configuration Source Name" => [NetAddr::IPv4Net, NetAddr::IPv6Net] } def render_routes(routes, client: false) indent = "\t\t\t" directive_indent = client ? indent : ' ' prefix = client ? '' : "push#{indent}\"" postfix = client ? '' : '"' result = [] routes.keys.sort.each do |source| result << "##{source}" routes[source].sort_by(&:to_s).each do |route| directive = case route when NetAddr::IPv4Net format 'route%s%s', directive_indent, route.extended when NetAddr::IPv6Net format 'route-ipv6%s%s', directive_indent, route.to_s when Comment format '# %s', route.to_s else raise "Only supporting IPv4 and IPv6 networks presently, got #{route.inspect} instead" end result << format("%s%s%s", prefix, directive, postfix) end result << '' end result.join "\n" end end end