Skip to content

Commit

Permalink
feat: IPv6 support
Browse files Browse the repository at this point in the history
  • Loading branch information
speed47 committed Dec 18, 2024
1 parent a97db44 commit 8ea66ae
Show file tree
Hide file tree
Showing 11 changed files with 181 additions and 38 deletions.
15 changes: 8 additions & 7 deletions bin/shell/osh.pl
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand All @@ -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{^--}) {
Expand All @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions doc/sphinx/administration/configuration/bastion_conf.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------
Expand Down Expand Up @@ -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
-------

Expand Down
10 changes: 10 additions & 0 deletions etc/bastion/bastion.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 27 additions & 9 deletions lib/perl/OVH/Bastion.pm
Original file line number Diff line number Diff line change
Expand Up @@ -694,21 +694,33 @@ sub is_valid_ip {
}

require Net::IP;
$ip =~ s{^\[|\]$}{}g; # remove IPv6 brackets, if any
my $IpObject = Net::IP->new($ip);

if (not $IpObject) {
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') {
Expand Down Expand Up @@ -1125,6 +1137,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;
Expand Down
38 changes: 24 additions & 14 deletions lib/perl/OVH/Bastion/allowdeny.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -301,34 +301,41 @@ 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) {
return R('ERR_DNS_DISABLED', msg => "DNS resolving is disabled on this bastion");
}

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;
Expand All @@ -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'");
}

Expand Down
2 changes: 0 additions & 2 deletions lib/perl/OVH/Bastion/allowkeeper.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions lib/perl/OVH/Bastion/configuration.inc
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ sub load_configuration {
options => [
qw{
enableSyslog enableGlobalAccessLog enableAccountAccessLog enableGlobalSqlLog enableAccountSqlLog displayLastLogin
interactiveModeByDefault interactiveModeProactiveMFAenabled
interactiveModeByDefault interactiveModeProactiveMFAenabled IPv4Allowed
}
],
},
Expand All @@ -275,7 +275,7 @@ sub load_configuration {
qw{
interactiveModeAllowed readOnlySlaveMode sshClientHasOptionE ingressKeysFromAllowOverride
moshAllowed debug keyboardInteractiveAllowed passwordAllowed telnetAllowed remoteCommandEscapeByDefault
accountExternalValidationDenyOnFailure ingressRequirePIV
accountExternalValidationDenyOnFailure ingressRequirePIV IPv6Allowed
}
],
}
Expand Down
8 changes: 7 additions & 1 deletion tests/functional/docker/docker_build_and_run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/docker/target_role.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions tests/functional/tests.d/355-ipv6.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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 'IP ::1 version (IPv6) is not allowed'
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 'IP [::1] version (IPv6) is not allowed'
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_1 $a0 ::1
contain "Connecting..."
contain "$account0.v6[..1].22.ttyrec"

run connect_ipv6_2 $a0 '[0:00:000:0000::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
4 changes: 2 additions & 2 deletions tests/functional/tests.d/395-mfa-scp-sftp-rsync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "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 "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"
Expand Down

0 comments on commit 8ea66ae

Please sign in to comment.