1320 lines
53 KiB
Perl
Executable File
1320 lines
53 KiB
Perl
Executable File
#!/usr/bin/perl
|
|
use strict;
|
|
use warnings;
|
|
use Test::More;
|
|
use Cwd qw(abs_path);
|
|
use File::Temp qw(tempdir);
|
|
use IPC::Open3 ();
|
|
use Symbol qw(gensym);
|
|
|
|
sub script_dir
|
|
{
|
|
my $path = $0;
|
|
if ($path =~ m{^/}) {
|
|
$path =~ s{/[^/]+$}{};
|
|
return $path;
|
|
}
|
|
my $cwd = `pwd`;
|
|
chomp($cwd);
|
|
if ($path =~ m{/}) {
|
|
$path =~ s{/[^/]+$}{};
|
|
return $cwd.'/'.$path;
|
|
}
|
|
return $cwd;
|
|
}
|
|
|
|
my $bindir = script_dir();
|
|
my $module_dir = abs_path("$bindir/..") or die "module dir: $!";
|
|
my $rootdir = abs_path("$bindir/../..") or die "root dir: $!";
|
|
|
|
my $confdir = tempdir(CLEANUP => 1);
|
|
my $vardir = tempdir(CLEANUP => 1);
|
|
my $keadir = "$confdir/kea";
|
|
mkdir($keadir) or die "mkdir $keadir: $!";
|
|
mkdir("$confdir/kea-dhcp") or die "mkdir module config: $!";
|
|
|
|
open(my $cfh, ">", "$confdir/config") or die "config: $!";
|
|
print $cfh "os_type=generic-linux\nos_version=0\n";
|
|
close($cfh);
|
|
open(my $vfh, ">", "$confdir/var-path") or die "var-path: $!";
|
|
print $vfh "$vardir\n";
|
|
close($vfh);
|
|
|
|
sub write_plain_file
|
|
{
|
|
my ($file, $content) = @_;
|
|
open(my $fh, ">", $file) or die "$file: $!";
|
|
print $fh $content;
|
|
close($fh);
|
|
}
|
|
|
|
my $dhcp4_conf = "$keadir/kea-dhcp4.conf";
|
|
my $dhcp6_conf = "$keadir/kea-dhcp6.conf";
|
|
my $ddns_conf = "$keadir/kea-dhcp-ddns.conf";
|
|
my $ctrl_conf = "$keadir/kea-ctrl-agent.conf";
|
|
my $api_password = "$keadir/kea-api-password";
|
|
my $dhcp4_lease_file = "$vardir/kea-leases4.csv";
|
|
my $dhcp6_lease_file = "$vardir/kea-leases6.csv";
|
|
|
|
# Keep fixture configs intentionally small: each file has the top-level Kea
|
|
# object the module validates, but still includes enough data to render every
|
|
# structured tab.
|
|
write_plain_file($dhcp4_conf, <<'EOF');
|
|
// Stock Kea configs include comments that structured saves intentionally drop.
|
|
{
|
|
"Dhcp4": {
|
|
"interfaces-config": { "interfaces": [] },
|
|
"lease-database": { "type": "memfile", "lfc-interval": 3600 },
|
|
"control-socket": { "socket-type": "unix", "socket-name": "kea4-ctrl-socket" },
|
|
"dhcp-ddns": {
|
|
"enable-updates": true,
|
|
"server-ip": "127.0.0.1",
|
|
"server-port": 53001
|
|
},
|
|
"loggers": [
|
|
{
|
|
"name": "kea-dhcp4",
|
|
"severity": "INFO",
|
|
"debuglevel": 0,
|
|
"output-options": [
|
|
{ "output": "stdout", "pattern": "%-5p %m\n" }
|
|
]
|
|
}
|
|
],
|
|
"renew-timer": 900,
|
|
"rebind-timer": 1800,
|
|
"valid-lifetime": 3600,
|
|
"option-data": [
|
|
{ "name": "domain-name-servers", "data": "192.0.2.1, 192.0.2.2" },
|
|
{ "code": 15, "data": "example.org" },
|
|
{ "name": "default-ip-ttl", "data": "0xf0" }
|
|
],
|
|
"shared-networks": [],
|
|
"subnet4": []
|
|
}
|
|
}
|
|
EOF
|
|
|
|
write_plain_file($dhcp6_conf, <<'EOF');
|
|
{
|
|
"Dhcp6": {
|
|
"interfaces-config": { "interfaces": [] },
|
|
"lease-database": { "type": "memfile", "lfc-interval": 3600 },
|
|
"control-socket": { "socket-type": "unix", "socket-name": "kea6-ctrl-socket" },
|
|
"dhcp-ddns": {
|
|
"enable-updates": false,
|
|
"server-ip": "127.0.0.1",
|
|
"server-port": 53001
|
|
},
|
|
"loggers": [
|
|
{
|
|
"name": "kea-dhcp6",
|
|
"severity": "INFO",
|
|
"debuglevel": 0,
|
|
"output-options": [
|
|
{ "output": "stdout", "pattern": "%-5p %m\n" }
|
|
]
|
|
}
|
|
],
|
|
"renew-timer": 1000,
|
|
"rebind-timer": 2000,
|
|
"preferred-lifetime": 3000,
|
|
"valid-lifetime": 4000,
|
|
"option-data": [
|
|
{ "name": "dns-servers", "data": "2001:db8:2::45, 2001:db8:2::100" },
|
|
{ "code": 12, "data": "2001:db8::1" }
|
|
],
|
|
"shared-networks": [],
|
|
"subnet6": []
|
|
}
|
|
}
|
|
EOF
|
|
|
|
write_plain_file($ddns_conf, <<'EOF');
|
|
// DHCP-DDNS is a separate Kea daemon shared by DHCPv4 and DHCPv6.
|
|
{
|
|
"DhcpDdns": {
|
|
"ip-address": "127.0.0.1",
|
|
"port": 53001,
|
|
"dns-server-timeout": 500,
|
|
"control-socket": {
|
|
"socket-type": "unix",
|
|
"socket-name": "kea-ddns-ctrl-socket"
|
|
},
|
|
"tsig-keys": [
|
|
{
|
|
"name": "ddns-key",
|
|
"algorithm": "hmac-sha256",
|
|
"secret": "ZmFrZQ=="
|
|
}
|
|
],
|
|
"forward-ddns": {
|
|
"ddns-domains": [
|
|
{
|
|
"name": "example.org.",
|
|
"key-name": "ddns-key",
|
|
"dns-servers": [
|
|
{ "ip-address": "192.0.2.53", "port": 53 },
|
|
{ "ip-address": "192.0.2.54", "port": 5353 }
|
|
]
|
|
}
|
|
]
|
|
},
|
|
"reverse-ddns": {
|
|
"ddns-domains": [
|
|
{
|
|
"name": "2.0.192.in-addr.arpa.",
|
|
"key-name": "ddns-key",
|
|
"dns-servers": [
|
|
{ "ip-address": "192.0.2.53", "port": 53 }
|
|
]
|
|
}
|
|
]
|
|
},
|
|
"loggers": [
|
|
{
|
|
"name": "kea-dhcp-ddns",
|
|
"severity": "INFO",
|
|
"debuglevel": 0,
|
|
"output-options": [
|
|
{ "output": "stdout", "pattern": "%-5p %m\n" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
EOF
|
|
|
|
write_plain_file($ctrl_conf, <<"EOF");
|
|
{
|
|
"Control-agent": {
|
|
"http-host": "127.0.0.1",
|
|
"http-port": 8000,
|
|
"authentication": {
|
|
"directory": "$keadir",
|
|
"clients": [
|
|
{ "user": "kea-api", "password-file": "kea-api-password" }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# Kea memfile lease databases are CSV files. The fixtures include one active
|
|
# and one expired lease per protocol so runtime pages can prove both parsing
|
|
# and filtering behavior.
|
|
write_plain_file($dhcp4_lease_file, <<'EOF');
|
|
address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context
|
|
192.0.2.50,00:11:22:33:44:55,01:00:11:22:33:44:55,3600,4102444800,1,0,0,alpha,0,
|
|
192.0.2.51,00:11:22:33:44:66,01:00:11:22:33:44:66,3600,1,1,0,0,expired,0,
|
|
EOF
|
|
|
|
write_plain_file($dhcp6_lease_file, <<'EOF');
|
|
address,duid,valid_lifetime,expire,subnet_id,pref_lifetime,lease_type,iaid,prefix_len,fqdn_fwd,fqdn_rev,hostname,hwaddr,state,user_context,pool_id
|
|
2001:db8:1::50,00:01:02:03,4000,4102444800,1,3000,0,1,128,0,0,bravo,00:11:22:33:44:77,0,,0
|
|
2001:db8:1::51,00:01:02:04,4000,1,1,3000,0,2,128,0,0,expired6,00:11:22:33:44:88,0,,0
|
|
EOF
|
|
|
|
sub write_module_config_dir
|
|
{
|
|
my ($dir, $paths) = @_;
|
|
$paths ||= { };
|
|
my $dhcp4_path = $paths->{dhcp4_path} || $^X;
|
|
my $dhcp6_path = $paths->{dhcp6_path} || $^X;
|
|
my $ddns_path = $paths->{ddns_path} || $^X;
|
|
my $ctrl_agent_path = $paths->{ctrl_agent_path} || $^X;
|
|
my $keactrl_path = $paths->{keactrl_path} || $^X;
|
|
write_plain_file("$dir/kea-dhcp/config", <<"EOF");
|
|
dhcp4_conf=$dhcp4_conf
|
|
dhcp6_conf=$dhcp6_conf
|
|
ddns_conf=$ddns_conf
|
|
ctrl_agent_conf=$ctrl_conf
|
|
dhcp4_path=$dhcp4_path
|
|
dhcp6_path=$dhcp6_path
|
|
ddns_path=$ddns_path
|
|
ctrl_agent_path=$ctrl_agent_path
|
|
keactrl_path=$keactrl_path
|
|
dhcp4_lease_file=$dhcp4_lease_file
|
|
dhcp6_lease_file=$dhcp6_lease_file
|
|
dhcp4_pid_file=$vardir/kea-dhcp4.pid
|
|
dhcp6_pid_file=$vardir/kea-dhcp6.pid
|
|
ddns_pid_file=$vardir/kea-dhcp-ddns.pid
|
|
ctrl_agent_pid_file=$vardir/kea-ctrl-agent.pid
|
|
start_cmd=/bin/true
|
|
stop_cmd=/bin/true
|
|
restart_cmd=/bin/true
|
|
EOF
|
|
}
|
|
|
|
sub write_module_config
|
|
{
|
|
my ($paths) = @_;
|
|
write_module_config_dir($confdir, $paths);
|
|
}
|
|
write_module_config();
|
|
|
|
$ENV{'WEBMIN_CONFIG'} = $confdir;
|
|
$ENV{'WEBMIN_VAR'} = $vardir;
|
|
$ENV{'FOREIGN_MODULE_NAME'} = 'kea-dhcp';
|
|
$ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir;
|
|
|
|
chdir($module_dir) or die "chdir $module_dir: $!";
|
|
require "$module_dir/kea-dhcp-lib.pl";
|
|
our (%config, %in, %text);
|
|
our (%kea_component_status_cache);
|
|
|
|
$INC{'kea-dhcp-lib.pl'} ||= "$module_dir/kea-dhcp-lib.pl";
|
|
{
|
|
local @INC = ($module_dir, @INC);
|
|
require "$module_dir/acl_security.pl";
|
|
}
|
|
|
|
sub urlenc
|
|
{
|
|
my $s = shift;
|
|
$s = '' if !defined $s;
|
|
$s =~ s/([^A-Za-z0-9._~-])/sprintf('%%%02X', ord($1))/ge;
|
|
return $s;
|
|
}
|
|
|
|
sub query_string
|
|
{
|
|
my ($params) = @_;
|
|
return join('&', map {
|
|
urlenc($_).'='.urlenc($params->{$_})
|
|
} sort keys %{$params || {}});
|
|
}
|
|
|
|
# Execute real Kea CGIs under a disposable Webmin environment. This catches
|
|
# missing globals, broken tab rendering, and invalid UI helper calls.
|
|
sub run_cgi
|
|
{
|
|
my ($cgi, $params, $extra_env) = @_;
|
|
my $query = query_string($params || {});
|
|
my %env = (
|
|
PATH => '/bin:/usr/bin',
|
|
WEBMIN_CONFIG => $confdir,
|
|
WEBMIN_VAR => $vardir,
|
|
FOREIGN_MODULE_NAME => 'kea-dhcp',
|
|
FOREIGN_ROOT_DIRECTORY => $rootdir,
|
|
REQUEST_METHOD => 'GET',
|
|
SCRIPT_NAME => "/kea-dhcp/$cgi",
|
|
QUERY_STRING => $query,
|
|
SERVER_NAME => 'localhost',
|
|
SERVER_PORT => '10000',
|
|
HTTP_HOST => 'localhost:10000',
|
|
REMOTE_USER => 'admin',
|
|
BASE_REMOTE_USER => 'admin',
|
|
);
|
|
if ($extra_env) {
|
|
foreach my $key (keys %$extra_env) {
|
|
$env{$key} = $extra_env->{$key};
|
|
}
|
|
}
|
|
|
|
my $errfh = gensym();
|
|
local %ENV = %env;
|
|
my $pid = IPC::Open3::open3(my $in, my $out, $errfh,
|
|
$^X, "-I$rootdir", "$module_dir/$cgi");
|
|
close($in);
|
|
local $/;
|
|
my $stdout = <$out>;
|
|
my $stderr = <$errfh>;
|
|
$stdout = '' if !defined $stdout;
|
|
$stderr = '' if !defined $stderr;
|
|
close($out);
|
|
close($errfh);
|
|
waitpid($pid, 0);
|
|
return {
|
|
out => $stdout,
|
|
err => $stderr,
|
|
status => $? >> 8,
|
|
};
|
|
}
|
|
|
|
sub cgi_ok
|
|
{
|
|
my ($cgi, $params, $name, $extra_env) = @_;
|
|
my $res = run_cgi($cgi, $params, $extra_env);
|
|
is($res->{status}, 0, "$name exits cleanly") or diag($res->{err});
|
|
like($res->{out}, qr/Content-type:/i, "$name returns a CGI response");
|
|
return $res->{out};
|
|
}
|
|
|
|
sub html_has
|
|
{
|
|
my ($html, $pattern, $name) = @_;
|
|
like($html, $pattern, $name);
|
|
}
|
|
|
|
foreign_require('kea-dhcp', 'log_parser.pl');
|
|
is(foreign_call('kea-dhcp', 'parse_webmin_log',
|
|
'root', 'save_options.cgi', 'modify', 'global-options',
|
|
'dhcp4', {}),
|
|
'Saved DHCPv4 global settings',
|
|
'Webmin log parser formats global settings changes');
|
|
like(foreign_call('kea-dhcp', 'parse_webmin_log',
|
|
'root', 'save_shared.cgi', 'create', 'shared-network',
|
|
'office', {}),
|
|
qr/^Created shared network <tt\b[^>]*>office<\/tt>\s*$/,
|
|
'Webmin log parser formats shared-network changes');
|
|
is(foreign_call('kea-dhcp', 'parse_webmin_log',
|
|
'root', 'delete_objects.cgi', 'delete', 'objects', 3, {}),
|
|
'Deleted 3 Kea DHCP objects',
|
|
'Webmin log parser formats bulk delete changes');
|
|
is(foreign_call('kea-dhcp', 'parse_webmin_log',
|
|
'root', 'delete_objects.cgi', 'delete', 'objects', 1, {}),
|
|
'Deleted 1 Kea DHCP object',
|
|
'Webmin log parser formats singular bulk delete changes');
|
|
|
|
my @passwords = kea_control_agent_password_files();
|
|
is_deeply(\@passwords, [ $api_password ],
|
|
'Control Agent password file is discoverable');
|
|
ok(scalar(grep { $_ eq $api_password } get_all_config_files()),
|
|
'configuration backups include Control Agent password file');
|
|
ok(scalar(grep { $_->{file} eq $api_password && $_->{type} eq 'password' }
|
|
kea_manual_edit_files()),
|
|
'manual editor allow-list includes Control Agent password file');
|
|
is(kea_component('dhcp4')->{'unit'}, 'kea-dhcp4-server.service',
|
|
'default service unit follows Debian Kea package naming');
|
|
{
|
|
my %el_config;
|
|
read_file("$module_dir/config-redhat-linux", \%el_config);
|
|
is($el_config{'dhcp4_unit'}, 'kea-dhcp4.service',
|
|
'modern RPM config uses EL Kea DHCPv4 unit name');
|
|
like($el_config{'stop_cmd'}, qr/\bkea-dhcp4\.service\b/,
|
|
'modern RPM config stop command uses EL Kea unit names');
|
|
}
|
|
{
|
|
no warnings 'redefine';
|
|
local *has_command = sub {
|
|
my ($cmd) = @_;
|
|
return '/bin/systemctl' if ($cmd eq 'systemctl');
|
|
return;
|
|
};
|
|
local $config{'dhcp4_unit'} = 'kea-dhcp4.service';
|
|
local $config{'dhcp6_unit'} = 'kea-dhcp6.service';
|
|
local $config{'ddns_unit'} = 'kea-dhcp-ddns.service';
|
|
local $config{'ctrl_agent_unit'} = 'kea-ctrl-agent.service';
|
|
local $config{'stop_cmd'} = '';
|
|
is(kea_component_systemd_unit(kea_component('dhcp4')), 'kea-dhcp4.service',
|
|
'systemd unit lookup uses the configured DHCPv4 unit');
|
|
like(kea_action_command('stop'),
|
|
qr{systemctl stop kea-dhcp4\.service kea-dhcp6\.service kea-dhcp-ddns\.service kea-ctrl-agent\.service},
|
|
'generated systemctl action uses configured unit names');
|
|
}
|
|
{
|
|
no warnings qw(redefine once);
|
|
local %kea_component_status_cache;
|
|
local *has_command = sub {
|
|
my ($cmd) = @_;
|
|
return '/bin/systemctl' if ($cmd eq 'systemctl');
|
|
return;
|
|
};
|
|
local *kea_component_pid = sub { return 0; };
|
|
local *kea_systemd_unit_properties = sub {
|
|
my ($unit) = @_;
|
|
return {
|
|
LoadState => 'loaded',
|
|
ActiveState => 'active',
|
|
Result => 'success',
|
|
MainPID => 12345,
|
|
ExecMainStatus => 0,
|
|
ConditionResult => 'yes',
|
|
} if ($unit eq 'kea-dhcp4.service');
|
|
return { LoadState => 'not-found' };
|
|
};
|
|
local $config{'dhcp4_unit'} = 'kea-dhcp4.service';
|
|
is(kea_component_systemd_unit(kea_component('dhcp4')), 'kea-dhcp4.service',
|
|
'systemd unit comes from configured Kea unit name');
|
|
my $status = kea_component_status(kea_component('dhcp4'));
|
|
is($status->{'unit'}, 'kea-dhcp4.service',
|
|
'component status uses the configured Kea unit name');
|
|
is($status->{'state'}, 'running',
|
|
'component status is read from the configured Kea unit');
|
|
}
|
|
|
|
my $index = cgi_ok('index.cgi', { mode => 'dhcp4' }, 'index DHCPv4 tab');
|
|
html_has($index, qr/No shared networks are defined\. Use the button above to create one\./,
|
|
'index empty shared-network text is concise');
|
|
html_has($index, qr/No subnets are defined\. Use the button above to create one\./,
|
|
'index empty subnet text is concise');
|
|
html_has($index, qr/DHCPv4 Global Settings/s,
|
|
'index links to DHCPv4 global settings');
|
|
html_has($index, qr/Runtime Status/s,
|
|
'index links to runtime status details');
|
|
html_has($index, qr/Edit Config Files/s,
|
|
'index links to raw config editor');
|
|
html_has($index, qr/The Kea configuration file\s*<tt\b[^>]*>\Q$dhcp4_conf\E<\/tt>\s*contains comments.*Webmin saves changes from these settings pages/s,
|
|
'index warns that DHCPv4 config comments will be removed');
|
|
|
|
my $ddns_index = cgi_ok('index.cgi', { mode => 'ddns' }, 'index DHCP-DDNS tab');
|
|
html_has($ddns_index, qr/Manage the standalone Kea DHCP-DDNS daemon/s,
|
|
'index renders the DHCP-DDNS tab');
|
|
html_has($ddns_index, qr/127\.0\.0\.1:53001.*example\.org\./s,
|
|
'DHCP-DDNS tab summarizes listener and forward zone');
|
|
html_has($ddns_index, qr/DHCPv4 sender.*Enabled.*127\.0\.0\.1:53001.*matches D2 listener/s,
|
|
'DHCP-DDNS tab shows matching DHCPv4 sender status');
|
|
html_has($ddns_index, qr/DHCPv6 sender.*Disabled/s,
|
|
'DHCP-DDNS tab shows disabled DHCPv6 sender status');
|
|
html_has($ddns_index, qr/DHCP-DDNS Settings/s,
|
|
'index links to DHCP-DDNS settings');
|
|
|
|
my $services_index = cgi_ok('index.cgi', { mode => 'services' },
|
|
'index services tab');
|
|
my $dhcp4_conf_url = urlize($dhcp4_conf);
|
|
unlike($services_index, qr/systemd unit/s,
|
|
'services table does not expose Linux-specific unit column');
|
|
html_has($services_index,
|
|
qr/edit_text\.cgi\?file=\Q$dhcp4_conf_url\E.*\Q$dhcp4_conf\E/s,
|
|
'services table links config files to manual editor when ACL allows');
|
|
html_has($services_index, qr/Configured endpoint: 127\.0\.0\.1:8000/s,
|
|
'Control Agent summary describes configured endpoint without implying runtime');
|
|
|
|
my $missing_confdir = tempdir(CLEANUP => 1);
|
|
mkdir("$missing_confdir/kea-dhcp")
|
|
or die "mkdir missing module config: $!";
|
|
write_plain_file("$missing_confdir/config", "os_type=generic-linux\nos_version=0\n");
|
|
write_plain_file("$missing_confdir/var-path", "$vardir\n");
|
|
write_module_config_dir($missing_confdir, {
|
|
dhcp4_path => "$confdir/missing-kea-dhcp4",
|
|
dhcp6_path => "$confdir/missing-kea-dhcp6",
|
|
ddns_path => "$confdir/missing-kea-dhcp-ddns",
|
|
ctrl_agent_path => "$confdir/missing-kea-ctrl-agent",
|
|
keactrl_path => "$confdir/missing-keactrl",
|
|
});
|
|
my $missing_index = cgi_ok('index.cgi', { mode => 'dhcp4' },
|
|
'index missing-install page',
|
|
{ WEBMIN_CONFIG => $missing_confdir });
|
|
html_has($missing_index, qr/Kea DHCP executables do not exist/,
|
|
'missing-install page explains missing executables');
|
|
unlike($missing_index, qr/class=["'][^"']*\btab-pane\b/,
|
|
'missing-install page does not render module tabs');
|
|
unlike($missing_index, qr/Edit Config Files|Start all Kea services|Stop all Kea services/,
|
|
'missing-install page does not render normal module actions');
|
|
|
|
my $orig_dhcp4_conf;
|
|
{
|
|
open(my $orig4fh, "<", $dhcp4_conf) or die "$dhcp4_conf: $!";
|
|
local $/;
|
|
$orig_dhcp4_conf = <$orig4fh>;
|
|
close($orig4fh);
|
|
}
|
|
write_plain_file($dhcp4_conf, <<'EOF');
|
|
{
|
|
"Dhcp4": {
|
|
"shared-networks": [
|
|
{
|
|
"name": "lan",
|
|
"subnet4": [
|
|
{ "id": 1, "subnet": "192.0.2.0/24" }
|
|
]
|
|
}
|
|
],
|
|
"subnet4": []
|
|
}
|
|
}
|
|
EOF
|
|
my $blocked_delete = run_cgi('delete_objects.cgi', { version => 4, d_shared => 0 });
|
|
like($blocked_delete->{out}, qr/still contains subnets/,
|
|
'bulk delete refuses to remove a non-empty shared network');
|
|
my $blocked_negative_shared_edit = run_cgi('edit_shared.cgi',
|
|
{ version => 4, idx => -1 });
|
|
like($blocked_negative_shared_edit->{out}, qr/requested shared network was not found/,
|
|
'shared-network editor rejects negative indexes');
|
|
my $blocked_negative_delete = run_cgi('delete_objects.cgi',
|
|
{ version => 4, d_subnet => '-1:0' });
|
|
like($blocked_negative_delete->{out}, qr/requested subnet was not found/,
|
|
'bulk delete rejects negative shared-network subnet indexes');
|
|
my $blocked_negative_edit = run_cgi('edit_subnet.cgi',
|
|
{ version => 4, sidx => -1, idx => 0 });
|
|
like($blocked_negative_edit->{out}, qr/requested subnet was not found/,
|
|
'subnet editor rejects negative shared-network indexes');
|
|
my $blocked_negative_save = run_cgi('save_subnet.cgi',
|
|
{ version => 4, delete => 1, sidx => -1, idx => 0 });
|
|
like($blocked_negative_save->{out}, qr/requested subnet was not found/,
|
|
'subnet save rejects negative shared-network indexes');
|
|
my $interfaces_warning = cgi_ok('index.cgi', { mode => 'dhcp4' },
|
|
'index warns about empty interfaces');
|
|
html_has($interfaces_warning, qr/no listening interfaces are configured/s,
|
|
'DHCP tab warns when subnets exist without listening interfaces');
|
|
my $services_warning = cgi_ok('index.cgi', { mode => 'services' },
|
|
'services warn about empty interfaces');
|
|
html_has($services_warning,
|
|
qr/Subnets exist, but no listening interfaces are configured/s,
|
|
'services table warns when a DHCP service has subnets but no interfaces');
|
|
write_plain_file($dhcp4_conf, $orig_dhcp4_conf);
|
|
|
|
my $orig_dhcp6_conf;
|
|
{
|
|
open(my $orig6fh, "<", $dhcp6_conf) or die "$dhcp6_conf: $!";
|
|
local $/;
|
|
$orig_dhcp6_conf = <$orig6fh>;
|
|
close($orig6fh);
|
|
}
|
|
write_plain_file($dhcp4_conf, <<'EOF');
|
|
{
|
|
"Dhcp4": {
|
|
"lease-database": { "type": "memfile" },
|
|
"subnet4": [
|
|
{
|
|
"id": 1,
|
|
"subnet": "192.0.2.0/24",
|
|
"pools": [ { "pool": "192.0.2.10 - 192.0.2.100" } ],
|
|
"reservations": [
|
|
{ "hw-address": "00:11:22:33:44:55", "ip-address": "192.0.2.60" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
EOF
|
|
write_plain_file($dhcp6_conf, <<'EOF');
|
|
{
|
|
"Dhcp6": {
|
|
"lease-database": { "type": "memfile" },
|
|
"subnet6": [
|
|
{
|
|
"id": 1,
|
|
"subnet": "2001:db8:1::/64",
|
|
"pools": [ { "pool": "2001:db8:1::/80" } ],
|
|
"pd-pools": [
|
|
{ "prefix": "2001:db8:8::", "prefix-len": 56, "delegated-len": 64 }
|
|
],
|
|
"reservations": [
|
|
{ "duid": "00:01:02:03", "ip-addresses": [ "2001:db8:1::60" ] }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# Runtime pages are intentionally read-only: they expose useful operational
|
|
# data from memfile leases without inventing a DHCP "connections" concept.
|
|
my $leases_active = cgi_ok('runtime.cgi', { mode => 'active' },
|
|
'active leases page');
|
|
html_has($leases_active, qr/192\.0\.2\.50.*alpha/s,
|
|
'active leases show DHCPv4 memfile lease');
|
|
html_has($leases_active, qr/2001:db8:1::50.*bravo/s,
|
|
'active leases show DHCPv6 memfile lease');
|
|
unlike($leases_active, qr/192\.0\.2\.51|expired6/,
|
|
'active leases omit expired memfile rows');
|
|
html_has($leases_active,
|
|
qr/used by DHCPv4.*\Q$dhcp4_lease_file\E.*DHCPv6.*\Q$dhcp6_lease_file\E/s,
|
|
'active leases intro shows configured DHCPv4 and DHCPv6 lease files');
|
|
unlike($leases_active, qr/Lease file:\s/,
|
|
'active leases do not repeat lease file labels per service');
|
|
my $leases_pools = cgi_ok('runtime.cgi', { mode => 'pools' },
|
|
'pool usage page');
|
|
html_has($leases_pools, qr/192\.0\.2\.0\/24.*1/s,
|
|
'pool usage counts active DHCPv4 leases by subnet ID');
|
|
html_has($leases_pools, qr/2001:db8:1::\/64.*1.*1.*1/s,
|
|
'pool usage shows DHCPv6 pools, prefix pools, reservations, and leases');
|
|
my $leases_stats = cgi_ok('runtime.cgi', { mode => 'stats' },
|
|
'lease statistics page');
|
|
html_has($leases_stats, qr/\Q$dhcp4_lease_file\E/s,
|
|
'lease statistics show the configured DHCPv4 lease file');
|
|
html_has($leases_stats, qr/\Q$dhcp6_lease_file\E/s,
|
|
'lease statistics show the configured DHCPv6 lease file');
|
|
html_has($leases_stats, qr/DHCPv4.*1.*2.*1/s,
|
|
'lease statistics count DHCPv4 active, total, and inactive leases');
|
|
html_has($leases_stats, qr/DHCPv6.*1.*2.*1/s,
|
|
'lease statistics count DHCPv6 active, total, and inactive leases');
|
|
my $leases_logs = cgi_ok('runtime.cgi', { mode => 'logs' },
|
|
'recent Kea logs page');
|
|
html_has($leases_logs, qr/No recent log lines were found|DHCPv4/s,
|
|
'recent logs page renders even without journal output');
|
|
write_plain_file($dhcp4_conf, $orig_dhcp4_conf);
|
|
write_plain_file($dhcp6_conf, $orig_dhcp6_conf);
|
|
|
|
my %global_modes = (
|
|
ddns_sender => qr/Name-change request destination.*Enable updates.*Update behavior/s,
|
|
interfaces => qr/Listen on interfaces/,
|
|
storage => qr/Lease database.*Control socket/s,
|
|
logging => qr/Loggers.*kea-dhcp4.*INFO.*stdout/s,
|
|
timers => qr/Renew timer.*Valid lifetime/s,
|
|
options => qr/Common options.*Additional option data/s,
|
|
advanced => qr/Authoritative.*DHCP server identifier/s,
|
|
);
|
|
foreach my $mode (sort keys %global_modes) {
|
|
my $html = cgi_ok('edit_options.cgi',
|
|
{ version => 4, mode => $mode },
|
|
"DHCPv4 global $mode tab");
|
|
html_has($html, $global_modes{$mode},
|
|
"DHCPv4 global $mode tab renders expected controls");
|
|
if ($mode eq 'advanced') {
|
|
unlike($html, qr/Usually leave empty\. An incorrect server identifier/,
|
|
'advanced option warnings are not printed inline');
|
|
html_has($html, qr/opt_dhcp_server_identifier/,
|
|
'advanced DHCP option label links to help');
|
|
}
|
|
if ($mode eq 'interfaces') {
|
|
html_has($html, qr/The Kea configuration file\s*<tt\b[^>]*>\Q$dhcp4_conf\E<\/tt>\s*contains comments.*Webmin saves changes from these settings pages/s,
|
|
'global editor names the commented DHCPv4 config file');
|
|
html_has($html, qr/field_interfaces.*field_dhcp_socket_type/s,
|
|
'interface settings labels link to help');
|
|
}
|
|
if ($mode eq 'storage') {
|
|
html_has($html, qr/field_lease_database_type.*field_control_socket_name/s,
|
|
'storage and control socket labels link to help');
|
|
}
|
|
if ($mode eq 'logging') {
|
|
html_has($html, qr/field_logger_name.*field_logger_pattern/s,
|
|
'logger table headings link to help');
|
|
}
|
|
if ($mode eq 'ddns_sender') {
|
|
html_has($html,
|
|
qr/field_ddns_enable_updates.*field_ddns_server_ip.*field_ddns_send_updates/s,
|
|
'DHCP-DDNS sender labels link to help');
|
|
}
|
|
}
|
|
|
|
my $v6_interfaces = cgi_ok('edit_options.cgi',
|
|
{ version => 6, mode => 'interfaces' },
|
|
'DHCPv6 global interfaces tab warning check');
|
|
html_has($v6_interfaces, qr/DHCPv6 does not provide the default router/s,
|
|
'DHCPv6 settings warn that Router Advertisements provide the default route');
|
|
my $v6_index = cgi_ok('index.cgi', { mode => 'dhcp6' },
|
|
'DHCPv6 index tab warning check');
|
|
html_has($v6_index, qr/DHCPv6 does not provide the default router/s,
|
|
'DHCPv6 index tab warns that Router Advertisements provide the default route');
|
|
my $v6_ddns_sender_tab = cgi_ok('edit_options.cgi',
|
|
{ version => 6, mode => 'ddns_sender' },
|
|
'DHCPv6 DDNS sender tab');
|
|
html_has($v6_ddns_sender_tab,
|
|
qr/Configure how DHCPv6 sends DNS update requests.*Name-change request destination/s,
|
|
'DHCPv6 DDNS sender tab renders sender controls');
|
|
|
|
my $v6_options = cgi_ok('edit_options.cgi',
|
|
{ version => 6, mode => 'options' },
|
|
'DHCPv6 global options tab');
|
|
html_has($v6_options, qr/DNS servers.*Unicast address/s,
|
|
'DHCPv6 named common options render');
|
|
html_has($v6_options, qr/2001:db8::1/s,
|
|
'DHCPv6 numeric option code 12 is promoted to unicast field');
|
|
html_has($v6_options, qr/class=["'][^"']*\boption-data-table\b/s,
|
|
'additional option editor uses option-data-table wrapper');
|
|
html_has($v6_options, qr/field_option_name.*field_option_data/s,
|
|
'additional option-data headings link to help');
|
|
|
|
my %ddns_modes = (
|
|
listener => qr/Listener.*IP address.*DNS server timeout.*Control socket/s,
|
|
zones => qr/Forward DDNS.*example\.org\..*Reverse DDNS/s,
|
|
tsig => qr/TSIG keys.*ddns-key.*hmac-sha256/s,
|
|
logging => qr/Loggers.*kea-dhcp-ddns.*INFO.*stdout/s,
|
|
);
|
|
foreach my $mode (sort keys %ddns_modes) {
|
|
my $html = cgi_ok('edit_ddns.cgi',
|
|
{ mode => $mode },
|
|
"DHCP-DDNS $mode tab");
|
|
html_has($html, $ddns_modes{$mode},
|
|
"DHCP-DDNS $mode tab renders expected controls");
|
|
if ($mode eq 'listener') {
|
|
html_has($html, qr/The Kea configuration file\s*<tt\b[^>]*>\Q$ddns_conf\E<\/tt>\s*contains comments/s,
|
|
'DHCP-DDNS editor names the commented D2 config file');
|
|
html_has($html, qr/field_ddns_ip_address.*field_ddns_timeout.*field_control_socket_name/s,
|
|
'DHCP-DDNS listener labels link to help');
|
|
html_has($html, qr/<select[^>]+name=["']ncr_protocol["'].*value=["']UDP["']/s,
|
|
'DHCP-DDNS listener constrains NCR protocol values');
|
|
html_has($html, qr/<select[^>]+name=["']ncr_format["'].*value=["']JSON["']/s,
|
|
'DHCP-DDNS listener constrains NCR format values');
|
|
}
|
|
if ($mode eq 'zones') {
|
|
html_has($html, qr/field_ddns_domain_name.*field_ddns_domain_port/s,
|
|
'DHCP-DDNS zone headings link to help');
|
|
html_has($html, qr/Mixed per-server ports/s,
|
|
'DHCP-DDNS zones make mixed server ports visible');
|
|
}
|
|
if ($mode eq 'tsig') {
|
|
html_has($html, qr/field_tsig_key_name.*field_tsig_key_secret/s,
|
|
'DHCP-DDNS TSIG headings link to help');
|
|
html_has($html, qr/<select[^>]+name=["']key_algorithm_0["'].*hmac-sha256/s,
|
|
'DHCP-DDNS TSIG editor constrains algorithm values');
|
|
unlike($html, qr/ZmFrZQ==/,
|
|
'DHCP-DDNS TSIG editor does not print existing secrets');
|
|
}
|
|
}
|
|
|
|
my $ddns_listener_only_save = run_cgi('save_ddns.cgi', {
|
|
ip_address => '127.0.0.2',
|
|
port => 53002,
|
|
dns_server_timeout => 750,
|
|
control_socket_type => 'unix',
|
|
control_socket_name => 'kea-ddns-ctrl-socket',
|
|
});
|
|
is($ddns_listener_only_save->{status}, 0,
|
|
'DHCP-DDNS listener-only save exits cleanly')
|
|
or diag($ddns_listener_only_save->{err});
|
|
my ($listener_only_ddns, $listener_only_ddns_err) =
|
|
kea_read_component_config(kea_component('ddns'));
|
|
ok(!defined($listener_only_ddns_err),
|
|
'listener-only DHCP-DDNS save reads back cleanly');
|
|
is($listener_only_ddns->{'forward-ddns'}->{'ddns-domains'}->[0]->{'name'},
|
|
'example.org.',
|
|
'listener-only save preserves forward DDNS domains');
|
|
is($listener_only_ddns->{'reverse-ddns'}->{'ddns-domains'}->[0]->{'name'},
|
|
'2.0.192.in-addr.arpa.',
|
|
'listener-only save preserves reverse DDNS domains');
|
|
is($listener_only_ddns->{'tsig-keys'}->[0]->{'secret'}, 'ZmFrZQ==',
|
|
'listener-only save preserves TSIG keys');
|
|
is($listener_only_ddns->{'loggers'}->[0]->{'name'}, 'kea-dhcp-ddns',
|
|
'listener-only save preserves loggers');
|
|
is($listener_only_ddns->{'dns-server-timeout'}, 750,
|
|
'listener-only save stores the D2 DNS server timeout');
|
|
my $ddns_non_default_loopback = cgi_ok('index.cgi', { mode => 'ddns' },
|
|
'DHCP-DDNS non-default loopback summary');
|
|
html_has($ddns_non_default_loopback,
|
|
qr/Non-default loopback address.*sender settings point to the same listener/s,
|
|
'DHCP-DDNS summary explains non-default loopback listener');
|
|
unlike($ddns_non_default_loopback,
|
|
qr/D2 is normally bound to loopback\./,
|
|
'DHCP-DDNS summary does not show the stronger non-loopback warning for 127.0.0.2');
|
|
|
|
my $ddns_zone_only_save = run_cgi('save_ddns.cgi', {
|
|
fwd_name_0 => 'example.org.',
|
|
fwd_key_0 => 'ddns-key',
|
|
fwd_servers_0 => '192.0.2.53 192.0.2.54',
|
|
fwd_port_0 => '',
|
|
});
|
|
is($ddns_zone_only_save->{status}, 0,
|
|
'DHCP-DDNS zone-only save exits cleanly')
|
|
or diag($ddns_zone_only_save->{err});
|
|
my ($zone_only_ddns, $zone_only_ddns_err) =
|
|
kea_read_component_config(kea_component('ddns'));
|
|
ok(!defined($zone_only_ddns_err),
|
|
'zone-only DHCP-DDNS save reads back cleanly');
|
|
is_deeply(
|
|
[ map { $_->{'port'} }
|
|
@{$zone_only_ddns->{'forward-ddns'}->{'ddns-domains'}->[0]->{'dns-servers'}} ],
|
|
[ 53, 5353 ],
|
|
'zone-only save preserves mixed DNS server ports when shared port is blank');
|
|
is($zone_only_ddns->{'ip-address'}, '127.0.0.2',
|
|
'zone-only save preserves listener fields');
|
|
|
|
my $ddns_unknown_key_save = run_cgi('save_ddns.cgi', {
|
|
fwd_name_0 => 'missing-key.example.',
|
|
fwd_key_0 => 'missing-key',
|
|
fwd_servers_0 => '192.0.2.53',
|
|
});
|
|
like($ddns_unknown_key_save->{out}.$ddns_unknown_key_save->{err},
|
|
qr/no TSIG key with that name is defined/,
|
|
'DHCP-DDNS save rejects domains that reference unknown TSIG keys');
|
|
|
|
my $ddns_bad_server_save = run_cgi('save_ddns.cgi', {
|
|
fwd_name_0 => 'bad-server.example.',
|
|
fwd_servers_0 => 'ns1.example.org',
|
|
});
|
|
like($ddns_bad_server_save->{out}.$ddns_bad_server_save->{err},
|
|
qr/must be an IPv4 or IPv6 address/,
|
|
'DHCP-DDNS save rejects DNS server hostnames');
|
|
|
|
my $ddns_blank_algorithm_save = run_cgi('save_ddns.cgi', {
|
|
key_name_0 => 'new-ddns-key',
|
|
key_secret_0 => 'ZmFrZQ==',
|
|
});
|
|
like($ddns_blank_algorithm_save->{out}.$ddns_blank_algorithm_save->{err},
|
|
qr/must select an algorithm/,
|
|
'DHCP-DDNS save rejects filled TSIG rows without an algorithm');
|
|
|
|
my $ddns_tsig_blank_save = run_cgi('save_ddns.cgi', {
|
|
key_name_0 => 'ddns-key',
|
|
key_algorithm_0 => 'hmac-sha256',
|
|
key_secret_0 => '',
|
|
});
|
|
is($ddns_tsig_blank_save->{status}, 0,
|
|
'DHCP-DDNS TSIG save with blank existing secret exits cleanly')
|
|
or diag($ddns_tsig_blank_save->{err});
|
|
my ($blank_secret_ddns, $blank_secret_ddns_err) =
|
|
kea_read_component_config(kea_component('ddns'));
|
|
ok(!defined($blank_secret_ddns_err),
|
|
'blank-secret DHCP-DDNS save reads back cleanly');
|
|
is($blank_secret_ddns->{'tsig-keys'}->[0]->{'secret'}, 'ZmFrZQ==',
|
|
'blank TSIG secret keeps the existing secret');
|
|
|
|
my $ddns_bad_debug_save = run_cgi('save_ddns.cgi', {
|
|
log_name_0 => 'kea-dhcp-ddns',
|
|
log_severity_0 => 'INFO',
|
|
log_debuglevel_0 => 4,
|
|
log_output_0 => 'stdout',
|
|
log_pattern_0 => '%-5p %m\n',
|
|
});
|
|
like($ddns_bad_debug_save->{out}.$ddns_bad_debug_save->{err},
|
|
qr/debug level can only be non-zero when severity is DEBUG/,
|
|
'DHCP-DDNS save rejects non-zero debug level below DEBUG severity');
|
|
|
|
my $argument_check_ddns_binary = "$confdir/kea-dhcp-ddns";
|
|
write_plain_file($argument_check_ddns_binary, <<'EOF');
|
|
#!/bin/sh
|
|
if [ "$1" = "-t" ] && [ -r "$2" ] && [ -z "$3" ]; then
|
|
exit 0
|
|
fi
|
|
echo "bad validation arguments: $*" >&2
|
|
exit 1
|
|
EOF
|
|
chmod(0755, $argument_check_ddns_binary)
|
|
or die "chmod $argument_check_ddns_binary: $!";
|
|
write_module_config({ ddns_path => $argument_check_ddns_binary });
|
|
my $ddns_validation_args = run_cgi('save_ddns.cgi', {
|
|
ip_address => '127.0.0.3',
|
|
});
|
|
is($ddns_validation_args->{status}, 0,
|
|
'DHCP-DDNS native validation passes a readable config file to the test flag')
|
|
or diag($ddns_validation_args->{out}.$ddns_validation_args->{err});
|
|
my ($after_validation_args, $after_validation_args_err) =
|
|
kea_read_component_config(kea_component('ddns'));
|
|
ok(!defined($after_validation_args_err),
|
|
'DHCP-DDNS config reads after argument-order validation');
|
|
is($after_validation_args->{'ip-address'}, '127.0.0.3',
|
|
'successful native validation saves DHCP-DDNS config');
|
|
|
|
my $failing_ddns_binary = "$confdir/kea-dhcp-ddns";
|
|
write_plain_file($failing_ddns_binary, <<'EOF');
|
|
#!/bin/sh
|
|
echo "intentional D2 validation failure" >&2
|
|
exit 1
|
|
EOF
|
|
chmod(0755, $failing_ddns_binary) or die "chmod $failing_ddns_binary: $!";
|
|
write_module_config({ ddns_path => $failing_ddns_binary });
|
|
my $ddns_validation_fail = run_cgi('save_ddns.cgi', {
|
|
ip_address => '127.0.0.99',
|
|
});
|
|
like($ddns_validation_fail->{out}.$ddns_validation_fail->{err},
|
|
qr/intentional D2 validation failure/,
|
|
'DHCP-DDNS save reports native Kea validation errors');
|
|
my ($after_validation_fail, $after_validation_fail_err) =
|
|
kea_read_component_config(kea_component('ddns'));
|
|
ok(!defined($after_validation_fail_err),
|
|
'DHCP-DDNS config still reads after validation failure');
|
|
is($after_validation_fail->{'ip-address'}, '127.0.0.3',
|
|
'failed native validation leaves existing DHCP-DDNS config unchanged');
|
|
write_module_config();
|
|
|
|
my $ddns_sender_save = run_cgi('save_options.cgi', {
|
|
version => 4,
|
|
interfaces => '*',
|
|
lease_type => 'memfile',
|
|
lease_lfc_interval => 3600,
|
|
control_socket_type => 'unix',
|
|
control_socket_name => 'kea4-ctrl-socket',
|
|
ddns_enable_updates => 'true',
|
|
ddns_server_ip => '127.0.0.3',
|
|
ddns_server_port => 53002,
|
|
ddns_sender_ip => '127.0.0.1',
|
|
ddns_sender_port => 53003,
|
|
ddns_max_queue_size => 2048,
|
|
ddns_ncr_protocol => 'UDP',
|
|
ddns_ncr_format => 'JSON',
|
|
'ddns-send-updates' => 'true',
|
|
'ddns-override-no-update' => 'false',
|
|
'ddns-override-client-update' => 'true',
|
|
'ddns-update-on-renew' => 'true',
|
|
'ddns-replace-client-name' => 'when-present',
|
|
'ddns-generated-prefix' => 'host',
|
|
'ddns-qualifying-suffix' => 'example.org.',
|
|
'ddns-conflict-resolution-mode' => 'check-with-dhcid',
|
|
'hostname-char-set' => '[^A-Za-z0-9.-]',
|
|
'hostname-char-replacement' => '-',
|
|
});
|
|
is($ddns_sender_save->{status}, 0,
|
|
'DHCPv4 DDNS sender settings save exits cleanly')
|
|
or diag($ddns_sender_save->{out}.$ddns_sender_save->{err});
|
|
my ($saved_ddns_sender_root, $saved_ddns_sender_err) =
|
|
kea_read_component_config(kea_dhcp_component(4));
|
|
ok(!defined($saved_ddns_sender_err),
|
|
'DHCPv4 config reads after DDNS sender save');
|
|
is($saved_ddns_sender_root->{'dhcp-ddns'}->{'server-ip'}, '127.0.0.3',
|
|
'DDNS sender save stores D2 server address');
|
|
is($saved_ddns_sender_root->{'dhcp-ddns'}->{'server-port'}, 53002,
|
|
'DDNS sender save stores D2 server port as an integer');
|
|
is($saved_ddns_sender_root->{'dhcp-ddns'}->{'enable-updates'}, 1,
|
|
'DDNS sender save stores enable-updates boolean');
|
|
is($saved_ddns_sender_root->{'dhcp-ddns'}->{'max-queue-size'}, 2048,
|
|
'DDNS sender save stores max queue size');
|
|
is($saved_ddns_sender_root->{'ddns-send-updates'}, 1,
|
|
'DDNS sender save stores update behavior booleans');
|
|
is($saved_ddns_sender_root->{'ddns-override-no-update'}, 0,
|
|
'DDNS sender save stores false update behavior booleans');
|
|
is($saved_ddns_sender_root->{'ddns-replace-client-name'}, 'when-present',
|
|
'DDNS sender save stores client-name replacement policy');
|
|
is($saved_ddns_sender_root->{'hostname-char-replacement'}, '-',
|
|
'DDNS sender save stores hostname replacement character');
|
|
|
|
my $v6_ddns_sender_save = run_cgi('save_options.cgi', {
|
|
version => 6,
|
|
interfaces => '*',
|
|
lease_type => 'memfile',
|
|
lease_lfc_interval => 3600,
|
|
control_socket_type => 'unix',
|
|
control_socket_name => 'kea6-ctrl-socket',
|
|
ddns_enable_updates => 'true',
|
|
ddns_server_ip => '127.0.0.3',
|
|
ddns_server_port => 53002,
|
|
ddns_ncr_protocol => 'UDP',
|
|
ddns_ncr_format => 'JSON',
|
|
'ddns-send-updates' => 'true',
|
|
'ddns-update-on-renew' => 'true',
|
|
});
|
|
is($v6_ddns_sender_save->{status}, 0,
|
|
'DHCPv6 DDNS sender settings save exits cleanly')
|
|
or diag($v6_ddns_sender_save->{out}.$v6_ddns_sender_save->{err});
|
|
my ($saved_v6_ddns_sender_root, $saved_v6_ddns_sender_err) =
|
|
kea_read_component_config(kea_dhcp_component(6));
|
|
ok(!defined($saved_v6_ddns_sender_err),
|
|
'DHCPv6 config reads after DDNS sender save');
|
|
is($saved_v6_ddns_sender_root->{'dhcp-ddns'}->{'server-ip'}, '127.0.0.3',
|
|
'DHCPv6 DDNS sender save stores D2 server address');
|
|
is($saved_v6_ddns_sender_root->{'dhcp-ddns'}->{'enable-updates'}, 1,
|
|
'DHCPv6 DDNS sender save stores enable-updates boolean');
|
|
is($saved_v6_ddns_sender_root->{'ddns-send-updates'}, 1,
|
|
'DHCPv6 DDNS sender save stores DDNS behavior');
|
|
|
|
my ($password_c, $password_root, $password_data, $password_err) =
|
|
kea_read_dhcp_config(4);
|
|
ok(!defined($password_err), 'DHCPv4 config reads before password preservation test');
|
|
$password_root->{'lease-database'}->{'password'} = 'oldsecret';
|
|
write_plain_file($dhcp4_conf, kea_encode_config($password_data));
|
|
my $storage_with_secret = cgi_ok('edit_options.cgi',
|
|
{ version => 4, mode => 'storage' },
|
|
'DHCPv4 storage tab with existing secret');
|
|
unlike($storage_with_secret, qr/oldsecret/,
|
|
'lease database editor does not print existing database passwords');
|
|
html_has($storage_with_secret, qr/Configured; leave blank to keep unchanged/,
|
|
'lease database editor explains blank password preservation');
|
|
my $password_blank_save = run_cgi('save_options.cgi', {
|
|
version => 4,
|
|
interfaces => '*',
|
|
lease_type => 'memfile',
|
|
lease_lfc_interval => 3600,
|
|
lease_password => '',
|
|
});
|
|
is($password_blank_save->{status}, 0,
|
|
'DHCPv4 storage save with blank existing password exits cleanly')
|
|
or diag($password_blank_save->{out}.$password_blank_save->{err});
|
|
my ($after_password_root, $after_password_err) =
|
|
kea_read_component_config(kea_dhcp_component(4));
|
|
ok(!defined($after_password_err),
|
|
'DHCPv4 config reads after blank password save');
|
|
is($after_password_root->{'lease-database'}->{'password'}, 'oldsecret',
|
|
'blank database password keeps the existing secret');
|
|
|
|
my %subnet_modes = (
|
|
general => qr/Subnet details.*Shared network/s,
|
|
pools => qr/Pools.*Address pool.*Prefix delegation pools/s,
|
|
reservations => qr/Identifier.*Identifier value.*Hostname/s,
|
|
options => qr/Common options/s,
|
|
advanced => qr/Interface.*Relay IP addresses/s,
|
|
);
|
|
foreach my $mode (sort keys %subnet_modes) {
|
|
my $html = cgi_ok('edit_subnet.cgi',
|
|
{ version => 6, new => 1, mode => $mode },
|
|
"DHCPv6 subnet $mode tab");
|
|
html_has($html, $subnet_modes{$mode},
|
|
"DHCPv6 subnet $mode tab renders expected controls");
|
|
unlike($html, qr/Authoritative/,
|
|
"DHCPv6 subnet $mode tab omits DHCPv4-only authoritative control")
|
|
if ($mode eq 'advanced');
|
|
}
|
|
my $v4_subnet_general = cgi_ok('edit_subnet.cgi',
|
|
{ version => 4, new => 1, mode => 'general' },
|
|
'DHCPv4 subnet general tab');
|
|
html_has($v4_subnet_general, qr/Calculated subnet mask/s,
|
|
'DHCPv4 subnet form shows calculated mask as non-editable data');
|
|
html_has($v4_subnet_general, qr/field_subnet_id.*field_calculated_subnet_mask/s,
|
|
'subnet general labels link to help');
|
|
my $subnet_pools = cgi_ok('edit_subnet.cgi',
|
|
{ version => 6, new => 1, mode => 'pools' },
|
|
'DHCPv6 subnet pools tab wrapper check');
|
|
html_has($subnet_pools, qr/class=["'][^"']*\boption-data-table\b/s,
|
|
'prefix delegation pools use option-data-table wrapper');
|
|
html_has($subnet_pools, qr/field_address_pool.*field_pd_prefix/s,
|
|
'pool labels link to help');
|
|
my $subnet_reservations = cgi_ok('edit_subnet.cgi',
|
|
{ version => 6, new => 1, mode => 'reservations' },
|
|
'DHCPv6 subnet reservations tab help check');
|
|
html_has($subnet_reservations,
|
|
qr/field_reservation_identifier_type.*field_reservation_prefixes/s,
|
|
'reservation labels link to help');
|
|
my $v4_subnet_advanced = cgi_ok('edit_subnet.cgi',
|
|
{ version => 4, new => 1, mode => 'advanced' },
|
|
'DHCPv4 subnet advanced tab help check');
|
|
html_has($v4_subnet_advanced, qr/field_next_server.*field_server_hostname.*field_boot_file_name/s,
|
|
'advanced subnet fields link to help files');
|
|
html_has($v4_subnet_advanced, qr/Authoritative/,
|
|
'DHCPv4 subnet advanced tab includes authoritative control');
|
|
my $v6_global_advanced = cgi_ok('edit_options.cgi',
|
|
{ version => 6, mode => 'advanced' },
|
|
'DHCPv6 global advanced tab');
|
|
unlike($v6_global_advanced, qr/Authoritative/,
|
|
'DHCPv6 global advanced tab omits DHCPv4-only authoritative control');
|
|
my $v6_shared_advanced = cgi_ok('edit_shared.cgi',
|
|
{ version => 6, new => 1, mode => 'advanced' },
|
|
'DHCPv6 shared advanced tab');
|
|
unlike($v6_shared_advanced, qr/Authoritative/,
|
|
'DHCPv6 shared advanced tab omits DHCPv4-only authoritative control');
|
|
ok(-r "$module_dir/help/opt_dhcp_server_identifier.html",
|
|
'DHCP server identifier help file exists');
|
|
ok(-r "$module_dir/help/field_interfaces.html" &&
|
|
-r "$module_dir/help/field_logger_pattern.html" &&
|
|
-r "$module_dir/help/field_reservation_identifier_type.html",
|
|
'new structured field help files exist');
|
|
ok(-r "$module_dir/help/field_ddns_enable_updates.html" &&
|
|
-r "$module_dir/help/field_ddns_server_ip.html" &&
|
|
-r "$module_dir/help/field_hostname_char_replacement.html",
|
|
'DHCP-DDNS sender help files exist');
|
|
|
|
my %shared_modes = (
|
|
general => qr/Shared network details.*Relay IP addresses/s,
|
|
options => qr/Common options/s,
|
|
advanced => qr/Advanced shared network settings|Authoritative/s,
|
|
);
|
|
foreach my $mode (sort keys %shared_modes) {
|
|
my $html = cgi_ok('edit_shared.cgi',
|
|
{ version => 4, new => 1, mode => $mode },
|
|
"DHCPv4 shared $mode tab");
|
|
html_has($html, $shared_modes{$mode},
|
|
"DHCPv4 shared $mode tab renders expected controls");
|
|
if ($mode eq 'general') {
|
|
html_has($html, qr/field_shared_network_name.*field_description/s,
|
|
'shared-network general labels link to help');
|
|
}
|
|
}
|
|
|
|
my $manual = cgi_ok('edit_text.cgi', {}, 'manual file editor');
|
|
html_has($manual, qr/\Q$api_password\E/,
|
|
'manual file editor lists Control Agent password file');
|
|
unlike($manual, qr/Webmin saves changes from these settings pages/s,
|
|
'manual file editor does not warn about comment loss');
|
|
|
|
my $acl_html = '';
|
|
{
|
|
open(my $capture, ">", \$acl_html) or die "capture ACL HTML: $!";
|
|
my $oldfh = select($capture);
|
|
print ui_table_start('ACL', undef, 4);
|
|
acl_security_form({ view => 1, edit => 0, apply => 1 });
|
|
print ui_table_end();
|
|
select($oldfh);
|
|
}
|
|
my @acl_full_width_rows = ($acl_html =~ /\bcolspan=['"]?3['"]?/g);
|
|
is(scalar(@acl_full_width_rows), 11,
|
|
'ACL permissions render as full-width table rows');
|
|
unlike($acl_html, qr/name="view"/,
|
|
'ACL form does not render a module-wide view switch');
|
|
html_has($acl_html,
|
|
qr/name="dhcp4".*name="dhcp6".*name="ddns".*name="services".*name="runtime".*name="edit4".*name="edit6".*name="editddns".*name="manual".*name="apply".*name="install"/s,
|
|
'ACL form renders all Kea permission radios');
|
|
# Parser/UI helper contract checks. These are not full Kea validation, but they
|
|
# protect the Webmin-specific mapping between named fields and raw option-data.
|
|
my $opts = [
|
|
{ name => 'domain-name-servers', data => '192.0.2.1' },
|
|
{ code => 15, data => 'example.org' },
|
|
{ name => 'default-ip-ttl', data => '0xf0' },
|
|
{ name => 'subnet-mask', data => '255.255.255.0' },
|
|
];
|
|
is(kea_option_value($opts, 'domain-name', 4), 'example.org',
|
|
'numeric DHCPv4 option code is readable through named common field');
|
|
is_deeply(kea_other_options($opts, 4),
|
|
[ { name => 'default-ip-ttl', data => '0xf0' } ],
|
|
'other option-data excludes common and Kea-managed options');
|
|
|
|
{
|
|
local %in = (
|
|
'opt_name_0' => '',
|
|
'opt_code_0' => 12,
|
|
'opt_data_0' => '2001:db8::1',
|
|
'opt_space_0' => '',
|
|
);
|
|
my $parsed = kea_parse_other_option_rows([], 6, 'opt_');
|
|
is_deeply($parsed, [ { code => 12, data => '2001:db8::1' } ],
|
|
'numeric DHCPv6 option code 12 is accepted without duplication');
|
|
is(kea_option_value($parsed, 'unicast', 6), '2001:db8::1',
|
|
'numeric DHCPv6 option code 12 maps back to the unicast field');
|
|
}
|
|
|
|
{
|
|
local %in = (
|
|
'log_name_0' => 'kea-dhcp4',
|
|
'log_severity_0' => 'DEBUG',
|
|
'log_debuglevel_0' => '50',
|
|
'log_output_0' => 'stdout',
|
|
'log_pattern_0' => '%-5p %m\n',
|
|
);
|
|
my $parsed = kea_parse_logger_rows([], 'log_');
|
|
is($parsed->[0]->{'name'}, 'kea-dhcp4',
|
|
'logger parser keeps the logger name');
|
|
is($parsed->[0]->{'output-options'}->[0]->{'pattern'}, "%-5p %m\n",
|
|
'logger parser converts visible newline escapes back to JSON values');
|
|
}
|
|
|
|
{
|
|
local %in = (
|
|
'fwd_name_0' => 'example.net.',
|
|
'fwd_key_0' => 'ddns-key',
|
|
'fwd_servers_0' => '192.0.2.53 192.0.2.54',
|
|
'fwd_port_0' => '53',
|
|
);
|
|
my $parsed = kea_parse_ddns_domain_rows([], 'fwd_');
|
|
is($parsed->[0]->{'name'}, 'example.net.',
|
|
'DDNS domain parser keeps the domain name');
|
|
is_deeply([ map { $_->{'ip-address'} } @{$parsed->[0]->{'dns-servers'}} ],
|
|
[ '192.0.2.53', '192.0.2.54' ],
|
|
'DDNS domain parser stores multiple DNS servers');
|
|
is($parsed->[0]->{'dns-servers'}->[0]->{'port'}, 53,
|
|
'DDNS domain parser applies the DNS server port');
|
|
}
|
|
|
|
{
|
|
local %in = (
|
|
'fwd_name_0' => 'default-port.example.',
|
|
'fwd_servers_0' => '192.0.2.55',
|
|
'fwd_port_0' => '',
|
|
);
|
|
my $parsed = kea_parse_ddns_domain_rows([], 'fwd_');
|
|
is($parsed->[0]->{'dns-servers'}->[0]->{'port'}, 53,
|
|
'DDNS domain parser defaults blank server port to DNS port 53');
|
|
}
|
|
|
|
ok(kea_ddns_listener_loopback({ 'ip-address' => '127.0.0.2' }),
|
|
'D2 listener helper treats all 127/8 addresses as loopback');
|
|
ok(kea_ddns_listener_non_default_loopback({ 'ip-address' => '127.0.0.2' }),
|
|
'D2 listener helper flags non-default loopback addresses');
|
|
ok(!kea_ddns_listener_non_loopback({ 'ip-address' => '127.0.0.2' }),
|
|
'D2 listener helper does not treat 127.0.0.2 as non-loopback');
|
|
ok(kea_ddns_listener_non_loopback({ 'ip-address' => '192.0.2.10' }),
|
|
'D2 listener helper flags non-loopback listener addresses');
|
|
|
|
my $ddns_save = run_cgi('save_ddns.cgi', {
|
|
ip_address => '127.0.0.1',
|
|
port => 53001,
|
|
dns_server_timeout => 500,
|
|
ncr_protocol => 'UDP',
|
|
ncr_format => 'JSON',
|
|
control_socket_type => 'unix',
|
|
control_socket_name => 'kea-ddns-ctrl-socket',
|
|
fwd_name_0 => 'example.net.',
|
|
fwd_key_0 => 'ddns-key',
|
|
fwd_servers_0 => '192.0.2.53',
|
|
fwd_port_0 => 53,
|
|
rev_name_0 => '',
|
|
key_name_0 => 'ddns-key',
|
|
key_algorithm_0 => 'hmac-sha256',
|
|
key_secret_0 => 'ZmFrZQ==',
|
|
log_name_0 => 'kea-dhcp-ddns',
|
|
log_severity_0 => 'INFO',
|
|
log_debuglevel_0 => 0,
|
|
log_output_0 => 'stdout',
|
|
log_pattern_0 => '%-5p %m\n',
|
|
});
|
|
is($ddns_save->{status}, 0, 'DHCP-DDNS settings save exits cleanly')
|
|
or diag($ddns_save->{err});
|
|
my ($saved_ddns, $saved_ddns_err) = kea_read_component_config(kea_component('ddns'));
|
|
ok(!defined($saved_ddns_err), 'saved DHCP-DDNS config reads back cleanly');
|
|
is($saved_ddns->{'forward-ddns'}->{'ddns-domains'}->[0]->{'name'},
|
|
'example.net.',
|
|
'saved DHCP-DDNS config preserves edited forward domain');
|
|
is($saved_ddns->{'dns-server-timeout'}, 500,
|
|
'saved DHCP-DDNS config preserves DNS server timeout');
|
|
is($saved_ddns->{'ncr-protocol'}, 'UDP',
|
|
'saved DHCP-DDNS config stores NCR protocol');
|
|
is($saved_ddns->{'ncr-format'}, 'JSON',
|
|
'saved DHCP-DDNS config stores NCR format');
|
|
|
|
my $v6_authoritative_global_save = run_cgi('save_options.cgi', {
|
|
version => 6,
|
|
authoritative => 'true',
|
|
});
|
|
is($v6_authoritative_global_save->{status}, 0,
|
|
'DHCPv6 global save ignores stale authoritative submission')
|
|
or diag($v6_authoritative_global_save->{err});
|
|
my ($saved_v6_global, $saved_v6_global_err) =
|
|
kea_read_component_config(kea_dhcp_component(6));
|
|
ok(!defined($saved_v6_global_err), 'DHCPv6 global config reads after save');
|
|
ok(!exists($saved_v6_global->{'authoritative'}),
|
|
'DHCPv6 global save does not write DHCPv4-only authoritative key');
|
|
|
|
my $v6_authoritative_shared_save = run_cgi('save_shared.cgi', {
|
|
version => 6,
|
|
new => 1,
|
|
name => 'v6-shared',
|
|
authoritative => 'true',
|
|
});
|
|
is($v6_authoritative_shared_save->{status}, 0,
|
|
'DHCPv6 shared-network save ignores stale authoritative submission')
|
|
or diag($v6_authoritative_shared_save->{err});
|
|
my ($saved_v6_shared_root, $saved_v6_shared_err) =
|
|
kea_read_component_config(kea_dhcp_component(6));
|
|
ok(!defined($saved_v6_shared_err), 'DHCPv6 shared-network config reads after save');
|
|
ok(!exists($saved_v6_shared_root->{'shared-networks'}->[0]->{'authoritative'}),
|
|
'DHCPv6 shared-network save does not write DHCPv4-only authoritative key');
|
|
|
|
my $v6_authoritative_subnet_save = run_cgi('save_subnet.cgi', {
|
|
version => 6,
|
|
new => 1,
|
|
id => 42,
|
|
subnet => '2001:db8:42::/64',
|
|
authoritative => 'true',
|
|
});
|
|
is($v6_authoritative_subnet_save->{status}, 0,
|
|
'DHCPv6 subnet save ignores stale authoritative submission')
|
|
or diag($v6_authoritative_subnet_save->{err});
|
|
my ($saved_v6_subnet_root, $saved_v6_subnet_err) =
|
|
kea_read_component_config(kea_dhcp_component(6));
|
|
ok(!defined($saved_v6_subnet_err), 'DHCPv6 subnet config reads after save');
|
|
ok(!exists($saved_v6_subnet_root->{'subnet6'}->[0]->{'authoritative'}),
|
|
'DHCPv6 subnet save does not write DHCPv4-only authoritative key');
|
|
|
|
{
|
|
no warnings qw(redefine once);
|
|
my $fake_dhcp4_binary = "$keadir/kea-dhcp4";
|
|
write_plain_file($fake_dhcp4_binary, "#!/bin/sh\nexit 0\n");
|
|
chmod(0755, $fake_dhcp4_binary) or die "chmod $fake_dhcp4_binary: $!";
|
|
|
|
my ($seen_cmd, $seen_tmp, $tmp_existed, $tmp_mode);
|
|
my @real_st = stat($dhcp4_conf);
|
|
local $config{'dhcp4_path'} = $fake_dhcp4_binary;
|
|
local *execute_command = sub {
|
|
my ($cmd, $stdin, $out, $err) = @_;
|
|
$seen_cmd = $cmd;
|
|
my $unquoted = $cmd;
|
|
$unquoted =~ s/\\(.)/$1/g;
|
|
($seen_tmp) = $unquoted =~ /\s-t\s+(\S+)/;
|
|
$tmp_existed = defined($seen_tmp) && -e $seen_tmp ? 1 : 0;
|
|
if ($tmp_existed) {
|
|
my @tmp_st = stat($seen_tmp);
|
|
$tmp_mode = $tmp_st[2] & 07777;
|
|
}
|
|
$$out = '';
|
|
$$err = '';
|
|
return 0;
|
|
};
|
|
|
|
my $test_err = kea_validate_component_json(
|
|
kea_dhcp_component(4), "{ \"Dhcp4\": {} }\n");
|
|
ok(!defined($test_err),
|
|
'native validation succeeds when the test command exits cleanly');
|
|
like($seen_cmd, qr/\s-t\s+\S+/,
|
|
'native validation passes the temporary config to Kea test mode');
|
|
like($seen_tmp, qr/^\Q$keadir\E\/kea-dhcp4-test-[a-f0-9]{6}\.conf$/,
|
|
'native validation uses a visible Kea config-directory test file');
|
|
ok($tmp_existed, 'native validation temp config exists during test command');
|
|
is($tmp_mode, $real_st[2] & 07777,
|
|
'native validation temp config copies real config permissions');
|
|
ok(!-e $seen_tmp, 'native validation removes the temporary config');
|
|
}
|
|
|
|
# Exercise the write path with the same temporary configs used by the UI tests.
|
|
# This catches strict-mode filehandle regressions in Webmin's tempfile helpers.
|
|
my $save_err = kea_save_component_config(kea_dhcp_component(4), {
|
|
Dhcp4 => {
|
|
'interfaces-config' => { interfaces => [ '*' ] },
|
|
subnet4 => [],
|
|
},
|
|
});
|
|
ok(!defined($save_err), 'structured Kea config saves without an error');
|
|
my ($saved_root, $saved_err) = kea_read_component_config(kea_dhcp_component(4));
|
|
ok(!defined($saved_err), 'saved structured Kea config reads back cleanly');
|
|
is_deeply($saved_root->{'interfaces-config'}->{'interfaces'}, [ '*' ],
|
|
'saved structured Kea config preserves edited data');
|
|
|
|
done_testing();
|