#!/usr/bin/perl
# NET::WebAPI v4.2.1  (c) 13.3.2018 by Andreas Ley  (u) 23.6.2026

package NET::WebAPI;

# On BIG-IP 11.6, we would need: use version 0.77; our $VERSION = version->declare('v1.2.3');
our $VERSION = "v4.2.1";
my $api = $VERSION =~ s/^v?(\d+\.)0*(\d+)[_.].*$/$1$2/r;

=head1 NAME

NET::WebAPI - Perl interface to the NetDB WebAPI

=cut

require 5.006;

use strict;
use warnings;
no warnings 'uninitialized';

#use Exporter ’import’;
# Not available in 5.8 on F5
use parent 'Exporter';
our @EXPORT_OK = ('rad','unrad','ip','is_ip','is_cidr','chopp');

# The script itself may use utf-8 encoded identifiers and literals
use utf8;
# Latin-1 codepoints are considered characters
use feature 'unicode_strings';
# Enable UTF-8 encoding for all files (but not already open handles)
use open ':encoding(utf8)';
use strict 'refs';

use Carp;
use File::Temp 'tempfile';
use File::Spec::Functions;
use Config::IniFiles;
use NetAddr::IP ':lower';
use JSON;
#use IO::Socket::SSL 'debug3';
use LWP::UserAgent;
require HTTP::Request::Common;

use Time::HiRes ('gettimeofday','tv_interval');
use Data::Dumper;
$Data::Dumper::Indent = 1;
$Data::Dumper::Terse = 1;
$Data::Dumper::Sortkeys = 1;
#carp Data::Dumper->Dump([\%hash],['*']) if ($opt_debug>0);
#carp Data::Dumper->new([\%hash],['*'])->Indent(0)->Dump if ($opt_debug>0);

my %debug;

=head1 SYNOPSIS

  use NET::WebAPI;

  my $webapi = NET::WebAPI->new( $keyfile, $certfile );
  # or
  my $webapi = NET::WebAPI->new( { key => $keyfile, cert => $certfile } );

  $webapi->create_a($fqdn,$ip);

=head1 DESCRIPTION

=cut

################################################################################
################################################################################
################################################################################
######
######      WebAPI
######
################################################################################
################################################################################
################################################################################

=head2 WebAPI

=over

=item $webapi = NET::WebAPI->new( { token => $token [, base_url => $base_url] } );

=item $webapi = NET::WebAPI->new( { key => $keyfile, cert => $certfile } );

=item $webapi = NET::WebAPI->new( $pemfile );

=item $webapi = NET::WebAPI->new( $keyfile, $certfile );

=item $webapi = NET::WebAPI->new( $keyfile, $certfile, $url );

=cut

