Files
2026-07-13 13:12:17 +08:00

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();