项目文件夹

文件
2026-07-13 13:12:17 +08:00

2444 行
74 KiB
Perl

# kea-dhcp-lib.pl
# Helpers for the ISC Kea DHCP Webmin module.
BEGIN { push(@INC, ".."); }; ## no critic
use strict;
use warnings;
use WebminCore;
our (%config, %in, %text);
our $module_root_directory;
&init_config();
&load_kea_defaults();
# kea_can_view_dhcp(&acl, version)
# Structured edit access implies read access to that DHCP version.
sub kea_can_view_dhcp
{
my ($acl, $ver) = @_;
return $acl->{'dhcp'.$ver} || $acl->{'edit'.$ver} ? 1 : 0;
}
# kea_can_edit_dhcp(&acl, version)
# Returns true if the user may change structured DHCPv4 or DHCPv6 settings.
sub kea_can_edit_dhcp
{
my ($acl, $ver) = @_;
return $acl->{'edit'.$ver} ? 1 : 0;
}
# kea_can_view_ddns(&acl)
# DHCP-DDNS edit access implies read access to the D2 daemon settings.
sub kea_can_view_ddns
{
my ($acl) = @_;
return $acl->{'ddns'} || $acl->{'editddns'} ? 1 : 0;
}
# kea_can_edit_ddns(&acl)
# Returns true if the user may change structured DHCP-DDNS settings.
sub kea_can_edit_ddns
{
my ($acl) = @_;
return $acl->{'editddns'} ? 1 : 0;
}
# kea_can_view_services(&acl)
# Service control implies enough service visibility to choose the right action.
sub kea_can_view_services
{
my ($acl) = @_;
return $acl->{'services'} || $acl->{'apply'} ? 1 : 0;
}
# kea_can_view_runtime(&acl)
# Returns true if the user may view lease, pool, statistics, and log data.
sub kea_can_view_runtime
{
my ($acl) = @_;
return $acl->{'runtime'} ? 1 : 0;
}
# load_kea_defaults()
# When a new module is copied into an existing Webmin install, its runtime
# config file may not exist yet even though config.cgi can still display
# defaults from config.info. Fill missing runtime values from the module's
# bundled config so the module works before the first Save on the config page.
sub load_kea_defaults
{
my %defaults;
&read_file($module_root_directory."/config", \%defaults)
if ($module_root_directory && -r $module_root_directory."/config");
foreach my $k (keys %defaults) {
if (!defined($config{$k}) || $config{$k} eq '') {
$config{$k} = $defaults{$k};
}
}
}
# kea_config_value(key)
# Returns a module configuration value after defaults have been loaded.
sub kea_config_value
{
my ($key) = @_;
return $config{$key};
}
# kea_components()
# Returns descriptors for the Kea services this module knows how to inspect.
sub kea_components
{
return (
{ 'id' => 'dhcp4', 'root' => 'Dhcp4',
'conf' => 'dhcp4_conf', 'path' => 'dhcp4_path',
'pid' => 'dhcp4_pid_file', 'proc' => 'kea-dhcp4',
'unit' => &kea_config_value('dhcp4_unit'),
'label' => $text{'comp_dhcp4'} || 'DHCPv4' },
{ 'id' => 'dhcp6', 'root' => 'Dhcp6',
'conf' => 'dhcp6_conf', 'path' => 'dhcp6_path',
'pid' => 'dhcp6_pid_file', 'proc' => 'kea-dhcp6',
'unit' => &kea_config_value('dhcp6_unit'),
'label' => $text{'comp_dhcp6'} || 'DHCPv6' },
{ 'id' => 'ddns', 'root' => 'DhcpDdns',
'conf' => 'ddns_conf', 'path' => 'ddns_path',
'pid' => 'ddns_pid_file', 'proc' => 'kea-dhcp-ddns',
'unit' => &kea_config_value('ddns_unit'),
'label' => $text{'comp_ddns'} || 'DHCP-DDNS' },
{ 'id' => 'ctrl', 'root' => 'Control-agent',
'conf' => 'ctrl_agent_conf', 'path' => 'ctrl_agent_path',
'pid' => 'ctrl_agent_pid_file', 'proc' => 'kea-ctrl-agent',
'unit' => &kea_config_value('ctrl_agent_unit'),
'label' => $text{'comp_ctrl'} || 'Control Agent' },
);
}
# kea_component(id)
# Looks up one Kea component descriptor by its internal module id.
sub kea_component
{
my ($id) = @_;
foreach my $c (&kea_components()) {
return $c if ($c->{'id'} eq $id);
}
return;
}
# kea_dhcp_component(version)
# Returns the DHCPv4 or DHCPv6 component descriptor for a numeric version.
sub kea_dhcp_component
{
my ($ver) = @_;
return &kea_component($ver == 6 ? 'dhcp6' : 'dhcp4');
}
# kea_component_systemd_unit(&component)
# Returns the configured systemd unit for this component. Distro-specific unit
# names belong in config-* defaults rather than guessed at runtime.
sub kea_component_systemd_unit
{
my ($c) = @_;
return $c->{'unit'} || "";
}
# kea_subnet_key(version)
# Returns the Kea JSON subnet array key for DHCPv4 or DHCPv6.
sub kea_subnet_key
{
my ($ver) = @_;
return $ver == 6 ? 'subnet6' : 'subnet4';
}
# kea_config_file(&component)
# Returns the configured JSON file path for a component descriptor.
sub kea_config_file
{
my ($c) = @_;
return &kea_config_value($c->{'conf'});
}
# kea_editable_components()
# Returns components that have a configured file path for manual editing.
sub kea_editable_components
{
my @rv;
foreach my $c (&kea_components()) {
push(@rv, $c) if (&kea_config_file($c));
}
return @rv;
}
# kea_control_agent_password_files()
# Returns password files referenced by the Kea Control Agent authentication
# section, plus the packaged default derived from the Control Agent config dir.
sub kea_control_agent_password_files
{
my @rv;
my $c = &kea_component('ctrl');
my $file = &kea_config_file($c);
return @rv if (!$file);
# Debian-style packages gate the Control Agent with this file even when the
# config only refers to it by a relative name.
my $dir = &kea_dirname($file);
my $default = "$dir/kea-api-password";
$default = &simplify_path($default) if ($default =~ /^\//);
push(@rv, $default);
if (-r $file) {
my ($root, $err) = &kea_read_component_config($c);
if (!$err && ref($root->{'authentication'}) eq 'HASH') {
my $auth = $root->{'authentication'};
my $base = $auth->{'directory'} || $dir;
my $clients = $auth->{'clients'};
if (ref($clients) eq 'ARRAY') {
foreach my $client (@$clients) {
next if (ref($client) ne 'HASH');
my $pfile = $client->{'password-file'};
next if (!defined($pfile) || $pfile eq '');
# Kea resolves relative password-file values below
# authentication/directory.
my $path = $pfile =~ /^\// ? $pfile : "$base/$pfile";
$path = &simplify_path($path) if ($path =~ /^\//);
push(@rv, $path) if ($path);
}
}
}
}
return grep { $_ } &unique(@rv);
}
# kea_manual_edit_files()
# Returns descriptors for files the raw editor may manage.
sub kea_manual_edit_files
{
my (@rv, %seen);
# Manual editing is deliberately limited to configured Kea files. This prevents
# the raw editor from becoming a general-purpose filesystem editor.
foreach my $c (&kea_editable_components()) {
my $file = &kea_config_file($c);
next if (!$file || $seen{$file}++);
push(@rv, { 'file' => $file, 'type' => 'config',
'component' => $c });
}
# Include Control Agent password files because a missing password can make the
# unit look inactive without any broken JSON to edit.
foreach my $file (&kea_control_agent_password_files()) {
next if (!$file || $seen{$file}++);
push(@rv, { 'file' => $file, 'type' => 'password' });
}
return @rv;
}
# kea_manual_edit_file(file)
# Returns the manual-edit descriptor for an allowed file path.
sub kea_manual_edit_file
{
my ($file) = @_;
foreach my $f (&kea_manual_edit_files()) {
return $f if ($f->{'file'} eq $file);
}
return;
}
# kea_any_installed()
# Returns true when at least one configured Kea executable exists.
sub kea_any_installed
{
return 1 if (&has_command(&kea_config_value('keactrl_path')));
foreach my $c (&kea_components()) {
return 1 if (&has_command(&kea_config_value($c->{'path'})));
}
return 0;
}
# kea_any_configured()
# Returns true when at least one configured Kea config file is readable.
sub kea_any_configured
{
foreach my $c (&kea_components()) {
my $file = &kea_config_file($c);
return 1 if ($file && -r $file);
}
return 0;
}
# kea_component_pid(&component)
# Finds the running process ID for a Kea component.
sub kea_component_pid
{
my ($c) = @_;
# Prefer distro PID files when configured, then fall back to process lookup for
# systems that run Kea without the expected pidfile names.
foreach my $pidfile (split(/\s+/, &kea_config_value($c->{'pid'}) || "")) {
my $pid = &check_pid_file($pidfile);
return $pid if ($pid);
}
my ($pid) = &find_byname($c->{'proc'});
return $pid;
}
# kea_component_status(&component)
# Returns normalized runtime status for a Kea component.
sub kea_component_status
{
my ($c) = @_;
our %kea_component_status_cache;
return $kea_component_status_cache{$c->{'id'}}
if (exists($kea_component_status_cache{$c->{'id'}}));
my $pid = &kea_component_pid($c);
my $status = {
'state' => $pid ? 'running' : 'stopped',
'pid' => $pid,
'manager' => 'process',
'details' => [ ],
'logs' => [ ],
};
# systemd is the source of truth for failed/skipped services; PID checks alone
# cannot distinguish a clean stop from a failed start or unmet condition.
my $systemctl = &has_command('systemctl');
my $unit = &kea_component_systemd_unit($c);
if ($systemctl && $unit) {
my $props = &kea_systemd_unit_properties($unit);
if (%$props) {
$status->{'manager'} = 'systemd';
$status->{'unit'} = $unit;
$status->{'active'} = $props->{'ActiveState'};
$status->{'sub'} = $props->{'SubState'};
$status->{'result'} = $props->{'Result'};
$status->{'condition_result'} = $props->{'ConditionResult'};
$status->{'unit_file_state'} = $props->{'UnitFileState'};
if ($props->{'MainPID'} =~ /^\d+$/ && $props->{'MainPID'} > 0) {
$status->{'pid'} = $props->{'MainPID'};
}
if ($props->{'LoadState'} && $props->{'LoadState'} eq 'not-found') {
$status->{'state'} = $status->{'pid'} ? 'running' : 'unknown';
push(@{$status->{'details'}}, $text{'status_unit_missing'});
}
elsif ($props->{'ActiveState'} eq 'active') {
$status->{'state'} = 'running';
}
elsif ($props->{'ActiveState'} eq 'failed') {
$status->{'state'} = 'failed';
}
elsif ($props->{'ActiveState'} eq 'activating') {
$status->{'state'} = 'starting';
}
elsif ($props->{'ActiveState'} eq 'deactivating') {
$status->{'state'} = 'stopping';
}
elsif ($props->{'Result'} eq 'condition' ||
defined($props->{'ConditionResult'}) &&
$props->{'ConditionResult'} eq 'no') {
$status->{'state'} = 'skipped';
}
elsif ($props->{'ActiveState'} eq 'inactive') {
$status->{'state'} = 'stopped';
}
else {
$status->{'state'} = $status->{'pid'} ? 'running' : 'unknown';
}
# Only attach detailed logs to abnormal states so the services
# table stays compact when everything is healthy.
if ($status->{'state'} eq 'failed' ||
$status->{'state'} eq 'skipped') {
push(@{$status->{'details'}},
&text('status_result', $props->{'Result'}))
if ($props->{'Result'} && $props->{'Result'} ne 'success');
push(@{$status->{'details'}}, $text{'status_condition_unmet'})
if ($status->{'state'} eq 'skipped');
push(@{$status->{'details'}},
&text('status_exit', $props->{'ExecMainStatus'}))
if ($props->{'ExecMainStatus'} =~ /^\d+$/ &&
$props->{'ExecMainStatus'} != 0);
$status->{'logs'} = &kea_systemd_unit_logs(
$unit, $status->{'state'});
}
}
}
return $kea_component_status_cache{$c->{'id'}} = $status;
}
# kea_systemd_unit_properties(unit)
# Reads selected systemd properties for a configured unit.
sub kea_systemd_unit_properties
{
my ($unit) = @_;
my $systemctl = &has_command('systemctl');
return { } if (!$systemctl);
# Query explicit properties instead of parsing localized systemctl status text.
my $cmd = &quote_path($systemctl).
" show --no-pager --property=LoadState,ActiveState,SubState,".
"Result,MainPID,ExecMainStatus,ExecMainCode,UnitFileState,".
"ConditionResult ".
quotemeta($unit)." 2>/dev/null";
my $out = &backquote_command($cmd);
return { } if ($? || $out !~ /\S/);
my %props;
foreach my $line (split(/\r?\n/, $out)) {
my ($name, $value) = split(/=/, $line, 2);
$props{$name} = $value if ($name ne '');
}
return \%props;
}
# kea_systemd_unit_logs(unit, state)
# Returns recent systemd journal lines relevant to a component state.
sub kea_systemd_unit_logs
{
my ($unit, $state) = @_;
my $journalctl = &has_command('journalctl');
return [ ] if (!$journalctl);
if ($state eq 'skipped') {
# Skipped units usually need the unmet condition rather than ordinary
# warnings.
my @condition = &kea_recent_unique_lines(grep {
/\b(condition|skipped|unmet)\b/i
} &kea_journal_lines($journalctl, $unit, "", 12));
return [ @condition > 3 ?
@condition[$#condition-2 .. $#condition] : @condition ]
if (@condition);
}
if ($state eq 'failed') {
# Kea emits the real config/parser error in its daemon log line; prefer
# that over generic systemd process-exit messages.
my @errors = &kea_recent_unique_lines(grep {
&kea_is_relevant_error_line($_)
} &kea_journal_lines($journalctl, $unit, "", 20));
return [ @errors > 3 ? @errors[$#errors-2 .. $#errors] : @errors ]
if (@errors);
}
# For non-failure states, keep the newest useful log lines as optional context.
my @logs = &kea_recent_unique_lines(grep { !&kea_is_low_value_unit_line($_) }
&kea_journal_lines($journalctl, $unit, "--priority warning"));
@logs = &kea_recent_unique_lines(&kea_journal_lines($journalctl, $unit, ""))
if (!@logs);
return [ @logs > 3 ? @logs[$#logs-2 .. $#logs] : @logs ];
}
# kea_is_relevant_error_line(line)
# Returns true for journal lines likely to contain actionable Kea errors.
sub kea_is_relevant_error_line
{
my ($line) = @_;
return 0 if (&kea_is_low_value_unit_line($line));
return $line =~ /\b(ERROR|_FAIL|failed to initialize|configuration error|parser|unexpected keyword|invalid)\b/i;
}
# kea_is_low_value_unit_line(line)
# Filters generic systemd noise from service diagnostics.
sub kea_is_low_value_unit_line
{
my ($line) = @_;
return $line =~ /ConfigurationDirectory .* mode is different/i ||
$line =~ /Main process exited/i ||
$line =~ /Failed with result/i;
}
# kea_recent_unique_lines(lines...)
# Keeps the newest copy of repeated journal messages.
sub kea_recent_unique_lines
{
my @lines = @_;
my (%seen, @rv);
for(my $i=$#lines; $i>=0; $i--) {
my $key = $lines[$i];
# De-duplicate retries of the same failure while preserving the newest
# text.
$key =~ s/\d{4}-\d\d-\d\d\s+\d\d:\d\d:\d\d(?:\.\d+)?/<time>/g;
$key =~ s/\bpid:\s*\d+/pid:<pid>/ig;
$key =~ s#/(\d+)\.#/<pid>.#g;
next if ($seen{$key}++);
unshift(@rv, $lines[$i]);
}
return @rv;
}
# kea_journal_lines(journalctl, unit, extra-args, [lines])
# Reads normalized recent journal lines for one systemd unit.
sub kea_journal_lines
{
my ($journalctl, $unit, $extra, $lines) = @_;
$lines ||= 6;
my $cmd = &quote_path($journalctl)." --unit ".quotemeta($unit).
" --lines ".int($lines)." --no-pager --output cat ".
($extra ? $extra." " : "")."2>/dev/null";
my $out = &backquote_command($cmd);
return ( ) if ($?);
my @lines;
foreach my $line (split(/\r?\n/, $out)) {
$line =~ s/^\s+|\s+$//g;
next if ($line eq '');
push(@lines, &kea_short_text($line, 180));
}
return @lines;
}
# kea_short_text(text, max-length)
# Truncates long service messages for compact table display.
sub kea_short_text
{
my ($text, $max) = @_;
$max ||= 180;
return length($text) > $max ? substr($text, 0, $max - 3)."..." : $text;
}
# kea_running_pids()
# Returns process IDs for Kea components currently considered running.
sub kea_running_pids
{
my @rv;
foreach my $c (&kea_components()) {
my $status = &kea_component_status($c);
push(@rv, $status->{'pid'} || 1) if ($status->{'state'} eq 'running');
}
return @rv;
}
# kea_parse_config_file(file)
# Reads and parses a Kea config file into a Perl data structure.
sub kea_parse_config_file
{
my ($file) = @_;
my $text = &read_file_contents($file);
die &text('file_eread', $file) if (!defined($text));
return &kea_parse_config_text($text, $file);
}
# kea_parse_config_text(text, file)
# Parses Kea JSON text after expanding include directives.
sub kea_parse_config_text
{
my ($text, $file) = @_;
my $dir = &kea_dirname($file);
# Kea supports <?include "..."> fragments; expand them before handing the
# result to Webmin's relaxed JSON reader.
$text = &kea_expand_includes($text, $dir, 0);
my $data = eval { &convert_from_json($text, undef, 1) };
die &text('parse_ejson', $@) if ($@);
return $data;
}
# kea_encode_config(&data)
# Serializes a Kea config object as stable, pretty JSON for structured saves.
sub kea_encode_config
{
my ($data) = @_;
# Structured edits intentionally save clean, pretty JSON. Comments from distro
# sample configs are not preserved after the first structured save.
return &convert_to_json($data, 1);
}
# kea_config_has_comments(file)
# Detects comment syntax that will be removed by structured JSON saves.
sub kea_config_has_comments
{
my ($file) = @_;
return 0 if (!$file || !-r $file);
my $text = &read_file_contents($file);
return 0 if (!defined($text));
# Kea package examples commonly use whole-line JSONC comments. Detecting those
# is enough to warn before a structured save normalizes the file to plain JSON.
return $text =~ m{^\s*(?://|#|/\*)}m ? 1 : 0;
}
# kea_comment_loss_warning(&component, ...)
# Builds the warning shown before saving comment-bearing config files.
sub kea_comment_loss_warning
{
my @components = @_;
my @files;
foreach my $c (@components) {
next if (!$c);
my $file = &kea_config_file($c);
push(@files, $file) if (&kea_config_has_comments($file));
}
return "" if (!@files);
# Name the exact config file so users know which Kea daemon will be rewritten
# when a settings page is saved.
my $list = join(", ", map { &ui_tag('tt', &html_escape($_)) } @files);
my $msg = @files == 1 ? &text('comments_loss_warning', $list) :
&text('comments_loss_warning_multi', $list);
return &ui_alert_box($msg, "success", undef, undef, "");
}
# kea_expand_includes(text, base-dir, depth)
# Expands Kea <?include "..."> directives recursively.
sub kea_expand_includes
{
my ($text, $base, $depth) = @_;
die $text{'include_edepth'} if ($depth > 10);
# The substitution calls back into kea_include_file so nested includes inherit
# their own base directory.
$text =~ s/<\?include\s+"([^"]+)"\s*\?>/&kea_include_file($1, $base, $depth)/ge;
return $text;
}
# kea_include_file(path, base-dir, depth)
# Reads and expands one Kea include target.
sub kea_include_file
{
my ($inc, $base, $depth) = @_;
my $file = $inc =~ /^\// ? $inc :
-r "$base/$inc" ? "$base/$inc" : $inc;
my $text = &read_file_contents($file);
die &text('include_eread', $file) if (!defined($text));
$text = &kea_expand_includes($text, &kea_dirname($file), $depth + 1);
# Kea commonly includes a JSON object inside another object and merges its
# members. Stripping the outer braces lets normal JSON parsers read that shape.
my $trim = $text;
$trim =~ s/^\s+//;
$trim =~ s/\s+$//;
if ($trim =~ /^\{(.*)\}$/s) {
return $1;
}
return $text;
}
# kea_dirname(file)
# Returns the containing directory for a path.
sub kea_dirname
{
my ($file) = @_;
$file =~ s/\/[^\/]*$//;
return $file || ".";
}
# kea_read_component_config(&component)
# Reads one component config and returns its root object.
sub kea_read_component_config
{
my ($c) = @_;
my $file = &kea_config_file($c);
return (undef, &text('config_missing', '')) if (!$file);
return (undef, &text('config_missing', $file)) if (!-e $file);
return (undef, &text('config_unreadable', $file)) if (!-r $file);
my $data = eval { &kea_parse_config_file($file) };
return (undef, $@) if ($@);
if (ref($data->{$c->{'root'}}) ne 'HASH') {
return (undef, &text('parse_eroot', $c->{'root'}));
}
return ($data->{$c->{'root'}}, undef, $data);
}
# kea_read_dhcp_config(version)
# Reads the DHCPv4 or DHCPv6 component config.
sub kea_read_dhcp_config
{
my ($ver) = @_;
my $c = &kea_dhcp_component($ver);
my ($root, $err, $data) = &kea_read_component_config($c);
return ($c, $root, $data, $err);
}
# kea_save_component_config(&component, &data)
# Validates and writes a component config file.
sub kea_save_component_config
{
my ($c, $data) = @_;
my $file = &kea_config_file($c);
return $text{'save_efile'} if (!$file);
my $json = eval { &kea_encode_config($data) };
return $@ if ($@);
my $verr = &kea_validate_component_json($c, $json);
return $verr if ($verr);
# Use Webmin's lock/tempfile path so concurrent UI edits and backups see a
# normal module-managed config write.
&lock_file($file);
my $fh;
&open_tempfile($fh, ">$file");
&print_tempfile($fh, $json);
&close_tempfile($fh);
&unlock_file($file);
return;
}
# kea_validate_component_json(&component, json)
# Runs Kea's native config test before replacing a live daemon file. Test
# fixtures often point component paths at perl itself, so only execute real Kea
# binaries whose basename matches the component descriptor.
sub kea_validate_component_json
{
my ($c, $json) = @_;
my $cmd = &kea_config_value($c->{'path'});
return if (!$cmd || !-x $cmd);
my $base = $cmd;
$base =~ s/^.*\///;
return if ($base ne $c->{'proc'});
# Debian's Kea AppArmor profile permits reading /etc/kea/** but denies
# Webmin's normal /tmp/.webmin candidates. Validate from the same directory as
# the live config so Kea sees the file through its packaged security policy.
my $file = &kea_config_file($c);
my $dir = &kea_dirname($file);
my $tmp = $dir."/".$c->{'proc'}."-test-".
&substitute_pattern('[a-f0-9]{6}').".conf";
push(@main::temporary_files, $tmp);
my $fh;
if (!&open_tempfile($fh, ">$tmp", 1)) {
return &text('save_etemp', $tmp, $!);
}
&print_tempfile($fh, $json);
if (!&close_tempfile($fh)) {
return &text('save_etemp', $tmp, $!);
}
my @st = $file ? stat($file) : ( );
my $mode = @st ? $st[2] & 07777 : 0644;
if (!chmod($mode, $tmp)) {
my $cerr = $!;
&unlink_file($tmp);
return &text('save_etemp', $tmp, $cerr);
}
if (@st && $> == 0 && !chown($st[4], $st[5], $tmp)) {
my $cerr = $!;
&unlink_file($tmp);
return &text('save_etemp', $tmp, $cerr);
}
my ($out, $err);
my $rv = &execute_command(quotemeta($cmd)." -t ".quotemeta($tmp),
undef, \$out, \$err, 0, 1);
&unlink_file($tmp);
return if (!$rv);
my $msg = $err || $out || &text('save_evalidate_empty');
$msg =~ s/\r//g;
$msg =~ s/^\s+//;
$msg =~ s/\s+$//;
return &text('save_evalidate', $c->{'label'}, $msg);
}
# kea_shared_networks(&root)
# Returns the shared-networks array, creating an empty one if needed.
sub kea_shared_networks
{
my ($root) = @_;
# Normalize missing shared-networks to an array so callers can safely mutate it.
$root->{'shared-networks'} = [ ] if (ref($root->{'shared-networks'}) ne 'ARRAY');
return $root->{'shared-networks'};
}
# kea_subnet_list(&root, version, [shared-index])
# Returns top-level or shared-network subnet arrays.
sub kea_subnet_list
{
my ($root, $ver, $sidx) = @_;
my $key = &kea_subnet_key($ver);
my $parent = $sidx ne '' ? &kea_shared_networks($root)->[$sidx] : $root;
return [ ] if (ref($parent) ne 'HASH');
# Top-level and shared-network subnets use the same key, only with different
# parents.
$parent->{$key} = [ ] if (ref($parent->{$key}) ne 'ARRAY');
return $parent->{$key};
}
# kea_valid_subnet_parent(&root, [shared-index])
# Returns true when a subnet parent index is top-level or an existing shared network.
sub kea_valid_subnet_parent
{
my ($root, $sidx) = @_;
return 1 if (!defined($sidx) || $sidx eq '');
return 0 if ($sidx !~ /^\d+$/);
# Reject negative and out-of-range parent indexes before Perl can treat them as
# reverse array indexes.
return ref(&kea_shared_networks($root)->[$sidx]) eq 'HASH';
}
# kea_get_subnet(&root, version, [shared-index], subnet-index)
# Returns one subnet object from its stored location.
sub kea_get_subnet
{
my ($root, $ver, $sidx, $idx) = @_;
return &kea_subnet_list($root, $ver, $sidx)->[$idx];
}
# kea_all_subnets(&root, version)
# Returns every subnet with location metadata for editing and deletion.
sub kea_all_subnets
{
my ($root, $ver) = @_;
my @rv;
# Return both top-level subnets and subnets nested below shared networks with
# enough location metadata for edit/delete links.
my $top = &kea_subnet_list($root, $ver, "");
for(my $i=0; $i<@$top; $i++) {
push(@rv, { 'sidx' => '', 'idx' => $i, 'subnet' => $top->[$i] });
}
my $shareds = &kea_shared_networks($root);
for(my $s=0; $s<@$shareds; $s++) {
my $subs = &kea_subnet_list($root, $ver, $s);
for(my $i=0; $i<@$subs; $i++) {
push(@rv, { 'sidx' => $s, 'idx' => $i,
'shared' => $shareds->[$s], 'subnet' => $subs->[$i] });
}
}
return @rv;
}
# kea_next_subnet_id(&root, version)
# Finds the next unused numeric subnet id.
sub kea_next_subnet_id
{
my ($root, $ver) = @_;
my $max = 0;
foreach my $s (&kea_all_subnets($root, $ver)) {
my $id = $s->{'subnet'}->{'id'};
$max = $id if ($id =~ /^\d+$/ && $id > $max);
}
return $max + 1;
}
# kea_lease_file(version, [&root])
# Resolves the memfile lease CSV path for a DHCP version.
sub kea_lease_file
{
my ($ver, $root) = @_;
if (!$root) {
my (undef, $read_root, undef, $err) = &kea_read_dhcp_config($ver);
return ("", $err) if ($err);
$root = $read_root;
}
my $db = ref($root->{'lease-database'}) eq 'HASH' ?
$root->{'lease-database'} : { };
my $type = lc($db->{'type'} || 'memfile');
return ("", &text('leases_backend_unsupported', $type))
if ($type ne 'memfile');
my $default = &kea_config_value('dhcp'.$ver.'_lease_file') || "";
my $name = $db->{'name'} || "";
return ($name, undef) if ($name =~ /^\//);
return (&kea_dirname($default)."/".$name, undef) if ($name && $default =~ /^\//);
return ($default, undef);
}
# kea_read_leases(version)
# Reads Kea memfile CSV leases and returns parsed rows, error text, and path.
sub kea_read_leases
{
my ($ver) = @_;
my (undef, $root, undef, $err) = &kea_read_dhcp_config($ver);
return ([ ], $err, "") if ($err);
my ($file, $ferr) = &kea_lease_file($ver, $root);
return ([ ], $ferr, $file) if ($ferr);
return ([ ], $text{'leases_enofile'}, $file) if (!$file);
return ([ ], &text('leases_missing', $file), $file) if (!-e $file);
return ([ ], &text('leases_unreadable', $file), $file) if (!-r $file);
my $text = &read_file_contents($file);
my @lines = split(/\r?\n/, $text);
my @heads;
my @leases;
foreach my $line (@lines) {
$line =~ s/^\s+|\s+$//g;
next if ($line eq '' || $line =~ /^#/);
my @cols = &kea_csv_fields($line);
if (!@heads) {
@heads = map { &kea_normalize_lease_field($_) } @cols;
next;
}
my %lease;
for(my $i=0; $i<@heads; $i++) {
$lease{$heads[$i]} = defined($cols[$i]) ? $cols[$i] : "";
}
push(@leases, \%lease);
}
return (\@leases, undef, $file);
}
# kea_csv_fields(line)
# Parses one Kea memfile CSV row using Perl's standard quoted-field parser.
sub kea_csv_fields
{
my ($line) = @_;
eval { require Text::ParseWords; };
return split(/,/, $line, -1) if ($@);
my @cols = Text::ParseWords::parse_line(',', 0, $line);
return @cols;
}
# kea_normalize_lease_field(field)
# Converts CSV header names to hash keys that are easy to read in the UI.
sub kea_normalize_lease_field
{
my ($field) = @_;
$field = lc($field || "");
$field =~ s/^\s+|\s+$//g;
$field =~ s/[-\s]+/_/g;
return $field;
}
# kea_active_leases(version)
# Returns active leases for a DHCP version, preserving file/error context.
sub kea_active_leases
{
my ($ver) = @_;
my ($leases, $err, $file) = &kea_read_leases($ver);
return ([ grep { &kea_lease_is_active($_) } @$leases ], $err, $file);
}
# kea_lease_is_active(&lease)
# Returns true for leases Kea still considers usable by clients.
sub kea_lease_is_active
{
my ($lease) = @_;
my $state = $lease->{'state'};
return 0 if (defined($state) && $state ne '' && $state ne '0');
my $expire = $lease->{'expire'};
return 1 if (!defined($expire) || $expire eq '' || $expire !~ /^\d+$/);
return $expire == 0 || $expire > time() ? 1 : 0;
}
# kea_lease_address(&lease)
# Returns the address or delegated prefix shown for one lease.
sub kea_lease_address
{
my ($lease) = @_;
return $lease->{'address'} || $lease->{'ip_address'} ||
$lease->{'prefix'} || "";
}
# kea_lease_identifier(&lease)
# Returns the most useful client identity from one DHCP lease row.
sub kea_lease_identifier
{
my ($lease) = @_;
return $lease->{'hwaddr'} || $lease->{'hw_address'} ||
$lease->{'client_id'} || $lease->{'duid'} ||
$lease->{'iaid'} || "";
}
# kea_lease_expires(&lease)
# Returns human-readable lease expiration text.
sub kea_lease_expires
{
my ($lease) = @_;
my $expire = $lease->{'expire'};
return $text{'leases_never'} if (defined($expire) && $expire eq '0');
return "" if (!defined($expire) || $expire eq '' || $expire !~ /^\d+$/);
return scalar(localtime($expire));
}
# kea_lease_state(&lease)
# Returns a compact state label for a lease CSV row.
sub kea_lease_state
{
my ($lease) = @_;
my $state = $lease->{'state'};
return $text{'leases_state_active'} if (!defined($state) || $state eq '' ||
$state eq '0');
return &text('leases_state_number', $state);
}
# kea_lease_summary(version)
# Counts total, active, and inactive leases for dashboard statistics.
sub kea_lease_summary
{
my ($ver) = @_;
my ($leases, $err, $file) = &kea_read_leases($ver);
my $active = 0;
foreach my $lease (@$leases) {
$active++ if (&kea_lease_is_active($lease));
}
return {
'version' => $ver,
'file' => $file,
'error' => $err,
'total' => scalar(@$leases),
'active' => $active,
'inactive' => scalar(@$leases) - $active,
};
}
# kea_pool_usage_rows(version)
# Builds per-subnet active-lease counts for the pool usage view.
sub kea_pool_usage_rows
{
my ($ver) = @_;
my (undef, $root, undef, $err) = &kea_read_dhcp_config($ver);
return ([ ], $err) if ($err);
my ($leases, $lerr) = &kea_active_leases($ver);
return ([ ], $lerr) if ($lerr && !@$leases);
my %count;
foreach my $lease (@$leases) {
my $id = $lease->{'subnet_id'} || $lease->{'subnet'};
$count{$id}++ if (defined($id) && $id ne '');
}
my @rows;
foreach my $entry (&kea_all_subnets($root, $ver)) {
my $subnet = $entry->{'subnet'};
my $id = $subnet->{'id'} || "";
push(@rows, {
'id' => $id,
'subnet' => $subnet->{'subnet'} || "",
'pools' => ref($subnet->{'pools'}) eq 'ARRAY' ?
scalar(@{$subnet->{'pools'}}) : 0,
'pd_pools' => ref($subnet->{'pd-pools'}) eq 'ARRAY' ?
scalar(@{$subnet->{'pd-pools'}}) : 0,
'reservations' => ref($subnet->{'reservations'}) eq 'ARRAY' ?
scalar(@{$subnet->{'reservations'}}) : 0,
'active' => $count{$id} || 0,
});
}
return (\@rows, undef);
}
# kea_recent_component_log_lines(&component, [lines])
# Returns recent journal lines for a component when systemd logs are available.
sub kea_recent_component_log_lines
{
my ($c, $lines) = @_;
my $journalctl = &has_command('journalctl');
my $unit = &kea_component_systemd_unit($c);
return [ ] if (!$journalctl || !$unit);
my @logs = &kea_recent_unique_lines(&kea_journal_lines(
$journalctl, $unit, "", $lines || 20));
return \@logs;
}
# kea_scope_name(&scope, fallback)
# Returns a display name for a shared network or subnet-like object.
sub kea_scope_name
{
my ($scope, $fallback) = @_;
return $scope->{'name'} || $scope->{'subnet'} || $fallback || "";
}
# kea_get_comment(&scope)
# Reads the Webmin-managed comment from Kea user-context.
sub kea_get_comment
{
my ($scope) = @_;
return ref($scope->{'user-context'}) eq 'HASH' ?
$scope->{'user-context'}->{'comment'} : undef;
}
# kea_set_comment(&scope, comment)
# Stores or removes the Webmin-managed comment in Kea user-context.
sub kea_set_comment
{
my ($scope, $comment) = @_;
$comment =~ s/^\s+|\s+$//g if (defined($comment));
# Kea stores human comments in user-context; clean up the wrapper when our
# comment is the last value left.
if (!defined($comment) || $comment eq '') {
delete($scope->{'user-context'}->{'comment'})
if (ref($scope->{'user-context'}) eq 'HASH');
delete($scope->{'user-context'})
if (ref($scope->{'user-context'}) eq 'HASH' &&
!keys(%{$scope->{'user-context'}}));
}
else {
$scope->{'user-context'} = { }
if (ref($scope->{'user-context'}) ne 'HASH');
$scope->{'user-context'}->{'comment'} = $comment;
}
}
# kea_count_array(&scope, key)
# Safely counts an array field on a Kea config object.
sub kea_count_array
{
my ($scope, $key) = @_;
return ref($scope->{$key}) eq 'ARRAY' ? scalar(@{$scope->{$key}}) : 0;
}
# kea_set_optional(&hash, key, value)
# Stores a form value as a scalar, deleting empty/default values.
sub kea_set_optional
{
my ($hash, $key, $value) = @_;
$value = &kea_trim_form_value($value);
# Empty form fields mean "inherit/default/unset" in Kea, so remove the key.
if ($value eq '') {
delete($hash->{$key});
}
elsif ($value =~ /^\d+$/) {
$hash->{$key} = int($value);
}
else {
$hash->{$key} = $value;
}
}
# kea_set_optional_string(&hash, key, value)
# Stores a form value as a JSON string, even when it looks numeric.
sub kea_set_optional_string
{
my ($hash, $key, $value) = @_;
$value = &kea_trim_form_value($value);
if ($value eq '') {
delete($hash->{$key});
}
else {
$hash->{$key} = $value;
}
}
# kea_trim_form_value(value)
# Normalizes a submitted scalar form value.
sub kea_trim_form_value
{
my ($value) = @_;
return "" if (!defined($value));
$value =~ s/^\s+|\s+$//g;
return $value;
}
# kea_form_has_prefix(prefix)
# Returns true when a submitted form includes fields for an optional section.
sub kea_form_has_prefix
{
my ($prefix) = @_;
foreach my $k (keys %in) {
return 1 if (index($k, $prefix) == 0);
}
return 0;
}
# kea_set_optional_integer(&hash, key, value)
# Stores an optional integer form value after validation.
sub kea_set_optional_integer
{
my ($hash, $key, $value) = @_;
$value = &kea_trim_form_value($value);
if ($value eq '') {
delete($hash->{$key});
}
elsif ($value =~ /^\d+$/) {
$hash->{$key} = int($value);
}
else {
&error(&text('field_enumber', $text{'field_'.$key} || $key));
}
}
# kea_validate_lifetimes(&hash)
# Validates timer ordering for lease lifetime fields.
sub kea_validate_lifetimes
{
my ($hash) = @_;
# Kea accepts these timers independently, but the UI should prevent impossible
# lease timing relationships before saving.
foreach my $pair ([ 'renew-timer', 'rebind-timer' ],
[ 'rebind-timer', 'valid-lifetime' ],
[ 'min-valid-lifetime', 'valid-lifetime' ],
[ 'valid-lifetime', 'max-valid-lifetime' ]) {
my ($low, $high) = @$pair;
next if (!defined($hash->{$low}) || !defined($hash->{$high}));
&error(&text('field_eorder',
$text{'field_'.$low} || $low,
$text{'field_'.$high} || $high))
if ($hash->{$low} >= $hash->{$high});
}
}
# kea_json_bool(value)
# Returns a JSON boolean object compatible with Webmin's JSON encoder.
sub kea_json_bool
{
my ($value) = @_;
eval { require JSON::PP };
return $value ? JSON::PP::true() : JSON::PP::false();
}
# kea_set_optional_bool(&hash, key, value)
# Stores an optional boolean form value after validation.
sub kea_set_optional_bool
{
my ($hash, $key, $value) = @_;
$value = &kea_trim_form_value($value);
if ($value eq '') {
delete($hash->{$key});
}
elsif ($value eq 'true' || $value eq '1') {
$hash->{$key} = &kea_json_bool(1);
}
elsif ($value eq 'false' || $value eq '0') {
$hash->{$key} = &kea_json_bool(0);
}
else {
&error(&text('field_ebool', $text{'field_'.$key} || $key));
}
}
# kea_bool_value(value)
# Converts a JSON boolean-ish value back to the UI select value.
sub kea_bool_value
{
my ($value) = @_;
return '' if (!defined($value));
return $value ? 'true' : 'false';
}
# kea_relay_addresses(&hash)
# Returns relay addresses from either old or current Kea relay syntax.
sub kea_relay_addresses
{
my ($hash) = @_;
my $relay = ref($hash->{'relay'}) eq 'HASH' ? $hash->{'relay'} : { };
return @{$relay->{'ip-addresses'}}
if (ref($relay->{'ip-addresses'}) eq 'ARRAY');
return ($relay->{'ip-address'}) if ($relay->{'ip-address'});
return ( );
}
# kea_set_relay_addresses(&hash, text)
# Stores relay IP addresses using Kea's ip-addresses list form.
sub kea_set_relay_addresses
{
my ($hash, $text) = @_;
my @addrs = grep { $_ ne '' } split(/[,\s]+/, $text || "");
if (@addrs) {
# Always write the modern ip-addresses list form even if older configs used the
# singular ip-address field.
$hash->{'relay'} = { 'ip-addresses' => \@addrs };
}
else {
delete($hash->{'relay'});
}
}
# kea_ipv4_canonical_subnet(cidr)
# Canonicalizes an IPv4 subnet CIDR by clearing host bits.
sub kea_ipv4_canonical_subnet
{
my ($cidr) = @_;
return if ($cidr !~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/);
my @o = ($1, $2, $3, $4);
my $prefix = $5;
return if (grep { $_ > 255 } @o);
return if ($prefix > 32);
my $ip = ($o[0] << 24) | ($o[1] << 16) | ($o[2] << 8) | $o[3];
my $mask = $prefix == 0 ? 0 : (0xffffffff << (32 - $prefix)) & 0xffffffff;
my $net = $ip & $mask;
# Normalize 192.0.2.1/24 to 192.0.2.0/24 to avoid ambiguous subnet selection.
return join(".", (($net >> 24) & 255, ($net >> 16) & 255,
($net >> 8) & 255, $net & 255))."/".$prefix;
}
# kea_ipv4_mask_from_subnet(cidr)
# Calculates the dotted IPv4 subnet mask for a CIDR prefix.
sub kea_ipv4_mask_from_subnet
{
my ($cidr) = @_;
return "" if ($cidr !~ /\/(\d+)$/ || $1 > 32);
my $prefix = $1;
my $mask = $prefix == 0 ? 0 : (0xffffffff << (32 - $prefix)) & 0xffffffff;
return join(".", (($mask >> 24) & 255, ($mask >> 16) & 255,
($mask >> 8) & 255, $mask & 255));
}
# kea_canonical_subnet(cidr, version)
# Applies protocol-specific subnet canonicalization.
sub kea_canonical_subnet
{
my ($cidr, $ver) = @_;
return $ver == 4 ? &kea_ipv4_canonical_subnet($cidr) : $cidr;
}
# kea_common_options(version)
# Returns the curated common DHCP option-data fields for a protocol.
sub kea_common_options
{
my ($ver) = @_;
# Keep only broadly useful option-data fields here. Less-common and vendor
# options remain available through the additional option-data grid.
return $ver == 6 ? (
[ 'dns-servers', $text{'opt_common_dns-servers'}, 46, 23 ],
[ 'domain-search', $text{'opt_common_domain-search'}, 46, 24 ],
[ 'sntp-servers', $text{'opt_common_sntp-servers'}, 46, 31 ],
[ 'preference', $text{'opt_common_preference'}, 10, 7 ],
[ 'unicast', $text{'opt_common_unicast'}, 40, 12 ],
[ 'bootfile-url', $text{'opt_common_bootfile-url'}, 60, 59 ],
[ 'bootfile-param', $text{'opt_common_bootfile-param'}, 60, 60 ],
[ 'new-posix-timezone', $text{'opt_common_new-posix-timezone'}, 46, 41 ],
[ 'new-tzdb-timezone', $text{'opt_common_new-tzdb-timezone'}, 32, 42 ],
) : (
[ 'routers', $text{'opt_common_routers'}, 46, 3 ],
[ 'broadcast-address', $text{'opt_common_broadcast-address'}, 20, 28 ],
[ 'domain-name', $text{'opt_common_domain-name'}, 40, 15 ],
[ 'domain-name-servers', $text{'opt_common_domain-name-servers'}, 46, 6 ],
[ 'domain-search', $text{'opt_common_domain-search'}, 46, 119 ],
[ 'ntp-servers', $text{'opt_common_ntp-servers'}, 46, 42 ],
[ 'time-servers', $text{'opt_common_time-servers'}, 46, 4 ],
[ 'log-servers', $text{'opt_common_log-servers'}, 46, 7 ],
[ 'root-path', $text{'opt_common_root-path'}, 46, 17 ],
[ 'tftp-server-name', $text{'opt_common_tftp-server-name'}, 46, 66 ],
[ 'boot-file-name', $text{'opt_common_boot-file-name'}, 46, 67 ],
[ 'nis-domain', $text{'opt_common_nis-domain'}, 40, 40 ],
[ 'nis-servers', $text{'opt_common_nis-servers'}, 46, 41 ],
[ 'netbios-name-servers', $text{'opt_common_netbios-name-servers'}, 46, 44 ],
[ 'netbios-scope', $text{'opt_common_netbios-scope'}, 32, 47 ],
[ 'netbios-node-type', $text{'opt_common_netbios-node-type'}, 10, 46 ],
[ 'time-offset', $text{'opt_common_time-offset'}, 10, 2 ],
);
}
# kea_advanced_options(version)
# Returns advanced option-data fields shown outside the common list.
sub kea_advanced_options
{
my ($ver) = @_;
return $ver == 4 ? (
[ 'dhcp-server-identifier', $text{'opt_common_dhcp-server-identifier'}, 20, 54 ],
) : ( );
}
# kea_managed_options(version)
# Returns option-data entries Kea manages automatically and Webmin must not edit.
sub kea_managed_options
{
my ($ver) = @_;
# Kea derives subnet-mask from the subnet prefix, so expose it read-only only.
return $ver == 4 ? (
[ 'subnet-mask', 1 ],
) : ( );
}
# kea_common_option_names(version)
# Returns common option names for duplicate filtering and parsing.
sub kea_common_option_names
{
my ($ver) = @_;
return map { $_->[0] } &kea_common_options($ver);
}
# kea_common_option_codes(version)
# Returns common option codes for duplicate filtering and parsing.
sub kea_common_option_codes
{
my ($ver) = @_;
return map { $_->[3] } grep { defined($_->[3]) } &kea_common_options($ver);
}
# kea_advanced_option_names(version)
# Returns advanced option names for duplicate filtering and parsing.
sub kea_advanced_option_names
{
my ($ver) = @_;
return map { $_->[0] } &kea_advanced_options($ver);
}
# kea_advanced_option_codes(version)
# Returns advanced option codes for duplicate filtering and parsing.
sub kea_advanced_option_codes
{
my ($ver) = @_;
return map { $_->[3] } grep { defined($_->[3]) } &kea_advanced_options($ver);
}
# kea_managed_option_names(version)
# Returns auto-managed option names that cannot be edited directly.
sub kea_managed_option_names
{
my ($ver) = @_;
return map { $_->[0] } &kea_managed_options($ver);
}
# kea_managed_option_codes(version)
# Returns auto-managed option codes that cannot be edited directly.
sub kea_managed_option_codes
{
my ($ver) = @_;
return map { $_->[1] } &kea_managed_options($ver);
}
# kea_option_code(version, name)
# Looks up a known DHCP option code by name.
sub kea_option_code
{
my ($ver, $name) = @_;
foreach my $o (&kea_common_options($ver), &kea_advanced_options($ver)) {
return $o->[3] if ($o->[0] eq $name && defined($o->[3]));
}
return;
}
# kea_option_name(version, code)
# Looks up a known DHCP option name by code.
sub kea_option_name
{
my ($ver, $code) = @_;
foreach my $o (&kea_common_options($ver), &kea_advanced_options($ver)) {
return $o->[0] if (defined($o->[3]) && defined($code) &&
$o->[3] == $code);
}
return;
}
# kea_known_option(&option, version)
# Returns true when an option-data row maps to a named UI field.
sub kea_known_option
{
my ($o, $ver) = @_;
return 0 if (ref($o) ne 'HASH');
return 1 if ($o->{'name'} && &indexof($o->{'name'},
(&kea_common_option_names($ver), &kea_advanced_option_names($ver))) >= 0);
return 1 if (defined($o->{'code'}) &&
&kea_option_name($ver, $o->{'code'}));
return 0;
}
# kea_same_known_option(&option, name, code)
# Compares an option-data row with a known option by name or code.
sub kea_same_known_option
{
my ($o, $name, $code) = @_;
return 0 if (ref($o) ne 'HASH');
return 1 if ($name ne '' && defined($o->{'name'}) && $o->{'name'} eq $name);
return 1 if (defined($code) && defined($o->{'code'}) && $o->{'code'} == $code);
return 0;
}
# kea_option_value(&options, name, [version])
# Reads the data value for a known option-data entry.
sub kea_option_value
{
my ($opts, $name, $ver) = @_;
$opts = [ ] if (ref($opts) ne 'ARRAY');
my $code = defined($ver) ? &kea_option_code($ver, $name) : undef;
# Match by either canonical name or known option code so numeric configs still
# populate named UI fields.
foreach my $o (@$opts) {
return $o->{'data'} if (ref($o) eq 'HASH' &&
(defined($o->{'name'}) && $o->{'name'} eq $name ||
defined($code) && defined($o->{'code'}) &&
$o->{'code'} == $code));
}
return "";
}
# kea_set_option_value(&options, version, name, value, [&new-option])
# Updates or removes one known option-data entry.
sub kea_set_option_value
{
my ($opts, $ver, $name, $value, $newopt) = @_;
$opts = [ ] if (ref($opts) ne 'ARRAY');
my $code = &kea_option_code($ver, $name);
my ($found, @keep);
# Replace all existing entries for this known option with at most one updated
# entry, preserving unrelated option-data rows.
foreach my $o (@$opts) {
if (&kea_same_known_option($o, $name, $code)) {
if (!$found && defined($value) && $value ne '') {
my %copy = ref($o) eq 'HASH' ? %$o : ( );
$copy{'name'} = $name
if (!$copy{'name'} && !defined($copy{'code'}));
$copy{'data'} = $value;
push(@keep, \%copy);
$found = 1;
}
}
else {
push(@keep, $o);
}
}
if (!$found && defined($value) && $value ne '') {
my %copy = ref($newopt) eq 'HASH' ? %$newopt :
( 'name' => $name, 'data' => $value );
# Prefer the original representation when a numeric option row was
# promoted into a named field.
$copy{'data'} = $value;
$copy{'name'} = $name if (!$copy{'name'} && !defined($copy{'code'}));
push(@keep, \%copy);
}
@$opts = @keep;
}
# kea_other_options(&options, version)
# Returns option-data rows not represented by named fields.
sub kea_other_options
{
my ($opts, $ver) = @_;
$opts = [ ] if (ref($opts) ne 'ARRAY');
# The free-form option grid should contain only entries not already represented
# by named/common fields and not managed automatically by Kea.
my %known = map { $_ => 1 } (&kea_common_option_names($ver),
&kea_advanced_option_names($ver),
&kea_managed_option_names($ver));
my %known_codes = map { $_ => 1 } (&kea_common_option_codes($ver),
&kea_advanced_option_codes($ver),
&kea_managed_option_codes($ver));
return [ grep {
ref($_) eq 'HASH' &&
!(defined($_->{'name'}) && $known{$_->{'name'}}) &&
!(defined($_->{'code'}) && $known_codes{$_->{'code'}})
} @$opts ];
}
# kea_common_option_rows(&options, version, prefix)
# Renders common option-data form rows for a scope.
sub kea_common_option_rows
{
my ($opts, $ver, $prefix) = @_;
print &ui_table_start($text{'options_common'}, "width=100%", 4);
# The metadata list drives both labels and field names so DHCPv4/DHCPv6 can
# share the same rendering code without copying option tables.
foreach my $o (&kea_common_options($ver)) {
my ($name, $label, $size) = @$o;
print &ui_table_row(&kea_option_hlink($name, $label),
&ui_textbox($prefix.$name, &kea_option_value($opts, $name, $ver),
$size || 40));
}
print &ui_table_end();
}
# kea_advanced_option_rows(&options, version, prefix)
# Renders advanced option-data form rows for a scope.
sub kea_advanced_option_rows
{
my ($opts, $ver, $prefix) = @_;
return if ($ver != 4);
# The advanced option list is intentionally small and DHCPv4-only. These are
# valid option-data entries but are risky enough to stay off the common tab.
foreach my $o (&kea_advanced_options($ver)) {
my ($name, $label, $size) = @$o;
print &ui_table_row(&kea_option_hlink($name, $label),
&ui_textbox($prefix.$name, &kea_option_value($opts, $name, $ver),
$size || 40));
}
}
# kea_option_hlink(option-name, [label])
# Returns a standard Webmin help link for a Kea option-data field.
sub kea_option_hlink
{
my ($name, $label) = @_;
return &hlink($label || $name, &kea_help_id("opt", $name));
}
# kea_field_hlink(field-name, [label])
# Returns a standard Webmin help link for a structured Kea object field.
sub kea_field_hlink
{
my ($name, $label) = @_;
return &hlink($label || $text{'field_'.$name} || $name,
&kea_help_id("field", $name));
}
# kea_help_id(prefix, name)
# Maps Kea's dash-heavy option and field names to help file basenames.
sub kea_help_id
{
my ($prefix, $name) = @_;
$name =~ s/[^A-Za-z0-9]+/_/g;
$name =~ s/^_+//;
$name =~ s/_+$//;
return $prefix."_".$name;
}
# kea_option_data_rows(&options, prefix, version)
# Renders the generic option-data editor grid.
sub kea_option_data_rows
{
my ($opts, $prefix, $ver) = @_;
$opts = &kea_other_options($opts, $ver) if ($ver);
$opts = [ ] if (ref($opts) ne 'ARRAY');
# option-data rows can contain long strings, so keep them inside the same
# wrapper used by other wide row-based editors in this module.
print &ui_tag_start('div', { 'class' => 'option-data-table' });
print &ui_columns_start([
&kea_field_hlink('option-name', $text{'opt_name'}),
&kea_field_hlink('option-code', $text{'opt_code'}),
&kea_field_hlink('option-space', $text{'opt_space'}),
&kea_field_hlink('option-data', $text{'opt_data'}) ], 100);
my $rows = @$opts + 3;
for(my $i=0; $i<$rows; $i++) {
my $o = $opts->[$i] || { };
print &ui_columns_row([
&ui_textbox($prefix."name_$i", $o->{'name'} || "", 24),
&ui_textbox($prefix."code_$i", $o->{'code'} || "", 6),
&ui_textbox($prefix."space_$i", $o->{'space'} || "", 12),
&ui_textbox($prefix."data_$i", $o->{'data'} || "", 45),
]);
}
print &ui_columns_end();
print &ui_tag_end('div');
}
# kea_option_data_section(&options, prefix, version)
# Renders the generic option-data section when it has content or is required.
sub kea_option_data_section
{
my ($opts, $prefix, $ver, $always) = @_;
my $other = &kea_other_options($opts, $ver);
return if (!@$other && !$always);
print &ui_subheading($text{'options_custom'});
print &ui_div($text{'options_custom_desc'});
&kea_option_data_rows($other, $prefix);
}
# kea_ddns_domains(&root, section-name)
# Returns D2 forward or reverse domain rows without forcing missing sections.
sub kea_ddns_domains
{
my ($root, $section) = @_;
my $conf = ref($root->{$section}) eq 'HASH' ? $root->{$section} : { };
return ref($conf->{'ddns-domains'}) eq 'ARRAY' ?
$conf->{'ddns-domains'} : [ ];
}
# kea_ddns_listener_endpoint(&root)
# Returns the visible D2 listener endpoint as separate host and port strings.
sub kea_ddns_listener_endpoint
{
my ($root) = @_;
my $host = $root->{'ip-address'} || "";
my $port = defined($root->{'port'}) ? $root->{'port'} : "";
return ($host, $port);
}
# kea_ddns_listener_target(&root)
# Returns the D2 listener as host:port text for display and comparison.
sub kea_ddns_listener_target
{
my ($root) = @_;
my ($host, $port) = &kea_ddns_listener_endpoint($root);
return "" if ($host eq '' && $port eq '');
return $host.($port ne '' ? ":".$port : "");
}
# kea_ddns_listener_host(&root)
# Returns the normalized D2 listener host without IPv6 brackets.
sub kea_ddns_listener_host
{
my ($root) = @_;
my ($host) = &kea_ddns_listener_endpoint($root);
$host = lc(&kea_trim_form_value($host));
$host =~ s/^\[(.*)\]$/$1/;
return $host;
}
# kea_ddns_listener_loopback(&root)
# Returns true for addresses that still accept only local D2 requests.
sub kea_ddns_listener_loopback
{
my ($root) = @_;
my $host = &kea_ddns_listener_host($root);
return 0 if ($host eq '');
return 1 if ($host eq 'localhost' || $host eq '::1');
return $host =~ /^127(?:\.\d{1,3}){3}$/ ? 1 : 0;
}
# kea_ddns_listener_non_default_loopback(&root)
# 127.0.0.2 and similar are local, but DHCP sender targets must match exactly.
sub kea_ddns_listener_non_default_loopback
{
my ($root) = @_;
return 0 if (!&kea_ddns_listener_loopback($root));
my $host = &kea_ddns_listener_host($root);
return $host ne '127.0.0.1' && $host ne '::1' && $host ne 'localhost';
}
# kea_ddns_listener_non_loopback(&root)
# D2 should normally only receive name-change requests from local Kea daemons.
sub kea_ddns_listener_non_loopback
{
my ($root) = @_;
my $host = &kea_ddns_listener_host($root);
return 0 if ($host eq '');
return &kea_ddns_listener_loopback($root) ? 0 : 1;
}
# kea_ddns_domain_server_fields(&domain)
# Flattens D2 server addresses and only shows a port when it is safe to edit as
# one shared value. Mixed per-server ports are preserved by leaving it blank.
sub kea_ddns_domain_server_fields
{
my ($domain) = @_;
my $servers = ref($domain->{'dns-servers'}) eq 'ARRAY' ?
$domain->{'dns-servers'} : [ ];
my @addrs;
my ($port, $mixed_port, $server_without_port);
foreach my $s (@$servers) {
next if (ref($s) ne 'HASH');
push(@addrs, $s->{'ip-address'}) if ($s->{'ip-address'});
if (defined($s->{'port'})) {
if (!defined($port)) {
$port = $s->{'port'};
$mixed_port = 1 if ($server_without_port);
}
elsif ($port ne $s->{'port'}) {
$mixed_port = 1;
}
}
else {
$server_without_port = 1;
$mixed_port = 1;
}
}
return (join(" ", @addrs), !$mixed_port && defined($port) ? $port : "",
$mixed_port ? 1 : 0);
}
# kea_ddns_domain_rows(&domains, prefix)
# Renders the D2 forward/reverse DNS update domain table.
sub kea_ddns_domain_rows
{
my ($domains, $prefix) = @_;
$domains = [ ] if (ref($domains) ne 'ARRAY');
print &ui_tag_start('div', { 'class' => 'option-data-table' });
print &ui_columns_start([
&kea_field_hlink('ddns-domain-name', $text{'ddns_domain_name'}),
&kea_field_hlink('ddns-domain-key', $text{'ddns_domain_key'}),
&kea_field_hlink('ddns-domain-servers', $text{'ddns_domain_servers'}),
&kea_field_hlink('ddns-domain-port', $text{'ddns_domain_port'}) ], 100);
# D2 domains are array entries. Keep a few empty rows so new domains can be
# added without a separate "add row" round trip.
my $rows = @$domains + 3;
for(my $i=0; $i<$rows; $i++) {
my $d = ref($domains->[$i]) eq 'HASH' ? $domains->[$i] : { };
my ($servers, $port, $mixed_port) = &kea_ddns_domain_server_fields($d);
my $port_field = &ui_textbox($prefix."port_$i", $port, 7);
if ($mixed_port) {
$port_field .= " ".&ui_tag('small',
$text{'ddns_mixed_ports'}, {
'style' => 'color:var(--text-color-light, #777)' });
}
print &ui_columns_row([
&ui_textbox($prefix."name_$i", $d->{'name'} || "", 28),
&ui_textbox($prefix."key_$i", $d->{'key-name'} || "", 20),
&ui_textbox($prefix."servers_$i", $servers, 42),
$port_field,
]);
}
print &ui_columns_end();
print &ui_tag_end('div');
}
# kea_select_options(current-value, default-label, value...)
# Builds a conservative select list without losing uncommon existing values.
sub kea_select_options
{
my ($current, $default_label, @values) = @_;
my @opts = ([ "", $default_label ]);
my %known = ( '' => 1 );
foreach my $v (@values) {
push(@opts, [ $v, $v ]);
$known{$v} = 1;
}
if (defined($current) && $current ne '' && !$known{$current}) {
push(@opts, [ $current, $current ]);
}
return \@opts;
}
# kea_tsig_algorithm_options(current-value)
# Builds the TSIG algorithm select list while preserving unusual existing values.
sub kea_tsig_algorithm_options
{
my ($current) = @_;
return &kea_select_options($current, $text{'socket_default'},
'hmac-sha256', 'hmac-sha384', 'hmac-sha512',
'hmac-sha224', 'hmac-sha1', 'hmac-md5');
}
# kea_tsig_key_names(&keys)
# Returns configured TSIG key names for DDNS domain validation.
sub kea_tsig_key_names
{
my ($keys) = @_;
my %names;
foreach my $key (@{ref($keys) eq 'ARRAY' ? $keys : [ ]}) {
next if (ref($key) ne 'HASH' || !$key->{'name'});
$names{$key->{'name'}} = 1;
}
return \%names;
}
# kea_parse_ddns_domain_rows(&existing-domains, prefix, [&tsig-keys])
# Parses D2 domain rows while preserving unexposed keys by row index.
sub kea_parse_ddns_domain_rows
{
my ($domains, $prefix, $keys) = @_;
$domains = [ ] if (ref($domains) ne 'ARRAY');
my $keynames = ref($keys) eq 'ARRAY' ? &kea_tsig_key_names($keys) : undef;
my @parsed;
# Rebuild only submitted rows while retaining unexposed per-domain properties
# from the row's existing object.
for(my $i=0; defined($in{$prefix."name_$i"}) ||
defined($in{$prefix."key_$i"}) ||
defined($in{$prefix."servers_$i"}) ||
defined($in{$prefix."port_$i"}); $i++) {
my $name = &kea_trim_form_value($in{$prefix."name_$i"});
my $key = &kea_trim_form_value($in{$prefix."key_$i"});
my $servers = &kea_trim_form_value($in{$prefix."servers_$i"});
my $port = &kea_trim_form_value($in{$prefix."port_$i"});
next if ($name eq '' && $key eq '' && $servers eq '' && $port eq '');
&error($text{'ddns_edomain'}) if ($name eq '');
&error($text{'ddns_eserver'}) if ($servers eq '');
&error($text{'ddns_eport'}) if ($port ne '' && $port !~ /^\d+$/);
&error(&text('ddns_ekey_unknown', $key))
if ($key ne '' && $keynames && !$keynames->{$key});
my %domain = ref($domains->[$i]) eq 'HASH' ? %{$domains->[$i]} : ( );
&kea_set_optional_string(\%domain, 'name', $name);
&kea_set_optional_string(\%domain, 'key-name', $key);
my @addrs = grep { $_ ne '' } split(/[,\s]+/, $servers);
foreach my $addr (@addrs) {
&error(&text('ddns_eaddr', &html_escape($addr)))
if (!&check_ipaddress($addr) && !&check_ip6address($addr));
}
if (@addrs) {
my @old = ref($domain{'dns-servers'}) eq 'ARRAY' ?
@{$domain{'dns-servers'}} : ( );
my @newservers;
for(my $j=0; $j<@addrs; $j++) {
my %server = ref($old[$j]) eq 'HASH' ? %{$old[$j]} : ( );
$server{'ip-address'} = $addrs[$j];
if ($port ne '') {
$server{'port'} = int($port);
}
elsif (!defined($server{'port'})) {
$server{'port'} = 53;
}
push(@newservers, \%server);
}
$domain{'dns-servers'} = \@newservers;
}
else {
delete($domain{'dns-servers'});
}
push(@parsed, \%domain);
}
return \@parsed;
}
# kea_tsig_key_rows(&keys, prefix)
# Renders D2 TSIG keys used by DNS update domains.
sub kea_tsig_key_rows
{
my ($keys, $prefix) = @_;
$keys = [ ] if (ref($keys) ne 'ARRAY');
print &ui_tag_start('div', { 'class' => 'option-data-table' });
print &ui_columns_start([
&kea_field_hlink('tsig-key-name', $text{'tsig_key_name'}),
&kea_field_hlink('tsig-key-algorithm', $text{'tsig_key_algorithm'}),
&kea_field_hlink('tsig-key-secret', $text{'tsig_key_secret'}) ], 100);
# Existing secrets are never echoed back into the browser. Leaving the password
# field empty keeps the old secret during save.
my $rows = @$keys + 3;
for(my $i=0; $i<$rows; $i++) {
my $key = ref($keys->[$i]) eq 'HASH' ? $keys->[$i] : { };
print &ui_columns_row([
&ui_textbox($prefix."name_$i", $key->{'name'} || "", 24),
&ui_select($prefix."algorithm_$i", $key->{'algorithm'} || "",
&kea_tsig_algorithm_options($key->{'algorithm'})),
&ui_password($prefix."secret_$i", "", 44),
]);
}
print &ui_columns_end();
print &ui_tag_end('div');
}
# kea_parse_tsig_key_rows(&existing-keys, prefix)
# Parses TSIG key rows while preserving existing secrets unless replaced.
sub kea_parse_tsig_key_rows
{
my ($keys, $prefix) = @_;
$keys = [ ] if (ref($keys) ne 'ARRAY');
my @parsed;
# Empty rows are ignored. A visible name with no algorithm is rejected because
# Kea requires every TSIG key to declare an algorithm.
for(my $i=0; defined($in{$prefix."name_$i"}) ||
defined($in{$prefix."algorithm_$i"}) ||
defined($in{$prefix."secret_$i"}); $i++) {
my $name = &kea_trim_form_value($in{$prefix."name_$i"});
my $algorithm = &kea_trim_form_value($in{$prefix."algorithm_$i"});
my $secret = &kea_trim_form_value($in{$prefix."secret_$i"});
next if ($name eq '' && $algorithm eq '' && $secret eq '');
&error($text{'ddns_ekey'}) if ($name eq '');
&error($text{'ddns_ekey_algorithm'}) if ($algorithm eq '');
my %key = ref($keys->[$i]) eq 'HASH' ? %{$keys->[$i]} : ( );
&kea_set_optional_string(\%key, 'name', $name);
&kea_set_optional_string(\%key, 'algorithm', $algorithm);
$key{'secret'} = $secret if ($secret ne '');
push(@parsed, \%key);
}
return \@parsed;
}
# kea_logger_rows(&loggers, prefix)
# Renders the common fields from Kea's root-level loggers array.
sub kea_logger_rows
{
my ($loggers, $prefix) = @_;
$loggers = [ ] if (ref($loggers) ne 'ARRAY');
print &ui_tag_start('div', { 'class' => 'option-data-table' });
print &ui_columns_start([
&kea_field_hlink('logger-name', $text{'logging_name'}),
&kea_field_hlink('logger-severity', $text{'logging_severity'}),
&kea_field_hlink('logger-debuglevel', $text{'logging_debuglevel'}),
&kea_field_hlink('logger-output', $text{'logging_output'}),
&kea_field_hlink('logger-pattern', $text{'logging_pattern'}) ], 100);
# Existing logger rows are shown first, with spare rows for adding more
# loggers. Multiple output-options are preserved, but the UI edits the first.
my $rows = @$loggers + 2;
for(my $i=0; $i<$rows; $i++) {
my $logger = ref($loggers->[$i]) eq 'HASH' ? $loggers->[$i] : { };
my $outputs = ref($logger->{'output-options'}) eq 'ARRAY' ?
$logger->{'output-options'} : [ ];
my $first = ref($outputs->[0]) eq 'HASH' ? $outputs->[0] : { };
print &ui_columns_row([
&ui_textbox($prefix."name_$i", $logger->{'name'} || "", 24),
&kea_logger_severity_select($prefix."severity_$i",
$logger->{'severity'} || ""),
&ui_textbox($prefix."debuglevel_$i",
defined($logger->{'debuglevel'}) ?
$logger->{'debuglevel'} : "", 6),
&ui_textbox($prefix."output_$i", $first->{'output'} || "", 24),
&ui_textbox($prefix."pattern_$i",
&kea_logger_display_value($first->{'pattern'}), 32),
]);
}
print &ui_columns_end();
print &ui_tag_end('div');
}
# kea_logger_severity_select(name, value)
# Renders the logger severity selector.
sub kea_logger_severity_select
{
my ($name, $value) = @_;
return &ui_select($name, $value, [
[ "", $text{'logging_default'} ],
map { [ $_, $_ ] } qw(FATAL ERROR WARN INFO DEBUG)
]);
}
# kea_logger_display_value(value)
# Shows control characters in compact text fields without losing intent.
sub kea_logger_display_value
{
my ($value) = @_;
return "" if (!defined($value));
$value =~ s/\\/\\\\/g;
$value =~ s/\n/\\n/g;
$value =~ s/\r/\\r/g;
$value =~ s/\t/\\t/g;
return $value;
}
# kea_logger_form_value(value)
# Converts the visible escape form back to the value Kea expects in JSON.
sub kea_logger_form_value
{
my ($value) = @_;
$value = &kea_trim_form_value($value);
$value =~ s/\\n/\n/g;
$value =~ s/\\r/\r/g;
$value =~ s/\\t/\t/g;
$value =~ s/\\\\/\\/g;
return $value;
}
# kea_parse_common_option_rows(&options, version, prefix)
# Parses common option-data form fields back into option-data rows.
sub kea_parse_common_option_rows
{
my ($opts, $ver, $prefix) = @_;
$opts = [ ] if (ref($opts) ne 'ARRAY');
foreach my $name (&kea_common_option_names($ver)) {
my $value = &kea_trim_form_value($in{$prefix.$name});
&kea_set_option_value($opts, $ver, $name, $value);
}
}
# kea_parse_advanced_option_rows(&options, version, prefix)
# Parses advanced option-data form fields back into option-data rows.
sub kea_parse_advanced_option_rows
{
my ($opts, $ver, $prefix) = @_;
$opts = [ ] if (ref($opts) ne 'ARRAY');
foreach my $name (&kea_advanced_option_names($ver)) {
my $value = &kea_trim_form_value($in{$prefix.$name});
&kea_set_option_value($opts, $ver, $name, $value);
}
}
# kea_parse_option_rows(prefix)
# Parses generic option-data rows from submitted form fields.
sub kea_parse_option_rows
{
my ($prefix) = @_;
my @opts;
# Stop at the first index where all row fields have disappeared from the form.
for(my $i=0; defined($in{$prefix."name_$i"}) ||
defined($in{$prefix."code_$i"}) ||
defined($in{$prefix."data_$i"}); $i++) {
my $name = &kea_trim_form_value($in{$prefix."name_$i"});
my $code = &kea_trim_form_value($in{$prefix."code_$i"});
my $data = &kea_trim_form_value($in{$prefix."data_$i"});
my $space = &kea_trim_form_value($in{$prefix."space_$i"});
next if ($name eq '' && $code eq '' && $data eq '');
&error($text{'opt_ename'}) if ($name eq '' && $code eq '');
&error(&text('opt_ecode', $code))
if ($code ne '' && $code !~ /^\d+$/);
# Store the smallest valid option-data object; optional fields are added
# only when the admin typed them.
my %opt;
$opt{'name'} = $name if ($name ne '');
$opt{'code'} = int($code) if ($code =~ /^\d+$/);
$opt{'data'} = $data if ($data ne '');
$opt{'space'} = $space if ($space ne '');
push(@opts, \%opt);
}
return \@opts;
}
# kea_parse_other_option_rows(&options, version, prefix)
# Merges generic option-data rows with named option rows.
sub kea_parse_other_option_rows
{
my ($opts, $ver, $prefix) = @_;
if (defined($in{$prefix."name_0"}) ||
defined($in{$prefix."code_0"}) ||
defined($in{$prefix."space_0"}) ||
defined($in{$prefix."data_0"})) {
my $parsed = &kea_parse_option_rows($prefix);
# Start with known options currently owned by named fields, then merge the
# additional rows back in. This lets numeric known options round-trip cleanly.
my %managed_names = map { $_ => 1 } &kea_managed_option_names($ver);
my %managed_codes = map { $_ => 1 } &kea_managed_option_codes($ver);
my @merged = grep { &kea_known_option($_, $ver) } @$opts;
foreach my $o (@$parsed) {
&error(&text('opt_emanaged', $o->{'name'}))
if ($o->{'name'} && $managed_names{$o->{'name'}});
&error(&text('opt_emanaged', $o->{'code'}))
if (defined($o->{'code'}) && $managed_codes{$o->{'code'}});
my $name = $o->{'name'} || "";
my $code = $o->{'code'};
$name ||= &kea_option_name($ver, $code)
if (defined($code));
if ($name && &indexof($name, (&kea_common_option_names($ver),
&kea_advanced_option_names($ver))) >= 0) {
# A numeric row for a known option is promoted into the
# named field's canonical storage instead of being rejected
# as a duplicate.
&kea_set_option_value(\@merged, $ver, $name,
$o->{'data'}, $o);
next;
}
push(@merged, $o);
}
return \@merged;
}
return $opts;
}
# kea_parse_logger_rows(&loggers, prefix)
# Parses root-level Kea logger rows while preserving unexposed logger keys.
sub kea_parse_logger_rows
{
my ($loggers, $prefix) = @_;
$loggers = [ ] if (ref($loggers) ne 'ARRAY');
my @parsed;
# A completely empty row is ignored. Non-empty rows require a logger name
# because Kea uses the name to attach messages to the right logging channel.
for(my $i=0; defined($in{$prefix."name_$i"}) ||
defined($in{$prefix."severity_$i"}) ||
defined($in{$prefix."debuglevel_$i"}) ||
defined($in{$prefix."output_$i"}) ||
defined($in{$prefix."pattern_$i"}); $i++) {
my $name = &kea_trim_form_value($in{$prefix."name_$i"});
my $severity = &kea_trim_form_value($in{$prefix."severity_$i"});
my $debuglevel = &kea_trim_form_value($in{$prefix."debuglevel_$i"});
my $output = &kea_trim_form_value($in{$prefix."output_$i"});
my $pattern = &kea_logger_form_value($in{$prefix."pattern_$i"});
next if ($name eq '' && $severity eq '' && $debuglevel eq '' &&
$output eq '' && $pattern eq '');
&error($text{'logging_ename'}) if ($name eq '');
&error($text{'logging_edebug'})
if ($debuglevel ne '' && $debuglevel !~ /^\d+$/);
&error($text{'logging_eseverity'})
if ($severity ne '' &&
&indexof($severity, qw(FATAL ERROR WARN INFO DEBUG)) < 0);
&error($text{'logging_edebug_severity'})
if ($debuglevel ne '' && $debuglevel != 0 &&
$severity ne 'DEBUG');
my %logger = ref($loggers->[$i]) eq 'HASH' ? %{$loggers->[$i]} : ( );
$logger{'name'} = $name;
if ($severity ne '') {
$logger{'severity'} = $severity;
}
else {
delete($logger{'severity'});
}
if ($debuglevel ne '') {
$logger{'debuglevel'} = int($debuglevel);
}
else {
delete($logger{'debuglevel'});
}
# Only the first output-options entry is edited by the UI. Additional
# entries, if present, are kept after it.
my @outputs = ref($logger{'output-options'}) eq 'ARRAY' ?
@{$logger{'output-options'}} : ( );
my %first = ref($outputs[0]) eq 'HASH' ? %{$outputs[0]} : ( );
if ($output ne '') {
$first{'output'} = $output;
}
else {
delete($first{'output'});
}
if ($pattern ne '') {
$first{'pattern'} = $pattern;
}
else {
delete($first{'pattern'});
}
if (keys(%first)) {
$outputs[0] = \%first;
$logger{'output-options'} = \@outputs;
}
elsif (@outputs > 1) {
shift(@outputs);
$logger{'output-options'} = \@outputs;
}
else {
delete($logger{'output-options'});
}
push(@parsed, \%logger);
}
return \@parsed;
}
# kea_parse_pool_rows(prefix)
# Parses DHCP address pool rows from submitted form fields.
sub kea_parse_pool_rows
{
my ($prefix) = @_;
my @pools;
# Pool syntax varies between address families and can include ranges or CIDR.
# Kea's native config test validates the final string.
for(my $i=0; defined($in{$prefix."pool_$i"}); $i++) {
my $pool = &kea_trim_form_value($in{$prefix."pool_$i"});
next if ($pool eq '');
push(@pools, { 'pool' => $pool });
}
return \@pools;
}
# kea_parse_pd_pool_rows(prefix)
# Parses DHCPv6 prefix-delegation pool rows.
sub kea_parse_pd_pool_rows
{
my ($prefix) = @_;
my @pools;
# Prefix delegation is DHCPv6-only, but keeping the parser here lets the subnet
# save handler stay protocol-neutral.
for(my $i=0; defined($in{$prefix."prefix_$i"}) ||
defined($in{$prefix."prefix_len_$i"}) ||
defined($in{$prefix."delegated_len_$i"}); $i++) {
my $pool_prefix = &kea_trim_form_value($in{$prefix."prefix_$i"});
my $plen = &kea_trim_form_value($in{$prefix."prefix_len_$i"});
my $dlen = &kea_trim_form_value($in{$prefix."delegated_len_$i"});
my $excluded = &kea_trim_form_value($in{$prefix."excluded_prefix_$i"});
my $elen = &kea_trim_form_value($in{$prefix."excluded_prefix_len_$i"});
next if ($pool_prefix eq '' && $plen eq '' && $dlen eq '');
my %pool;
$pool{'prefix'} = $pool_prefix if ($pool_prefix ne '');
$pool{'prefix-len'} = int($plen) if ($plen =~ /^\d+$/);
$pool{'delegated-len'} = int($dlen) if ($dlen =~ /^\d+$/);
$pool{'excluded-prefix'} = $excluded if ($excluded ne '');
$pool{'excluded-prefix-len'} = int($elen) if ($elen =~ /^\d+$/);
push(@pools, \%pool);
}
return \@pools;
}
# kea_parse_reservation_rows(prefix, version)
# Parses host reservation rows for DHCPv4 or DHCPv6.
sub kea_parse_reservation_rows
{
my ($prefix, $ver) = @_;
my @reservations;
# Reservations differ by protocol: DHCPv6 can assign multiple addresses and
# delegated prefixes, while DHCPv4 stores a single ip-address value.
for(my $i=0; defined($in{$prefix."type_$i"}) ||
defined($in{$prefix."identifier_$i"}) ||
defined($in{$prefix."address_$i"}); $i++) {
my $type = &kea_trim_form_value($in{$prefix."type_$i"}) ||
($ver == 6 ? 'duid' : 'hw-address');
my $identifier = &kea_trim_form_value($in{$prefix."identifier_$i"});
my $address = &kea_trim_form_value($in{$prefix."address_$i"});
my $hostname = &kea_trim_form_value($in{$prefix."hostname_$i"});
my $prefixes = &kea_trim_form_value($in{$prefix."prefixes_$i"});
next if ($identifier eq '' && $address eq '' && $hostname eq '');
my %r;
$r{$type} = $identifier if ($identifier ne '');
if ($ver == 6) {
my @addresses = grep { $_ ne '' } split(/\s*,\s*|\s+/, $address || "");
$r{'ip-addresses'} = \@addresses if (@addresses);
my @prefixes = grep { $_ ne '' } split(/\s*,\s*|\s+/, $prefixes || "");
$r{'prefixes'} = \@prefixes if (@prefixes);
}
else {
$r{'ip-address'} = $address if ($address ne '');
}
$r{'hostname'} = $hostname if ($hostname ne '');
push(@reservations, \%r);
}
return \@reservations;
}
# kea_component_interfaces(&config)
# Returns configured listen interfaces for a DHCP component.
sub kea_component_interfaces
{
my ($root) = @_;
my $ifaces = ref($root->{'interfaces-config'}) eq 'HASH' ?
$root->{'interfaces-config'}->{'interfaces'} : undef;
return $text{'iface_none'} if (ref($ifaces) ne 'ARRAY' || !@$ifaces);
return join(", ", @$ifaces);
}
# kea_dhcp_subnet_count(&root, version)
# Counts subnets across daemon-level and shared-network scopes.
sub kea_dhcp_subnet_count
{
my ($root, $ver) = @_;
my $subkey = &kea_subnet_key($ver);
my @shareds = ref($root->{'shared-networks'}) eq 'ARRAY' ?
@{$root->{'shared-networks'}} : ( );
my @subnets = ref($root->{$subkey}) eq 'ARRAY' ? @{$root->{$subkey}} : ( );
foreach my $s (@shareds) {
push(@subnets, @{$s->{$subkey}}) if (ref($s->{$subkey}) eq 'ARRAY');
}
return scalar(@subnets);
}
# kea_dhcp_has_listening_interfaces(&root)
# Returns true when interfaces-config contains at least one interface.
sub kea_dhcp_has_listening_interfaces
{
my ($root) = @_;
my $ifaces = ref($root->{'interfaces-config'}) eq 'HASH' ?
$root->{'interfaces-config'}->{'interfaces'} : undef;
return ref($ifaces) eq 'ARRAY' && @$ifaces ? 1 : 0;
}
# kea_dhcp_needs_interface_warning(&root, version)
# Returns true when subnets exist but no listening interfaces are configured.
sub kea_dhcp_needs_interface_warning
{
my ($root, $ver) = @_;
return &kea_dhcp_subnet_count($root, $ver) &&
!&kea_dhcp_has_listening_interfaces($root) ? 1 : 0;
}
# kea_component_summary(&component, &root)
# Returns the services-table summary for a component config.
sub kea_component_summary
{
my ($c, $root) = @_;
if ($c->{'id'} eq 'dhcp4') {
return &kea_dhcp_summary($root, 4);
}
elsif ($c->{'id'} eq 'dhcp6') {
return &kea_dhcp_summary($root, 6);
}
elsif ($c->{'id'} eq 'ddns') {
# DDNS configs are deep; the summary only reports whether
# forward/reverse domain lists are present.
my $forward = ref($root->{'forward-ddns'}) eq 'HASH' ?
$root->{'forward-ddns'}->{'ddns-domains'} : undef;
my $reverse = ref($root->{'reverse-ddns'}) eq 'HASH' ?
$root->{'reverse-ddns'}->{'ddns-domains'} : undef;
return &text('summary_ddns',
&kea_yesno($forward), &kea_yesno($reverse));
}
elsif ($c->{'id'} eq 'ctrl') {
return &text('summary_ctrl',
$root->{'http-host'} || "127.0.0.1",
$root->{'http-port'} || "8000");
}
return $text{'summary_empty'};
}
# kea_dhcp_summary(&root, 4|6)
# Summarizes DHCP subnets, pools, and reservations for one protocol.
sub kea_dhcp_summary
{
my ($root, $ver) = @_;
my $subkey = $ver == 6 ? 'subnet6' : 'subnet4';
# Count subnets both at the daemon level and inside shared networks because Kea
# allows either storage location.
my @shareds = ref($root->{'shared-networks'}) eq 'ARRAY' ?
@{$root->{'shared-networks'}} : ( );
my @subnets = ref($root->{$subkey}) eq 'ARRAY' ? @{$root->{$subkey}} : ( );
foreach my $s (@shareds) {
push(@subnets, @{$s->{$subkey}}) if (ref($s->{$subkey}) eq 'ARRAY');
}
my ($pools, $pdpools, $reservations) = (0, 0, 0);
foreach my $s (@subnets) {
$pools += @{$s->{'pools'}} if (ref($s->{'pools'}) eq 'ARRAY');
$pdpools += @{$s->{'pd-pools'}} if (ref($s->{'pd-pools'}) eq 'ARRAY');
$reservations += @{$s->{'reservations'}}
if (ref($s->{'reservations'}) eq 'ARRAY');
}
return $ver == 6 ? &text('summary_dhcp6', scalar(@subnets),
scalar(@shareds), $pools, $pdpools, $reservations)
: &text('summary_dhcp4', scalar(@subnets),
scalar(@shareds), $pools, $reservations);
}
# kea_yesno(value)
# Returns the localized yes/no label for a boolean value.
sub kea_yesno
{
my ($v) = @_;
return $v ? $text{'yes'} : $text{'no'};
}
# kea_action_command(action)
# Builds the command used by global start, stop, and restart actions.
sub kea_action_command
{
my ($action) = @_;
my $cmd = &kea_config_value($action.'_cmd');
# Ignore packaged keactrl commands on systems where the Kea packages no longer
# ship keactrl, and fall back to systemd units below.
if ($cmd && $cmd =~ /\bkeactrl\b/ &&
!&has_command(&kea_config_value('keactrl_path')) &&
!&has_command('keactrl')) {
$cmd = undef;
}
if ($cmd && $cmd =~ /^systemctl\b/ && !&has_command('systemctl')) {
$cmd = undef;
}
if (!$cmd) {
my $systemctl = &has_command('systemctl');
if ($systemctl) {
my $verb = $action eq 'restart' ? 'restart' : $action;
my @units = grep { $_ } map { &kea_component_systemd_unit($_) }
&kea_components();
# Act on all Kea units together because the header action buttons
# are global.
$cmd = &quote_path($systemctl)." ".$verb." ".
join(" ", @units);
}
}
if (!$cmd) {
my $keactrl = &has_command(&kea_config_value('keactrl_path')) ||
&has_command('keactrl');
if ($keactrl) {
my $kcmd = $action eq 'restart' ? 'reload' : $action;
$cmd = &quote_path($keactrl)." ".$kcmd;
}
}
return $cmd;
}
# kea_run_action(action)
# Executes a global Kea service action and returns any failure text.
sub kea_run_action
{
my ($action) = @_;
my $cmd = &kea_action_command($action);
return $text{'action_ecmd'} if (!$cmd);
# Return formatted command output only on failure; successful actions redirect
# back to the module index with a Webmin log entry.
my $out = &backquote_logged("$cmd 2>&1");
if ($?) {
return &ui_tag('pre', &html_escape($out));
}
return;
}
# get_all_config_files()
# Returns config files that Webmin should include in module backups.
sub get_all_config_files
{
my @rv;
foreach my $c (&kea_components()) {
my $file = &kea_config_file($c);
next if (!$file);
push(@rv, $file);
# Backups should include files pulled in by Kea include directives.
push(@rv, &kea_config_includes($file, 0)) if (-r $file);
}
# The Control Agent can keep API credentials in a separate password file.
# Include it with the config backup so a restore can bring the service back.
push(@rv, &kea_control_agent_password_files());
return &unique(@rv);
}
# kea_config_includes(file, depth)
# Finds Kea include files so backups cover all referenced config fragments.
sub kea_config_includes
{
my ($file, $depth) = @_;
return ( ) if ($depth > 10);
my $text = &read_file_contents($file);
return ( ) if (!defined($text));
my $dir = &kea_dirname($file);
my @rv;
# Keep include discovery parallel to parse-time expansion so backup coverage
# matches the files Kea would read.
while($text =~ /<\?include\s+"([^"]+)"\s*\?>/g) {
my $inc = $1 =~ /^\// ? $1 :
-r "$dir/$1" ? "$dir/$1" : $1;
push(@rv, $inc);
push(@rv, &kea_config_includes($inc, $depth + 1)) if (-r $inc);
}
return @rv;
}
1;