sub new {
	my $class = shift;

	my (%webapi,%ssl_opts);

	if (ref($_[0])) {
		my $options = shift;
		$webapi{'endpoint'} = $options->{'endpoint'} if (defined($options->{'endpoint'}));
		$webapi{'token'} = $options->{'token'} if (defined($options->{'token'}));
		$ssl_opts{'SSL_key_file'} = $options->{'key'} if (defined($options->{'key'}));
		$ssl_opts{'SSL_cert_file'} = $options->{'cert'} if (defined($options->{'cert'}));
		$webapi{'base_url'} = $options->{'base_url'} if (defined($options->{'base_url'}));
		$webapi{'base_url'} = 'api.netdb-'.$options->{'url'}.'.scc.kit.edu' if (defined($options->{'url'}) && $options->{'url'} !~ /:\/\//);
		$webapi{'url'} = $options->{'url'} if (defined($options->{'url'}) && $options->{'url'} =~ /:\/\//);
		%debug = %{$options->{'debug'}} if (defined($options->{'debug'}));
#		$IO::Socket::SSL::DEBUG = $debug{'ssl'} if (defined($debug{'ssl'}));
	}

	$ssl_opts{'SSL_key_file'} = shift if (@_);
	$ssl_opts{'SSL_cert_file'} = shift if (@_);
	$webapi{'url'} = shift if (@_);

	$webapi{'base_url'} = 'api.netdb.scc.kit.edu' unless (defined($webapi{'base_url'}));
	$webapi{'url'} = 'https://'.$webapi{'base_url'}.'/'.$api unless (defined($webapi{'url'}));

	if (defined($webapi{'token'})) {
		delete $ssl_opts{'SSL_key_file'};
		delete $ssl_opts{'SSL_cert_file'};
	}
	$ssl_opts{'verify_hostname'} = 1;

	$webapi{'ua'} = LWP::UserAgent->new(
		'ssl_opts' => \%ssl_opts,
		'keep_alive' => 1,
		'agent' => $class.'/'.$VERSION=~s/^v//r);

	$webapi{'ua'}->default_header('Authorization'=>'Bearer '.$webapi{'token'}) if (defined($webapi{'token'}));

	return(bless(\%webapi,$class));
}

################################################################################

=item $webapi->read_config( $file )

Read config file and returns a reference to a hash suitable for new().
If no file is given, defaults to ~/.config/netdb_client.ini

=cut

sub read_config
{
	&debug(__FILE__,__LINE__,'+ &read_config'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'read_config'}>0);
	my ($class,$file) = @_;

	$file = catfile($ENV{'HOME'},'.config','netdb_client.ini') unless (defined($file));
	# Handling the CVE-2026-11527 fix in libconfig-inifiles-perl 3.000003-2+deb12u1
	my $cfg = Config::IniFiles->new('-file'=>($file eq '-'?*STDIN:$file));
	my $endpoint = $cfg->val('DEFAULT','endpoint');
	my $base_url = $cfg->val($endpoint,'base_url');
	my $token = $cfg->val($endpoint,'token');

	return({'endpoint'=>$endpoint,'base_url'=>$base_url,'token'=>$token});
}

################################################################################

=item $webapi->write_config( $file )

Write current configuration to a config file suitable for read_config().
If no file is given, a temporary file is created and the filename is returned.

=cut

sub write_config
{
	&debug(__FILE__,__LINE__,'+ &write_config'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'write_config'}>0);
	my ($self,$file) = @_;

	my $fh;
	if (defined($file)) {
		open($fh,'>',$file) or return;
	}
	else {
		($fh,$file) = tempfile('netdb_client.XXXXXXXXXX','TMPDIR'=>1,'UNLINK'=>1);
	}

	my $cfg = Config::IniFiles->new;
	my $endpoint = $self->{'endpoint'} // 'unknown';
	$cfg->newval('DEFAULT','endpoint',$endpoint);
	$cfg->newval($endpoint,'base_url',$self->{'base_url'});
	$cfg->newval($endpoint,'version',$api);
	$cfg->newval($endpoint,'token',$self->{'token'});
	$cfg->OutputConfigToFileHandle($fh);

	return($file);
}

################################################################################

=item $webapi->request( $function, key => $value, ... )

=item $webapi->request( $function, \%json )

You normally don't make requests directly but utilize one of the subsystem
specific methods (see below).

For the rare case where there is no utility function yet, you may provide
key/value pairs or a reference to a data structure that will be converted
to a JSON string.

No error handling is currently implemented - if the request fails, the
routine croaks, dumping the header and body of the failed API request.
Up to now, this is left to human parsing.

=cut

sub request
{
	&debug(__FILE__,__LINE__,'+ &request'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'request'}>0);
	my ($self,$function,@param) = @_;

	my $url = $self->{'url'}.'/dns/'.$function;

	sub pairs { my @retval; push(@retval,shift(@_).'='.shift(@_)) while (@_); return (@retval); }

	&debug(__FILE__,__LINE__,'curl'.
		" --url '".$url."'".
		(defined($self->{'token'})?" --header 'Authorization: Bearer ".$self->{'token'}."'":(defined($self->{'ua'}{'ssl_opts'}{'SSL_key_file'})?' --key '.$self->{'ua'}{'ssl_opts'}{'SSL_key_file'}:'').(defined($self->{'ua'}{'ssl_opts'}{'SSL_cert_file'})?' --cert '.$self->{'ua'}{'ssl_opts'}{'SSL_cert_file'}:'')).
		(@param==1?" --header 'Content-Type: application/json'":'').
		(@param?@param>1?join('',map(" -d '$_'",&pairs(@param))):" -d '".encode_json($param[0])."'":'')
		) if ($debug{'curl'}>0);

	&debug(__FILE__,__LINE__,'+ &request: Request = '.encode_json($param[0])) if ($debug{'json'}>0 || $debug{'request'}>2);
	&debug(__FILE__,__LINE__,'=> '.$url) if ($debug{'net'}>0);
	&debug(__FILE__,__LINE__,'   '.encode_json($param[0])) if ($debug{'net'}>0);
	&debug(__FILE__,__LINE__,'+ &request: $ua->post'.Data::Dumper->new([[$url,@param>1?\@param:('Content'=>encode_json($param[0]),'Content-Type'=>'application/json')]],['*'])->Indent(0)->Dump) if ($debug{'request'}>3);

	my $start_time = [gettimeofday] if ($debug{'timing'}>0);
	my $res = $self->{'ua'}->post($url,@param>1?\@param:('Content'=>encode_json($param[0]),'Content-Type'=>'application/json'));
	&debug(__FILE__,__LINE__,tv_interval($start_time,[gettimeofday])) if ($debug{'timing'}>0);
	&debug(__FILE__,__LINE__,'+ &request: Response = '.$res->decoded_content) if ($debug{'json'}>0 || $debug{'request'}>2);
	# Unfortunatly, decoded_content only decodes charset on text/*
	&debug(__FILE__,__LINE__,'<= '.Encode::decode_utf8($res->decoded_content)) if ($debug{'net'}>0);
	&debug(__FILE__,__LINE__,'+ &request: $res->decoded_content = '.Data::Dumper->new([$res->decoded_content],['*'])->Indent(0)->Dump) if ($debug{'request'}>4);

	my $json = decode_json($res->decoded_content) if ($res->content_type eq 'application/json');
	croak $res->status_line."\n".join('',map($_.': '.$res->header($_)."\n",$res->header_field_names))."\n".(defined($json)?JSON->new->pretty(1)->encode($json):$res->decoded_content)."\n" unless ($res->is_success);

	&debug(__FILE__,__LINE__,'+ &request = '.Data::Dumper->new([$json],['*'])->Indent(0)->Dump) if ($debug{'return'}>2 || $debug{'request'}>1);
	return($json);
}

################################################################################

=item $webapi->transaction( \@json )

=item $webapi->transaction(\%options, \@json )

=item $webapi->transaction(\%options, \%json, ...)

You normally don't start transactions directly but utilize one of the subsystem
specific methods (see below).

For the rare case where there is no utility function yet, you may provide
references to data structures that will be converted to JSON strings.

The first parameter may optionally be a reference to a hash of options which
may list a boolean for 'dict_mode' to request, well, dict mode.

No error handling is currently implemented - if the request fails, the
routine croaks, dumping the header and body of the failed API request.
Up to now, this is left to human parsing.

=cut

