From 223713c7e80cf63f1e7e001cb9f1ba7b307e4f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lesimple?= Date: Wed, 18 Dec 2024 15:18:49 +0000 Subject: [PATCH] feat: IPv6 support --- bin/shell/osh.pl | 15 ++-- .../configuration/bastion_conf.rst | 24 +++++++ etc/bastion/bastion.conf.dist | 10 +++ lib/perl/OVH/Bastion.pm | 35 ++++++--- lib/perl/OVH/Bastion/allowdeny.inc | 38 ++++++---- lib/perl/OVH/Bastion/allowkeeper.inc | 2 - lib/perl/OVH/Bastion/configuration.inc | 4 +- .../docker/docker_build_and_run_tests.sh | 8 ++- tests/functional/docker/target_role.sh | 2 +- tests/functional/tests.d/355-ipv6.sh | 72 +++++++++++++++++++ .../tests.d/395-mfa-scp-sftp-rsync.sh | 4 +- 11 files changed, 176 insertions(+), 38 deletions(-) create mode 100644 tests/functional/tests.d/355-ipv6.sh diff --git a/bin/shell/osh.pl b/bin/shell/osh.pl index c35a1c27c..34f5764bb 100755 --- a/bin/shell/osh.pl +++ b/bin/shell/osh.pl @@ -600,8 +600,7 @@ sub main_exit { if ($user && !OVH::Bastion::is_valid_remote_user(user => $user, allowWildcards => ($osh_command ? 1 : 0))) { main_exit OVH::Bastion::EXIT_INVALID_REMOTE_USER, 'invalid_remote_user', "Remote user name '$user' seems invalid"; } -if ($host && $host !~ m{^[a-zA-Z0-9._/:-]+$}) { - +if ($host && $host !~ m{^\[?[a-zA-Z0-9._/:-]+\]?$}) { # can be an IP (v4 or v6), hostname, or prefix (with a /) main_exit OVH::Bastion::EXIT_INVALID_REMOTE_HOST, 'invalid_remote_host', "Remote host name '$host' seems invalid"; } @@ -612,7 +611,6 @@ sub main_exit { # if: avoid loading Net::IP and BigInt if there's no host specified if ($host) { - # probably this "host" is in fact an option, but we didn't parse it because it's an unknown one, # so we call the long_help() for the user, before exiting if ($host =~ m{^--}) { @@ -624,14 +622,17 @@ sub main_exit { $fnret = OVH::Bastion::get_ip(host => $host); } if (!$fnret) { - - # exit error when not osh ... + # exit error when not a plugin call if (!$osh_command) { - main_exit OVH::Bastion::EXIT_HOST_NOT_FOUND, 'host_not_found', "Unable to resolve host '$host' ($fnret)"; + main_exit OVH::Bastion::EXIT_HOST_NOT_FOUND, 'host_not_found', $fnret->msg; } elsif ($host && $host !~ m{^[0-9.:]+/\d+$}) # in some osh plugins, ip/mask is accepted, don't yell. { - osh_warn("I was unable to resolve host '$host'. Something shitty might happen."); + osh_warn($fnret->msg); + osh_warn("Trying to proceed with $osh_command anyway, but things might go wrong."); + if (index($host, ':') >= 0 && !OVH::Bastion::config('IPv6Allowed')->value) { + osh_warn("Note that '$host' looks like an IPv6 but IPv6 support has not been enabled."); + } } } else { diff --git a/doc/sphinx/administration/configuration/bastion_conf.rst b/doc/sphinx/administration/configuration/bastion_conf.rst index 0510f5ba4..bc341c2af 100644 --- a/doc/sphinx/administration/configuration/bastion_conf.rst +++ b/doc/sphinx/administration/configuration/bastion_conf.rst @@ -51,6 +51,8 @@ Those options can set a few global network policies to be applied bastion-wide. - `allowedNetworks`_ - `forbiddenNetworks`_ - `ingressToEgressRules`_ +- `IPv4Allowed`_ +- `IPv6Allowed`_ Logging options --------------- @@ -426,6 +428,28 @@ For example, take the following configuration: In any case, all the personal and group accesses still apply in addition to these global rules. +.. _IPv4Allowed: + +IPv4Allowed +*********** + +:Type: ``boolean`` + +:Default: ``true`` + +If enabled, IPv4 egress connections will be allowed, and IPv4 will be enabled in the DNS queries. This is the default. Do NOT disable this unless you enable IPv6Allowed, if you need to have an IPv6-only bastion. + +.. _IPv6Allowed: + +IPv6Allowed +*********** + +:Type: ``boolean`` + +:Default: ``false`` + +If enabled, IPv6 egress connections will be allowed, and IPv6 will be enabled in the DNS queries. By default, only IPv4 is allowed. + Logging ------- diff --git a/etc/bastion/bastion.conf.dist b/etc/bastion/bastion.conf.dist index 7376604ba..ac74a7618 100644 --- a/etc/bastion/bastion.conf.dist +++ b/etc/bastion/bastion.conf.dist @@ -173,6 +173,16 @@ # DEFAULT: [] "ingressToEgressRules": [], # +# IPv4Allowed (boolean) +# DESC: If enabled, IPv4 egress connections will be allowed, and IPv4 will be enabled in the DNS queries. This is the default. Do NOT disable this unless you enable IPv6Allowed, if you need to have an IPv6-only bastion. +# DEFAULT: true +"IPv4Allowed": true, +# +# IPv6Allowed (boolean) +# DESC: If enabled, IPv6 egress connections will be allowed, and IPv6 will be enabled in the DNS queries. By default, only IPv4 is allowed. +# DEFAULT: false +"IPv6Allowed": false, +# ########### # > Logging # >> Options to customize how logs should be produced. diff --git a/lib/perl/OVH/Bastion.pm b/lib/perl/OVH/Bastion.pm index c02f57e03..150080a8c 100644 --- a/lib/perl/OVH/Bastion.pm +++ b/lib/perl/OVH/Bastion.pm @@ -700,15 +700,26 @@ sub is_valid_ip { return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)"); } - my $shortip = $IpObject->prefix; - - # if /32 or /128, omit the /prefixlen on $shortip - my $type = 'prefix'; - if ( ($IpObject->version == 4 and $IpObject->prefixlen == 32) - or ($IpObject->version == 6 and $IpObject->prefixlen == 128)) - { - $shortip =~ s'/\d+$''; - $type = 'single'; + my ($shortip, $type); + if ($IpObject->version == 4) { + if ($IpObject->prefixlen == 32) { + $shortip = $IpObject->ip; + $type = 'single'; + } + else { + $shortip = $IpObject->prefix; + $type = 'prefix'; + } + } + elsif ($IpObject->version == 6) { + if ($IpObject->prefixlen == 128) { + $shortip = $IpObject->short; + $type = 'single'; + } + else { + $shortip = $IpObject->short . '/' . $IpObject->prefixlen; + $type = 'prefix'; + } } if (not $allowPrefixes and $type eq 'prefix') { @@ -1125,6 +1136,12 @@ sub build_ttyrec_cmdline_part1of2 { return R('ERR_MISSING_PARAMETER', msg => "Missing ip parameter"); } + # if ip is an IPv6, replace :'s by .'s and surround by v6[]'s (which is allowed on all filesystems) + if ($params{'ip'} && index($params{'ip'}, ':') >= 0) { + $params{'ip'} =~ tr/:/./; + $params{'ip'} = 'v6[' . $params{'ip'} . ']'; + } + # build ttyrec filename format my $bastionName = OVH::Bastion::config('bastionName')->value; my $ttyrecFilenameFormat = OVH::Bastion::config('ttyrecFilenameFormat')->value; diff --git a/lib/perl/OVH/Bastion/allowdeny.inc b/lib/perl/OVH/Bastion/allowdeny.inc index 70ab3cd22..cbcfaa31b 100644 --- a/lib/perl/OVH/Bastion/allowdeny.inc +++ b/lib/perl/OVH/Bastion/allowdeny.inc @@ -280,19 +280,19 @@ sub is_access_way_granted { sub get_ip { my %params = @_; my $host = $params{'host'}; - my $v4 = $params{'v4'}; # allow ipv4 ? - my $v6 = $params{'v6'}; # allow ipv6 ? + my $v4 = $params{'v4'} // OVH::Bastion::config('IPv4Allowed')->value; + my $v6 = $params{'v6'} // OVH::Bastion::config('IPv6Allowed')->value; if (!$host) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'host'"); } - # by default, only v4 unless specified otherwise - $v4 = 1 if not defined $v4; - $v6 = 0 if not defined $v6; + # if v4 or v6 are disabled in config, force-disable them here too + $v4 = 0 if !OVH::Bastion::config('IPv4Allowed')->value; + $v6 = 0 if !OVH::Bastion::config('IPv6Allowed')->value; # try to see if it's already an IP - osh_debug("checking if '$host' is already an IP"); + osh_debug("checking if '$host' is already an IP (v4=$v4 v6=$v6)"); my $fnret = OVH::Bastion::is_valid_ip(ip => $host, allowPrefixes => 0); if ($fnret) { osh_debug("Host $host is already an IP"); @@ -301,7 +301,7 @@ sub get_ip { { return R('OK', value => {ip => $fnret->value->{'ip'}, iplist => [$fnret->value->{'ip'}]}); } - return R('ERR_INVALID_IP', msg => "IP $host version is not allowed"); + return R('ERR_INVALID_IP', msg => "IP $host version (IPv" . $fnret->value->{'version'} . ") is not allowed"); } if (OVH::Bastion::config('dnsSupportLevel')->value < 1) { @@ -309,26 +309,33 @@ sub get_ip { } osh_debug("Trying to resolve '$host' because is_valid_ip() says it's not an IP"); - my ($err, @res); - eval { + my ($err, @res) = eval { # dns resolving, v4/v6 compatible # can croak - ($err, @res) = getaddrinfo($host, undef, {socktype => SOCK_STREAM}); + getaddrinfo($host, undef, {socktype => SOCK_STREAM}); }; - return R('ERR_HOST_NOT_FOUND', msg => $@) if $@; - return R('ERR_HOST_NOT_FOUND', msg => $err) if $err; + return R('ERR_HOST_NOT_FOUND', msg => "Unable to resolve '$host' ($@)") if $@; + return R('ERR_HOST_NOT_FOUND', msg => "Unable to resolve '$host' ($err)") if $err; my %iplist; my $lastip; + my $skippedcount = 0; foreach my $item (@res) { if ($item->{'family'} == AF_INET) { - next if not $v4; + if (!$v4) { + $skippedcount++; + next; + } } elsif ($item->{'family'} == AF_INET6) { - next if not $v6; + if (!$v6) { + $skippedcount++; + next; + } } else { # unknown weird family ? + $skippedcount++; next; } my $as_text; @@ -347,6 +354,9 @@ sub get_ip { } # %iplist empty, not resolved (?) + return R('ERR_HOST_NOT_FOUND', + msg => "Unable to resolve '$host' (some IPv4 and/or IPv6 were skipped due to policy)") + if $skippedcount; return R('ERR_HOST_NOT_FOUND', msg => "Unable to resolve '$host'"); } diff --git a/lib/perl/OVH/Bastion/allowkeeper.inc b/lib/perl/OVH/Bastion/allowkeeper.inc index 8a3281f19..1c24bb072 100644 --- a/lib/perl/OVH/Bastion/allowkeeper.inc +++ b/lib/perl/OVH/Bastion/allowkeeper.inc @@ -592,11 +592,9 @@ sub access_modify { # if we're adding it, append other parameters as comments if ($action eq 'add') { - $entry .= " $entryComment"; if ($forceKey) { - # hash is case-sensitive only for new SHA256 format $forceKey = lc($forceKey) if ($forceKey !~ /^sha256:/i); $entry .= " # FORCEKEY=" . $forceKey; diff --git a/lib/perl/OVH/Bastion/configuration.inc b/lib/perl/OVH/Bastion/configuration.inc index 137e75bdb..0009f9739 100644 --- a/lib/perl/OVH/Bastion/configuration.inc +++ b/lib/perl/OVH/Bastion/configuration.inc @@ -265,7 +265,7 @@ sub load_configuration { options => [ qw{ enableSyslog enableGlobalAccessLog enableAccountAccessLog enableGlobalSqlLog enableAccountSqlLog displayLastLogin - interactiveModeByDefault interactiveModeProactiveMFAenabled + interactiveModeByDefault interactiveModeProactiveMFAenabled IPv4Allowed } ], }, @@ -275,7 +275,7 @@ sub load_configuration { qw{ interactiveModeAllowed readOnlySlaveMode sshClientHasOptionE ingressKeysFromAllowOverride moshAllowed debug keyboardInteractiveAllowed passwordAllowed telnetAllowed remoteCommandEscapeByDefault - accountExternalValidationDenyOnFailure ingressRequirePIV + accountExternalValidationDenyOnFailure ingressRequirePIV IPv6Allowed } ], } diff --git a/tests/functional/docker/docker_build_and_run_tests.sh b/tests/functional/docker/docker_build_and_run_tests.sh index 8602d7a8e..815ed83d9 100755 --- a/tests/functional/docker/docker_build_and_run_tests.sh +++ b/tests/functional/docker/docker_build_and_run_tests.sh @@ -123,7 +123,13 @@ docker rm -f "bastion_${target}_tester" 2>/dev/null || true if docker inspect "bastion-$target" >/dev/null 2>&1; then docker network rm "bastion-$target" >/dev/null fi -docker network create "bastion-$target" >/dev/null + +# trying with IPv6 +if ! docker network create --ipv6 --subnet fd42:cafe:efac:"$(printf "%x" $RANDOM)"::/64 "bastion-$target" >/dev/null; then + # didn't work... retry without IPv6 + echo "... IPv6 is not enabled in docker daemon, falling back to IPv4-only network" + docker network create "bastion-$target" >/dev/null +fi # run target but force entrypoint to test one, and add some keys in env (will be shared with tester) echo "Starting target instance" diff --git a/tests/functional/docker/target_role.sh b/tests/functional/docker/target_role.sh index bf4be3438..c1daf9f1b 100755 --- a/tests/functional/docker/target_role.sh +++ b/tests/functional/docker/target_role.sh @@ -125,7 +125,7 @@ if [ "$OS_FAMILY" = Linux ] ; then elif [ "$OS_FAMILY" = OpenBSD ] || [ "$OS_FAMILY" = FreeBSD ] || [ "$OS_FAMILY" = NetBSD ] ; then # setup some 127.0.0.x IPs (needed for our tests) - # this automatically works under Linux on lo + # this is not required under Linux where all IPs of 127.0.0.0/8 implicitely work nic=$(ifconfig | perl -ne 'm{^([a-z._0-9]+): flags}i and $nic=$1; m{inet 127\.0\.0\.1} and print $nic and exit') : "${nic:=lo0}" i=2 diff --git a/tests/functional/tests.d/355-ipv6.sh b/tests/functional/tests.d/355-ipv6.sh new file mode 100644 index 000000000..ad5fbc687 --- /dev/null +++ b/tests/functional/tests.d/355-ipv6.sh @@ -0,0 +1,72 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2086,SC2016,SC2046 +# below: convoluted way that forces shellcheck to source our caller +# shellcheck source=tests/functional/launch_tests_on_instance.sh +. "$(dirname "${BASH_SOURCE[0]}")"/dummy + +testsuite_ipv6() +{ + # create account1 + success accountCreate $a0 --osh accountCreate --always-active --account $account1 --uid $uid1 --public-key "\"$(cat $account1key1file.pub)\"" + json .error_code OK .command accountCreate .value null + + plgfail use_ipv6_notenabled_1 $a0 --osh selfAddPersonalAccess --host ::1 --force --user-any --port-any + contain 'Unable to resolve host' + contain 'looks like an IPv6' + json .command selfAddPersonalAccess .error_code ERR_MISSING_PARAMETER + + plgfail use_ipv6_notenabled_2 $a0 --osh selfAddPersonalAccess --host '[::1]' --force --user-any --port-any + contain 'Unable to resolve host' + contain 'looks like an IPv6' + json .command selfAddPersonalAccess .error_code ERR_MISSING_PARAMETER + + # now enable IPv6 + configchg 's=^\\\\x22IPv6Allowed\\\\x22.+=\\\\x22IPv6Allowed\\\\x22:true,=' + + success add_access_ipv6 $a0 --osh selfAddPersonalAccess --host '::1' --force --user-any --port-any + nocontain "already" + contain "Forcing add as asked" + json .command selfAddPersonalAccess .error_code OK .value.ip ::1 .value.port null .value.user null + + success add_access_ipv6_dupe $a0 --osh selfAddPersonalAccess --host '::1' --force --user-any --port-any + contain "already" + json .command selfAddPersonalAccess .error_code OK_NO_CHANGE + + success add_access_ipv6_multiformat $a0 --osh selfAddPersonalAccess --host 'fe80:cafe::000f:ff' --force --user-any --port-any + nocontain "already" + json .command selfAddPersonalAccess .error_code OK .value.ip fe80:cafe::f:ff .value.port null .value.user null + + success add_access_ipv6_multiformat_dupe1 $a0 --osh selfAddPersonalAccess --host 'fe80:cafe:0000:0000:0000:0000:000f:00ff' --force --user-any --port-any + contain "already" + json .command selfAddPersonalAccess .error_code OK_NO_CHANGE + + success add_access_ipv6_multiformat_dupe2 $a0 --osh selfAddPersonalAccess --host 'fe80:cafe:00::0:f:ff' --force --user-any --port-any + contain "already" + json .command selfAddPersonalAccess .error_code OK_NO_CHANGE + + success self_listaccesses $a0 --osh selfListAccesses + json .command selfListAccesses .error_code OK + json --splitsort '[.value[]|select(.type == "personal").acl[]|.ip]' '::1 fe80:cafe::f:ff' + + run connect_ipv6 $a0 ::1 + contain "Connecting..." + contain "$account0.v6[..1].22.ttyrec" + + success self_delaccess $a0 --osh selfDelPersonalAccess --host 'fe80:cafe:0:00:0::f:ff' --port '*' --user '*' + json .command selfDelPersonalAccess .error_code OK + + success self_delaccess_dupe $a0 --osh selfDelPersonalAccess --host 'fe80:cafe:00:0:00::f:ff' --port '*' --user '*' + json .command selfDelPersonalAccess .error_code OK_NO_CHANGE + + success self_listaccesses_2 $a0 --osh selfListAccesses + json .command selfListAccesses .error_code OK + json --splitsort '[.value[]|select(.type == "personal").acl[]|.ip]' '::1' + + # delete account1 + script cleanup $a0 --osh accountDelete --account $account1 "<<< \"Yes, do as I say and delete $account1, kthxbye\"" + retvalshouldbe 0 +} + +testsuite_ipv6 +unset -f testsuite_ipv6 diff --git a/tests/functional/tests.d/395-mfa-scp-sftp-rsync.sh b/tests/functional/tests.d/395-mfa-scp-sftp-rsync.sh index 1ee07625a..2cb73402b 100644 --- a/tests/functional/tests.d/395-mfa-scp-sftp-rsync.sh +++ b/tests/functional/tests.d/395-mfa-scp-sftp-rsync.sh @@ -152,11 +152,11 @@ EOF run invalidhostname_scp_oldhelper scp $scp_options -F $mytmpdir/ssh_config -S /tmp/scphelper -i $account0key1file $shellaccount@_invalid._invalid:uptest /tmp/downloaded retvalshouldbe 1 - contain REGEX "Sorry, couldn't resolve the host you specified|I was unable to resolve host" + contain REGEX "Sorry, couldn't resolve the host you specified|I was unable to resolve host|Unable to resolve host" run invalidhostname_scp_newwrapper /tmp/scpwrapper -i $account0key1file $shellaccount@_invalid._invalid:uptest /tmp/downloaded retvalshouldbe 1 - contain REGEX "Sorry, couldn't resolve the host you specified|I was unable to resolve host" + contain REGEX "Sorry, couldn't resolve the host you specified|I was unable to resolve host|Unable to resolve host" success personal_scp_upload_oldhelper_ok scp $scp_options -F $mytmpdir/ssh_config -S /tmp/scphelper -i $account0key1file /etc/passwd $shellaccount@127.0.0.2:uptest contain "through the bastion to"