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 29, 2024
1 parent 58354cc commit d26bd91
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 66 deletions.
13 changes: 6 additions & 7 deletions bin/shell/osh.pl
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ sub main_exit {

my $R = R($retcode eq OVH::Bastion::EXIT_OK ? 'OK' : 'KO_' . uc($comment), msg => $msg);

# always print to STDERR as some plugins (such as scp) won't display STDOUT
$ENV{'FORCE_STDERR'} = 1;
OVH::Bastion::osh_crit($R->msg) if not $R;
OVH::Bastion::json_output($R) if $ENV{'PLUGIN_JSON'};

Expand Down Expand Up @@ -600,8 +602,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 +613,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 +624,13 @@ 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.");
main_exit OVH::Bastion::EXIT_INVALID_REMOTE_HOST, 'invalid_remote_host', $fnret->msg;
}
}
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
101 changes: 62 additions & 39 deletions lib/perl/OVH/Bastion/allowdeny.inc
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,8 @@ sub is_access_way_granted {
}

# then, check IP
# if we want an exact match, it's a stupid strcmp()
if ($exactIpMatch) {
# if we want an exact match, it's a simple strcmp() for IPv4
if ($exactIpMatch && index($allowedIp, ':') == -1) {
next if ($allowedIp ne $wantedIp);

# here, we got a perfect match
Expand All @@ -229,8 +229,8 @@ sub is_access_way_granted {
last; # perfect match, don't search further
}

# check IP in not-exactIpMatch case. if it contains / then it's a prefix
if ($allowedIp =~ m{/}) {
# check IP in not-exactIpMatch case. if it's a netblock or IPv6, use Netmask matching
if (index($allowedIp, '/') != -1 || index($allowedIp, ':') != -1) {

# build slash and test
require Net::Netmask;
Expand All @@ -248,7 +248,7 @@ sub is_access_way_granted {
}
}
else {
# it's a single ip, so a stupid strcmp() does the trick
# it's a single ipv4, so a simple strcmp() does the trick
if ($allowedIp eq $wantedIp) {
osh_debug("... we got a singleip match !");
$forceKey = $localForceKey;
Expand Down 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 => "Can't use '$host', IPv" . $fnret->value->{'version'} ." support disabled by policy");
}

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 Expand Up @@ -681,18 +691,10 @@ sub _is_in_any_net {
return R('ERR_INVALID_PARAMETER', msg => "Parameter 'networks' must be an array");
}

require Net::Netmask;
foreach my $net (@$networks) {
if ($net =~ m{/}) {

# build slash and test
require Net::Netmask;
my $ipCheck = Net::Netmask->new2($net);
return R('OK', value => {matched => $net}) if ($ipCheck && $ipCheck->match($ip));
}
else {
# it's a single ip, so it's a stupid strcmp() does the trick
return R('OK', value => {matched => $net}) if ($net eq $ip);
}
my $Netmask = Net::Netmask->new2($net);
return R('OK', value => {matched => $net}) if ($Netmask && $Netmask->match($ip));
}
return R('KO', msg => "No match found");
}
Expand Down Expand Up @@ -1289,31 +1291,52 @@ sub _get_acl_from_file {
# empty line ?
$line or next;

$line =~ m{^
(
(\S+)@ # $2 == $user
)? # user part, optional
(
(
[0-9.]+ # IPv4
| # or
\[[0-9a-f:.]+\] # IPv6
)
(
/[0-9]+ # prefix size
)? # prefix size, optional
) # ip or netblock part, mandatory ($3)
(
:([0-9]+) # $7 == port
)?
$}x or next;

($user, $ip, $port) = ($2, $3, $7);

# extract custom port if present
if ($line =~ s/:(\d+)$//) {
$fnret = OVH::Bastion::is_valid_port(port => $1);
if (defined $port) {
$fnret = OVH::Bastion::is_valid_port(port => $port);
if (!$fnret) {
osh_debug("skipping line <$line> because port ($1) is invalid");
osh_debug("skipping line <$line> because port ($port) is invalid");
next;
}
$port = $fnret->value;
}

# extract custom user if present
if ($line =~ s/^(\S+)\@//) {
$fnret = OVH::Bastion::is_valid_remote_user(user => $1, allowWildcards => 1);
if (defined $user) {
$fnret = OVH::Bastion::is_valid_remote_user(user => $user, allowWildcards => 1);
if (!$fnret) {
osh_debug("skipping line <$line> because user ($1) is invalid");
osh_debug("skipping line <$line> because user ($user) is invalid");
next;
}
$user = $fnret->value;
}

# extract ip (v4 or v6)
if ($line =~ m{([0-9a-f./:]+)}i) {
$fnret = OVH::Bastion::is_valid_ip(ip => $1, allowPrefixes => 1, fast => 1);
if (defined $ip) {
$fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1, fast => 1);
if (!$fnret) {
osh_debug("skipping line <$line> because IP ($1) is invalid");
osh_debug("skipping line <$line> because IP ($ip) is invalid");
next;
}
$ip = $fnret->value->{'ip'};
Expand Down
9 changes: 5 additions & 4 deletions lib/perl/OVH/Bastion/allowkeeper.inc
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,9 @@ sub access_modify {
}

# build the line we're either adding or looking for (to delete it)
my $machine = OVH::Bastion::machine_display(ip => $ip, port => $port, user => $user)->value;
my $entry = $machine;
my $entry = (index($ip, ':') >= 0 ? "[$ip]" : $ip);
$entry .= ":$port" if $port;
$entry = $user . '@' . $entry if $user;

my $t = localtime(time);
my $fmt = "%Y-%m-%d %H:%M:%S";
Expand All @@ -592,11 +593,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 Expand Up @@ -628,6 +627,7 @@ sub access_modify {
else {
return R('ERR_CANNOT_OPEN_FILE', msg => "Error opening $file: $!");
}
my $machine = OVH::Bastion::machine_display(ip => $ip, port => $port, user => $user)->value;
my $ttlmsg =
$ttl ? (' (expires in ' . OVH::Bastion::duration2human(seconds => $ttl)->value->{'human'} . ')') : '';
$returnmsg = "Access to $machine successfully added$ttlmsg";
Expand All @@ -652,6 +652,7 @@ sub access_modify {
if (open(my $fh_file, '>', $file)) {
print $fh_file $newFile;
close($fh_file);
my $machine = OVH::Bastion::machine_display(ip => $ip, port => $port, user => $user)->value;
$returnmsg = "Access to $machine successfully removed";
}
else {
Expand Down
Loading

0 comments on commit d26bd91

Please sign in to comment.