sub transaction
{
	&debug(__FILE__,__LINE__,'+ &transaction'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'transaction'}>0);
	my $self = shift;

	my $url = $self->{'url'}.'/wapi/transaction/execute';
	if (ref($_[0]) eq 'HASH') {
		my $options = shift;
		$url .= '?dict_mode=true' if (defined($options->{'dict_mode'}) && $options->{'dict_mode'});
	}

	my $transaction = encode_json(@_ > 1 || ref($_[0]) ne 'ARRAY' ? \@_ : $_[0]);

	#&debug(__FILE__,__LINE__,'curl'." -d '".$transaction."' --header 'Content-Type: application/json'".' --key '.$self->{'ua'}{'ssl_opts'}{'SSL_key_file'}.' --cert '.$self->{'ua'}{'ssl_opts'}{'SSL_cert_file'}." '".$url."'") if ($debug{'curl'}>0);

	&debug(__FILE__,__LINE__,'curl'.
		" --url '".$url."'".
		(defined($self->{'token'})?" --header 'Authorization: Bearer ".$self->{'token'}."'":(defined($self->{'ua'}{'ssl_opts'}{'SSL_key_file'})?' --key '.$self->{'ua'}{'ssl_opts'}{'SSL_key_file'}:'').(defined($self->{'ua'}{'ssl_opts'}{'SSL_cert_file'})?' --cert '.$self->{'ua'}{'ssl_opts'}{'SSL_cert_file'}:'')).
		" --header 'Content-Type: application/json'".
		" -d '".$transaction."'"
		) if ($debug{'curl'}>0);

	&debug(__FILE__,__LINE__,'+ &transaction: Request = '.$transaction) if ($debug{'json'}>0 || $debug{'transaction'}>2);
	&debug(__FILE__,__LINE__,'=> '.$url) if ($debug{'net'}>0);
	&debug(__FILE__,__LINE__,'   '.$transaction) if ($debug{'net'}>0);
	&debug(__FILE__,__LINE__,Data::Dumper->Dump([decode_json($transaction)],['*'])) if ($debug{'net'}>1);
	&debug(__FILE__,__LINE__,'+ &transaction: $ua->post'.Data::Dumper->new([[$url,('Content'=>$transaction,'Content-Type'=>'application/json')]],['*'])->Indent(0)->Dump) if ($debug{'transaction'}>3);

	my $start_time = [gettimeofday] if ($debug{'timing'}>0);
	my $res = $self->{'ua'}->post($url,'Content'=>$transaction,'Content-Type'=>'application/json');
	&debug(__FILE__,__LINE__,tv_interval($start_time,[gettimeofday])) if ($debug{'timing'}>0);
	&debug(__FILE__,__LINE__,'+ &transaction: Response = '.$res->decoded_content) if ($debug{'json'}>0 || $debug{'transaction'}>2);
	# Unfortunatly, decoded_content only decodes charset on text/*
	&debug(__FILE__,__LINE__,'<= '.Encode::decode_utf8($res->decoded_content)) if ($debug{'net'}>0);
	&debug(__FILE__,__LINE__,'+ &transaction: $res->decoded_content = '.Data::Dumper->new([$res->decoded_content],['*'])->Indent(0)->Dump) if ($debug{'transaction'}>4);

	my $json = decode_json($res->decoded_content) if ($res->content_type eq 'application/json');
	croak $res->status_line."\n".join('',map($_.': '.$res->header($_)."\n",$res->header_field_names))."\n".(defined($json)?JSON->new->pretty(1)->encode($json):$res->decoded_content)."\n" unless ($res->is_success);
	&debug(__FILE__,__LINE__,Data::Dumper->Dump([$json],['*'])) if ($debug{'net'}>1);

	&debug(__FILE__,__LINE__,'+ &transaction = '.Data::Dumper->new([$json],['*'])->Indent(0)->Dump) if ($debug{'return'}>2 || $debug{'transaction'}>1);
	return($json);
}

################################################################################
################################################################################
################################################################################
######
######      DNS
######
################################################################################
################################################################################
################################################################################

=back

=head2 DNS

=cut

################################################################################
#
# &chopp( $fqdn )
#
# Removes the trailing dot
#
sub chopp
{
	my ($fqdn) = @_;

	$fqdn =~ s/\.$//;

	return($fqdn);
}

################################################################################
#
# $webapi->_list_record( $type, $fqdn )
#
# Generic record listing
#
sub _list_record
{
	&debug(__FILE__,__LINE__,'+ &_list_record'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'_list_record'}>0);
	my ($self,$type,$fqdn) = @_;

	my $json = $self->request('record/list',
		{
			'old' => {
				'fqdn_list' => [$fqdn],
				'type_list' => [$type]
			}
		}
	);
	my @retval = map(&chopp($_->{'data'}),@{$json->[0]});

	&debug(__FILE__,__LINE__,'+ &_list_record = '.Data::Dumper->new([\@retval],['*'])->Indent(0)->Dump) if ($debug{'return'}>2 || $debug{'_list_record'}>1);
	return (@retval);
}

################################################################################
#
# $webapi->_create_record( $type, $fqdn, $data )
#
# Generic record creation
#
sub _create_record
{
	&debug(__FILE__,__LINE__,'+ &_create_record'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'_create_record'}>0);
	my ($self,$rr,$fqdn,$type,$data) = @_;

	$self->transaction([
		# Look up FQDN
		{
			'name' => 'dns.fqdn.list',
			'old' => {
				'value_list' => [$fqdn]
			}
		},
		# Create FQDN if not already defined
		{
			'name' => 'dns.fqdn.create',
			'new' => {
				'value' => $fqdn,
				'type' => $type
			},
			'when' => {
				'returns_no_data' => ['0']
			}
		},
		# Update FQDN type if already defined and not the required type
		{
			'name' => 'dns.fqdn.update',
			'old' => {
				'value' => $fqdn
			},
			'new' => {
				'type' => $type
			},
			'when' => {
				'and' => [
					{
						'returns_data' => ['0']
					},
					{
						'compare' => [
							'ne',
							{
								'returned_param_value' => ['0','type']
							},
							$type
						]
					}
				]
			}
		},
		# Create RR for the FQDN
		{
			'name' => 'dns.record.create',
			'new' => {
				'fqdn' => $fqdn,
				'type' => $rr,
				'data' => $data
			}
		}
	]);
}

################################################################################
################################################################################
###
###   FQDN
###
################################################################################
################################################################################

=head3 FQDN

=over

=cut

################################################################################
#
# &_type_fqdn
#
sub _type_fqdn
{
	&debug(__FILE__,__LINE__,'+ &_type_fqdn('.join(',',map("'$_'",@_)).')') if ($debug{'call'}>1 || $debug{'_type_fqdn'}>0);
	#&debug(__FILE__,__LINE__,'+ &_type_fqdn'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'_type_fqdn'}>0);

	my ($meta) = @_;
	&debug(__FILE__,__LINE__,$meta) if ($debug{'_type_fqdn'}>2);

	my $fqdn_type;
	if ($meta) {
		$fqdn_type = 'meta';
	}
	else {
		$fqdn_type = 'domain';
	}

	&debug(__FILE__,__LINE__,'+ &_type_fqdn = '.$fqdn_type) if ($debug{'return'}>1 || $debug{'_type_fqdn'}>1);
	return($fqdn_type);
}

################################################################################

=item $webapi->is_alias( $fqdn )

Checks whether $fqdn is an alias registered with DNSVS.

=cut

sub is_alias
{
	&debug(__FILE__,__LINE__,'+ &is_alias'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'is_alias'}>0);
	my ($self,$fqdn) = @_;

	my $json = $self->request('fqdn/list',
		{
			'old' => {
				'value_list' => [$fqdn],
				'type' => 'alias'
			}
		}
	);

	&debug(__FILE__,__LINE__,'+ &is_alias = '.(@{$json->[0]}>0)) if ($debug{'return'}>1 || $debug{'is_alias'}>1);
	return(@{$json->[0]}>0);
}

################################################################################

=item $webapi->is_domain( $fqdn )

Checks whether $fqdn is a domain registered with DNSVS.

=cut

sub is_domain
{
	&debug(__FILE__,__LINE__,'+ &is_domain'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'is_domain'}>0);
	my ($self,$fqdn) = @_;

	my $json = $self->request('fqdn/list',
		{
			'old' => {
				'value_list' => [$fqdn],
				'type' => &_type_fqdn(0)
			}
		}
	);

	&debug(__FILE__,__LINE__,'+ &is_domain = '.(@{$json->[0]}>0)) if ($debug{'return'}>1 || $debug{'is_domain'}>1);
	return(@{$json->[0]}>0);
}

################################################################################

=item $webapi->is_dnsvs( $fqdn )

Checks whether $fqdn lies within a zone that is provided by DNSVS (i.e. deployed to live nameservers).

=cut

sub is_dnsvs
{
	&debug(__FILE__,__LINE__,'+ &is_dnsvs'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'is_dnsvs'}>0);
	my ($self,$fqdn) = @_;

	my @label = split(/\./,$fqdn);
	my @list = map(join('.',@label[$_..@label]),0..@label);
	$list[$#list] = '.';

	my $json = $self->transaction({'dict_mode'=>1}, [
		{
			'idx' => 'fqdn',
			'name' => 'dns.fqdn.list',
			'old' => {
				'value_list' => \@list,
				},
		},
		{
			'idx' => 'zone',
			'name' => 'dns.zone.list',
			'inner_join_ref' => {
				'fqdn' => 'api_fkey_dns_fqdn_zone',
				},
		},
		{
			'idx' => 'ns_set',
			'name' => 'dnscfg.ns_set.list',
			'inner_join_ref' => {
				'zone' => 'default',
				},
		},
	]);

	my %fqdn = map(($_->{'value'}=>$_),@{$json->{'fqdn'}});
	my %zone = map(($_->{'fqdn'}=>$_),@{$json->{'zone'}});
	my %ns_set = map(($_->{'name'}=>$_),@{$json->{'ns_set'}});

	for my $fqdn (@list) {
		if (defined($fqdn{$fqdn})) {
			my $ns_set_name = $zone{$fqdn{$fqdn}{'zone'}}{'ns_set_name'};
			my $retval = (defined($ns_set_name) && !$ns_set{$ns_set_name}{'zone_data_is_external'});
			&debug(__FILE__,__LINE__,'+ &is_dnsvs = '.$retval) if ($debug{'return'}>1 || $debug{'is_dnsvs'}>1);
			return($retval);
		}
	}
}

################################################################################

=item $webapi->is_nonterminal( $fqdn )

Checks whether $fqdn is a non-terminal.

=cut

sub is_nonterminal
{
	&debug(__FILE__,__LINE__,'+ &is_nonterminal'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'is_nonterminal'}>0);
	my ($self,$fqdn) = @_;

	my $json = $self->request('fqdn/list',
		{
			'old' => {
				'value_list' => [$fqdn],
			}
		}
	);

	&debug(__FILE__,__LINE__,'+ &is_nonterminal: $json = '.Data::Dumper->Dump([\$json],['*'])) if ($debug{'is_nonterminal'}>2);
	my @retval = map($_->{'is_nonterminal'}>0,@{$json->[0]});

	&debug(__FILE__,__LINE__,'+ &is_nonterminal = '.Data::Dumper->new([\@retval],['*'])->Indent(0)->Dump) if ($debug{'return'}>1 || $debug{'is_nonterminal'}>1);
	return (wantarray?@retval:$retval[0]);
}

################################################################################

=item $webapi->fqdn_type( $fqdn )

Returns type of FQDN fqdn.

=cut

sub fqdn_type
{
	&debug(__FILE__,__LINE__,'+ &fqdn_type'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'fqdn_type'}>0);
	my ($self,$fqdn) = @_;

	my $json = $self->request('fqdn/list',
		{
			'old' => {
				'value_list' => [$fqdn]
			}
		}
	);
	&debug(__FILE__,__LINE__,'+ &fqdn_type: $json = '.Data::Dumper->Dump([\$json],['*'])) if ($debug{'fqdn_type'}>2);
	my @retval = map($_->{'type'},@{$json->[0]});

	&debug(__FILE__,__LINE__,'+ &fqdn_type = '.Data::Dumper->new([\@retval],['*'])->Indent(0)->Dump) if ($debug{'return'}>1 || $debug{'fqdn_type'}>1);
	return (@retval);
}

################################################################################

=item $webapi->create_fqdn( $fqdn )

Creates the FQDN $fqdn.

=cut

sub create_fqdn
{
	&debug(__FILE__,__LINE__,'+ &create_fqdn'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'create_fqdn'}>0);

	my ($self,$fqdn) = @_;

	$self->request('fqdn/create',
		{
			'new' => {
				'value' => $fqdn,
				'type' => &_type_fqdn(substr($fqdn,0,1) eq '_')
			}
		}
	);
}

################################################################################

=item $webapi->update_fqdn( $fqdn, $new )

Changes the FQDN name $fqdn to $new.

Beware, this is also reflected in all DNS resource records that involve $fqdn!

=cut

sub update_fqdn
{
	&debug(__FILE__,__LINE__,'+ &update_fqdn'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'update_fqdn'}>0);

	my ($self,$fqdn,$new) = @_;

	$self->request('fqdn/update',
		{
			'old' => {
				'value' => $fqdn
			},
			'new' => {
				'value' => $new
			}
		}
	);
}

################################################################################
# …/4.2/wapi/datadict/list?sys_name=dns&ot_name=fqdn&function_name=update

=item $webapi->update_fqdn_type( $fqdn, $type )

Changes the FQDN type of $fqdn. Possible values (string):

meta	FQDN is a meta entry (SRV or SD RRs)
domain	FQDN is a domain (non-terminal)
alias	FQDN is an alias (terminal, only CNAMEs)
wildcard

=cut

sub update_fqdn_type
{
	&debug(__FILE__,__LINE__,'+ &update_fqdn_type'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'update_fqdn_type'}>0);

	my ($self,$fqdn,$type) = @_;

	$self->request('fqdn/update',
		{
			'old' => {
				'value' => $fqdn
			},
			'new' => {
				'type' => $type
			}
		}
	);
}

################################################################################
################################################################################
###
###   A/AAAA records
###
################################################################################
################################################################################

=back

=head3 A/AAAA records

=over

=cut

################################################################################
#
# &_type_ip($ip)
#
sub _type_ip
{
	&debug(__FILE__,__LINE__,'+ &_type_ip'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'_type_ip'}>0);
	my ($ip) = @_;

	return($ip->version()==6?'AAAA':'A');
}

################################################################################

=item $webapi->list_a( $arg )

=item $webapi->list_a( $arg, $type, ... )

Lists all A/AAAA resource records mentioning $arg.
If $arg is a string, a (left hand side) FQDN is looked up.
If $arg is C<NetAddr::IP>, a (right hand side) IP address/subnet is looked up.

Returns a hash FQDN => C<NetAddr::IP>.

=cut

sub list_a
{
	&debug(__FILE__,__LINE__,'+ &list_a'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_a'}>0);
	my ($self,$arg,@types) = @_;

	my @retval;
	if (ref($arg)) {
#		my $json = $self->request('record/list',
#			{
#				'old' => {
#					'target_ipaddr_cidr' => $arg->cidr(),
#					'type' => &_type_ip($arg)
#				}
#			}
#		);
#		@retval = map((&chopp($_->{'fqdn'}),&ip($_->{'data'})),@{$json->[0]});
		my $json = $self->transaction([
			{
				'name' => 'dns.ip_addr.list',
				'old' => {
					'cidr' => $arg->cidr(),
				}
			},
			{
				'name' => 'dns.record.list',
				'old' => {
					'type_list' => [&_type_ip($arg)]
				},
				'inner_join_ref' => {
					'0' => 'api_fkey_dns_record_target_ipaddr'
				}
			}
		]);
		@retval = map((&chopp($_->{'fqdn'}),&ip($_->{'data'})),@{$json->[1]});
	}
	else {
		@types = ('A','AAAA') unless (@types);
		my $json = $self->request('record/list',
			{
				'old' => {
					'fqdn_list' => [$arg],
					'type_list' => \@types
				}
			}
		);
		@retval = map((&chopp($_->{'fqdn'}),&ip($_->{'data'})),@{$json->[0]});
	}

	&debug(__FILE__,__LINE__,'+ &list_a = '.Data::Dumper->new([\@retval],['*'])->Indent(0)->Dump) if ($debug{'return'}>1 || $debug{'list_a'}>1);
	return (@retval);
}

################################################################################

=item $webapi->create_a($fqdn, $ip, ...)

=item $webapi->create_a(\%options, $fqdn, $ip, ...)

Creates an A/AAAA resource record from $fqdn to $ip (which is a C<NetAddr::IP>).

The first parameter may optionally be a reference to a hash of options which
may list a boolean for 'set' to request an $fqdn that may have multiple values,
or 'ns' to specify that $fqdn is an NS object.

=cut

sub create_a
{
	&debug(__FILE__,__LINE__,'+ &create_a'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'create_a'}>0);
	my $self = shift;

	my $fqdn_type = 'domain';
	my $target_is_singleton = my $target_is_reverse_unique = JSON::true;

	if (ref($_[0])) {
		my $options = shift;
		$fqdn_type = 'nameserver' if (defined($options->{'ns'}) && $options->{'ns'} || defined($options->{'nameserver'}) && $options->{'nameserver'});
		$target_is_singleton = $target_is_reverse_unique = JSON::false if (defined($options->{'set'}) && $options->{'set'});
	}

	my $fqdn = shift;
	my $json = $self->transaction([
		# Look up FQDN
		{
			'name' => 'dns.fqdn.list',
			'old' => {
				'value_list' => [$fqdn]
			}
		},
		# Create FQDN if not already defined
		{
			'name' => 'dns.fqdn.create',
			'new' => {
				'value' => $fqdn,
				'type' => $fqdn_type
			},
			'when' => {
				'returns_no_data' => ['0']
			}
		},
		# Update FQDN type if already defined and not the required type
		{
			'name' => 'dns.fqdn.update',
			'old' => {
				'value' => $fqdn
			},
			'new' => {
				'type' => $fqdn_type
			},
			'when' => {
				'and' => [
					{
						'returns_data' => ['0']
					},
					{
						'compare' => [
							'ne',
							{
								'returned_param_value' => ['0','type']
							},
							$fqdn_type
						]
					}
				]
			}
		},
		# Create all RRs for the FQDN
		map({
			'name' => 'dns.record.create',
			'new' => {
				'fqdn' => $fqdn,
				'type' => &_type_ip($_),
				'data' => $_->addr(),
				'target_is_singleton' => $target_is_singleton,
				'target_is_reverse_unique' => $target_is_reverse_unique,
			}
		
		},@_)
	]);
}

################################################################################

=item $webapi->update_a($fqdn, $new)

=item $webapi->update_a($fqdn, $new, $old)

Update the IP address for $fqdn's A/AAAA resource records to $new
(a C<NetAddr::IP>).
If there is a set of A/AAAA resource records and you only want to update one of
them (you usually do!), provide the current value $old (also a C<NetAddr::IP>).

=cut

sub update_a
{
	&debug(__FILE__,__LINE__,'+ &update_a'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'update_a'}>0);
	my ($self,$fqdn,$new,$ip) = @_;

	my $type = &_type_ip($new);
	for my $old (defined($ip)?$ip->addr():$self->_list_record($type,$fqdn)) {
		$self->request('record/update',
			{
				'old' => {
					'fqdn' => $fqdn,
					'type' => $type,
					'data' => $old
				},
				'new' => {
					'data' => $new->addr()
				}
			}
		);
	}
}

################################################################################

=item $webapi->delete_a( $fqdn )

=item $webapi->delete_a( $fqdn, $ip, ... )

=item $webapi->delete_a( $ip, ... )

Delete the A/AAAA resource records for a given FQDN, a given IP address or
subnet or a specific combination of both.

If $fqdn is a string, a (left hand side) FQDN is looked up and deleted.
If the first argument is a C<NetAddr::IP>, a (right hand side) IP address/subnet
is looked up and deleted.

=cut

sub delete_a
{
	&debug(__FILE__,__LINE__,'+ &delete_a'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'delete_a'}>0);
	my ($self,$arg,@ip) = @_;

	sub _delete_a_records
	{
		&debug(__FILE__,__LINE__,'+ &_delete_a_records'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'_delete_a_records'}>0);
		my $self = shift;
		my $json = $self->request('record/list',
			{
				'old' => {
					@_
				}
			}
		);
		for my $old (@{$json->[0]}) {
			$self->request('record/delete',
				{
					'old' => {
						'fqdn' => $old->{'fqdn'},
						'type' => $old->{'type'},
						'data' => $old->{'data'}
					}
				}
			);
		}
	}

	# $webapi->delete_a( $ip, ... )
	if (ref($arg)) {
		for my $ip ($arg,@ip) {
			$self->_delete_a_records(
				# data is a text field and only matches if we use canon
				# FIXME: This probably should be a transaction using dns.ip_addr.list, like in &list_a
				'data_list' => [$ip->canon()],
				'type_list' => [&_type_ip($ip)]
			);
		}
	}
	else {
		# $webapi->delete_a( $fqdn, $ip, ... )
		if (@ip) {
			for my $ip (@ip) {
				$self->_delete_a_records(
					'fqdn_list' => [$arg],
					# data is a text field and only matches if we use canon
					# FIXME: This probably should be a transaction using dns.ip_addr.list, like in &list_a
					'data_list' => [$ip->canon()],
					'type_list' => [&_type_ip($ip)]
				);
			}
		}
		# $webapi->delete_a( $fqdn )
		else {
			for my $type ('A','AAAA') {
				$self->_delete_a_records(
					'fqdn_list' => [$arg],
					'type_list' => [$type]
				);
			}
		}
	}
}

################################################################################
################################################################################
###
###   PTR records
###
################################################################################
################################################################################

=back

=head3 PTR records

=over

=cut

################################################################################

=item $webapi->list_ptr( $arg )

Lists all A/AAAA resource records mentioning $arg.
If $arg is C<NetAddr::IP>, a (left hand side) IP address is looked up and a list of FQDNs is returned.
If $arg is a string, a (right hand side) FQDN is looked up and a list of C<NetAddr::IP> is returned.

=cut

sub list_ptr
{
	&debug(__FILE__,__LINE__,'+ &list_ptr'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_ptr'}>0);
	my ($self,$arg) = @_;

	my @retval;
	if (ref($arg)) {
		return ($self->_list_record('PTR',&rad($arg)));
	}
	else {
		my $json = $self->transaction([
			{
				'name' => 'dns.fqdn.list',
				'old' => {
					'value_list' => [$arg]
				}
			},
			{
				'name' => 'dns.record.list',
				'old' => {
					'type_list' => ['PTR']
				},
				'inner_join_ref' => {
					'0' => 'api_fkey_dns_record_target_fqdn'
				}
			}
		]);
		@retval = map(&unrad(&chopp($_->{'fqdn'})),@{$json->[1]});
	}

	&debug(__FILE__,__LINE__,'+ &list_ptr = '.Data::Dumper->new([\@retval],['*'])->Indent(0)->Dump) if ($debug{'return'}>1 || $debug{'list_ptr'}>1);
	return (@retval);
}

################################################################################

=item $webapi->create_ptr( $ip, $fqdn )

Creates a PTR resource record from $ip (which is a C<NetAddr::IP>) to $fqdn.

=cut

sub create_ptr
{
	&debug(__FILE__,__LINE__,'+ &create_ptr'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'create_ptr'}>0);
	my ($self,$ip,$fqdn) = @_;

	$self->_create_record('PTR',&rad($ip),'meta',$fqdn);
}

################################################################################

=item $webapi->update_ptr( $ip, $fqdn )

Update the right hand side of $ip's (a C<NetAddr::IP>) PTR resource record to $fqdn.

=cut

sub update_ptr
{
	&debug(__FILE__,__LINE__,'+ &update_ptr'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'update_ptr'}>0);
	my ($self,$ip,$fqdn) = @_;

	my $rad = &rad($ip);
	for my $old ($self->_list_record('PTR',$rad)) {
		$self->request('record/update',
			{
				'old' => {
					'fqdn' => $rad,
					'type' => 'PTR',
					'data' => $old
				},
				'new' => {
					'data' => $fqdn
				}
			}
		);
	}
}

################################################################################

=item $webapi->delete_ptr( $ip )

Delete the PTR resource record for $ip (a C<NetAddr::IP>).

=cut

sub delete_ptr
{
	&debug(__FILE__,__LINE__,'+ &delete_ptr'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'delete_ptr'}>0);
	my ($self,$ip) = @_;

	my $rad = &rad($ip);
	for my $old ($self->_list_record('PTR',$rad)) {
		$self->request('record/delete',
			{
				'old' => {
					'fqdn' => $rad,
					'type' => $old->{'type'},
					'data' => $old->{'data'}
				}
			}
		);
	}
}

################################################################################
################################################################################
###
###   CNAME records
###
################################################################################
################################################################################

=back

=head3 CNAME records

=over

=cut

################################################################################
# …/4.2/dns/record/

=item $webapi->list_cname( $fqdn )

Lists (all) CNAME resource records defined for $fqdn.

=cut

sub list_cname
{
	&debug(__FILE__,__LINE__,'+ &list_cname'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_cname'}>0);
	my ($self,$fqdn) = @_;

	return ($self->_list_record('CNAME',$fqdn));
}

################################################################################
# …/4.2/dns/record/

=item $webapi->list_cname_target( $cname )

Lists all CNAME resource records with target $cname.

=cut

sub list_cname_target
{
	&debug(__FILE__,__LINE__,'+ &list_cname_target'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_cname_target'}>0);
	my ($self,$cname) = @_;

	my $json = $self->transaction([
		{
			'name' => 'dns.fqdn.list',
			'old' => {
				'value_list' => [$cname]
			}
		},
		{
			'name' => 'dns.record.list',
			'old' => {
				'type_list' => ['CNAME']
			},
			'inner_join_ref' => {
				'0' => 'api_fkey_dns_record_target_fqdn'
			}
		}
	]);
	my @retval = map(&chopp($_->{'fqdn'}),@{$json->[1]});

	&debug(__FILE__,__LINE__,'+ &list_cname_target = '.Data::Dumper->new([\@retval],['*'])->Indent(0)->Dump) if ($debug{'return'}>1 || $debug{'list_cname_target'}>1);
	return (@retval);
}

################################################################################

=item $webapi->create_cname( $fqdn, $cname )

Creates a CNAME resource record for $fqdn.

=cut

sub create_cname
{
	&debug(__FILE__,__LINE__,'+ &create_cname'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'create_cname'}>0);
	my ($self,$fqdn,$cname) = @_;

	$self->_create_record('CNAME',$fqdn,'alias',$cname);
}

################################################################################

=item $webapi->update_cname( $fqdn, $cname )

Update the target of $fqdn's CNAME resource record to $cname.

=cut

sub update_cname
{
	&debug(__FILE__,__LINE__,'+ &update_cname'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'update_cname'}>0);
	my ($self,$fqdn,$new) = @_;

	for my $old ($self->_list_record('CNAME',$fqdn)) {
		$self->request('record/update',
			{
				'old' => {
					'fqdn' => $fqdn,
					'type' => 'CNAME',
					'data' => $old
					},
				'new' => {
					'data' => $new
					}
			}
		);
	}
}

################################################################################

=item $webapi->delete_cname( $fqdn )

Delete the CNAME resource record for $fqdn.

=cut

sub delete_cname
{
	&debug(__FILE__,__LINE__,'+ &delete_cname'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'delete_cname'}>0);
	my ($self,$fqdn) = @_;

	# FIXME: Should be a transaction
	for my $cname ($self->list_cname($fqdn)) {
		$self->request('record/delete',
			{
				'old' => {
					'fqdn' => $fqdn,
					'type' => 'CNAME',
					'data' => $cname
				}
			}
		);
	}
}

################################################################################
################################################################################
###
###   TXT records
###
################################################################################
################################################################################

=back

=head3 TXT records

=over

=cut

################################################################################

=item $webapi->list_txt( $fqdn )

Lists all TXT resource records defined for $fqdn.

=cut

sub list_txt
{
	&debug(__FILE__,__LINE__,'+ &list_txt'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_txt'}>0);
	my ($self,$fqdn) = @_;

	return ($self->_list_record('TXT',$fqdn));
}

################################################################################

=item $webapi->create_txt( $fqdn, $txt )

Creates a TXT resource record for $fqdn.

=cut

sub create_txt
{
	&debug(__FILE__,__LINE__,'+ &create_txt'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'create_txt'}>0);
	my ($self,$fqdn,$txt) = @_;

	$self->_create_record('TXT',$fqdn,'meta',$txt);
}

################################################################################

=item $webapi->update_txt( $fqdn, $old, $new )

Update the right hand side of one of $fqdn's TXT resource records (the one specifying $old) to $new.

=cut

sub update_txt
{
	&debug(__FILE__,__LINE__,'+ &update_txt'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'update_txt'}>0);
	my ($self,$fqdn,$old,$new) = @_;

	$self->request('record/update',
		{
			'old' => {
				'fqdn' => $fqdn,
				'type' => 'TXT',
				'data' => $old
			},
			'new' => {
				'data' => $new
			}
		}
	);
}

################################################################################

=item $webapi->delete_txt( $fqdn, $txt )

Delete the TXT resource record (specifying $txt) for $fqdn.

=cut

sub delete_txt
{
	&debug(__FILE__,__LINE__,'+ &delete_txt'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'delete_txt'}>0);
	my ($self,$fqdn,$txt) = @_;

	$self->request('record/delete',
		{
			'old' => {
				'fqdn' => $fqdn,
				'type' => 'TXT',
				'data' => $txt
			}
		}
	);
}

################################################################################
################################################################################
###
###   SOA records
###
################################################################################
################################################################################

=back

=head3 SOA records

=over

=cut

################################################################################

=item $webapi->list_soa( $fqdn )

Lists all SOA resource records defined for $fqdn.

=cut

sub list_soa
{
	&debug(__FILE__,__LINE__,'+ &list_soa'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_soa'}>0);
	my ($self,$fqdn) = @_;

	return ($self->_list_record('SOA',$fqdn));
}

################################################################################
################################################################################
###
###   NS records
###
################################################################################
################################################################################

=back

=head3 NS records

=over

=cut

################################################################################

=item $webapi->list_ns( $fqdn )

Lists all NS resource records defined for $fqdn.

=cut

sub list_ns
{
	&debug(__FILE__,__LINE__,'+ &list_ns'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>1 || $debug{'list_ns'}>0);
	my ($self,$fqdn) = @_;

	return ($self->_list_record('NS',$fqdn));
}

################################################################################
################################################################################
################################################################################
######
######      Helper functions
######
################################################################################
################################################################################
################################################################################

=back

=head2 Helper Functions

=over

=item is_ip( $string )

Returns a boolean value for whether $string may syntactically be an IPv4 or IPv6 address.

=cut

sub is_ip
{
	my ($text) = @_;

	return ($text =~ /^(?:\d+(?:\.\d+){3}|[\da-f]*(?::[\da-f]*)+)$/i);
}

################################################################################

=item is_cidr( $string )

Returns a boolean value for whether $string may syntactically be an IPv4 or IPv6 address with an optional subnet mask length.

=cut

sub is_cidr
{
	my ($text) = @_;

	return ($text =~ /^(?:\d+(?:\.\d+){3}|[\da-f]*(?::[\da-f]*)+)(?:\/\d+)?$/i);
}

################################################################################

=item ip( $string )

=item ip( $string, $mask )

=item ip( $string, $mask, $text )

Converts a textual IP address (optionally with a subnet mask) to a C<NetAddr::IP>.
Croaks if this fails, with "Invalid $text address" ($text defaults to "IP").

=cut

sub ip
{
	&debug(__FILE__,__LINE__,'+ &ip('.join(',',map("'$_'",@_)).')') if ($debug{'call'}>2 || $debug{'ip'}>0);
	my ($addr,$mask,@text) = @_;;

	my $ip = NetAddr::IP->new($addr,$mask//());
	croak join(' ','Invalid',@text?@text:'IP','address:',$addr)."\n" unless (defined($ip));

	&debug(__FILE__,__LINE__,'+ &ip = '.$ip.' '.Data::Dumper->new([\$ip],['*'])->Indent(0)->Dump) if ($debug{'return'}>2 || $debug{'ip'}>1);
	return($ip);
}

################################################################################

=item rad( $ip )

Convert an (C<NetAddr::IP>) IP address to a Reverse Address Domain

=cut

sub rad
{
	&debug(__FILE__,__LINE__,'+ &rad'.Data::Dumper->new([\@_],['*'])->Indent(0)->Dump) if ($debug{'call'}>2 || $debug{'rad'}>0);

	my ($ip) = @_;

	my $rad;
	if ($ip->version() == 6) {
		$rad = join('.',reverse(grep($_ ne ':',split('',$ip->full6()))),'ip6','arpa');
	}
	else {
		$rad = join('.',reverse(split(/\./,$ip->addr())),'in-addr','arpa');
	}

	&debug(__FILE__,__LINE__,'+ &rad = '.$rad) if ($debug{'return'}>2 || $debug{'rad'}>1);
	return($rad);
}

################################################################################

=item unrad( $fqdn )

Convert a Reverse Address Domain to a C<NetAddr::IP>

=cut

sub unrad
{
	&debug(__FILE__,__LINE__,'+ &unrad('.join(',',map("'$_'",@_)).')') if ($debug{'call'}>2 || $debug{'unrad'}>0);

	my ($rad) = @_;

	my $ip;
	if ($rad =~ /^(?:([0-9a-f])\.){32}ip6.arpa$/i) {
		$ip = join('',reverse((split(/\./,$rad))[0..31]));
		$ip =~ s/.{4}(?=.)/$&:/g;
	}
	elsif ($rad =~ /^(?:([0-9]+)\.){4}in-addr.arpa$/i) {
		$ip = join('.',reverse((split(/\./,$rad))[0..3]));
	}
	else {
		croak "Unknown RAD format: $rad";
	}
	$ip = NetAddr::IP->new($ip);

	&debug(__FILE__,__LINE__,'+ &unrad = '.Data::Dumper->new([\$ip],['*'])->Indent(0)->Dump) if ($debug{'return'}>2 || $debug{'unrad'}>1);
	return($ip);
}

################################################################################

sub debug
{
	&{$debug{undef}}(@_) if (defined($debug{undef}));
}

################################################################################

=back

=head1 AUTHOR

Written by Andreas Ley E<lt>andreas.ley@kit.eduE<gt>
with support by Klara Mall E<lt>klara.mall@kit.eduE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2018-2025 by Andreas Ley E<lt>andreas.ley@kit.eduE<gt>, Klara Mall E<lt>klara.mall@kit.eduE<gt>

This library is free software; you may redistribute it and/or modify it under the same terms as Perl itself.

=head1 SEE ALSO

See L<NetAddr::IP> for a description of the format for IP addresses.

See L<https://netvs.scc.kit.edu/swagger> for a description of the API itself.

=cut

1;
