Linux mail.vriendennsm.nl 6.1.0-51-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.177-1 (2026-07-16) x86_64
Apache/2.4.68 (Debian)
Server IP : 217.154.75.17 & Your IP : 216.73.216.195
Domains :
Cant Read [ /etc/named.conf ]
User : www-data
Terminal
Auto Root
Create File
Create Folder
Localroot Suggester
Backdoor Destroyer
Readme
/
usr /
share /
webmin /
kea-dhcp /
Delete
Unzip
Name
Size
Permission
Date
Action
help
[ DIR ]
drwxr-xr-x
2026-07-25 20:55
images
[ DIR ]
drwxr-xr-x
2026-07-25 20:55
lang
[ DIR ]
drwxr-xr-x
2026-07-25 20:55
acl_security.pl
1.12
KB
-rw-r--r--
2026-07-17 21:17
backup_config.pl
951
B
-rw-r--r--
2026-07-17 21:17
config
1.14
KB
-rw-r--r--
2026-07-17 21:17
config-freebsd
884
B
-rw-r--r--
2026-07-17 21:17
config-redhat-linux
1.05
KB
-rw-r--r--
2026-07-17 21:17
config.info
1.11
KB
-rw-r--r--
2026-07-17 21:17
defaultacl
98
B
-rw-r--r--
2026-07-17 21:17
delete_objects.cgi
1.94
KB
-rwxr-xr-x
2026-07-17 21:22
edit_ddns.cgi
4.17
KB
-rwxr-xr-x
2026-07-17 21:22
edit_options.cgi
10.51
KB
-rwxr-xr-x
2026-07-17 21:22
edit_shared.cgi
5.11
KB
-rwxr-xr-x
2026-07-17 21:22
edit_subnet.cgi
9.84
KB
-rwxr-xr-x
2026-07-17 21:22
edit_text.cgi
1.42
KB
-rwxr-xr-x
2026-07-17 21:22
index.cgi
21.32
KB
-rwxr-xr-x
2026-07-17 21:22
install_check.pl
459
B
-rw-r--r--
2026-07-17 21:17
kea-dhcp-lib.pl
73.94
KB
-rw-r--r--
2026-07-17 21:17
log_parser.pl
1.73
KB
-rw-r--r--
2026-07-17 21:17
module.info
289
B
-rw-r--r--
2026-07-17 21:17
restart.cgi
470
B
-rwxr-xr-x
2026-07-17 21:22
runtime.cgi
5.25
KB
-rwxr-xr-x
2026-07-17 21:22
save_ddns.cgi
2.75
KB
-rwxr-xr-x
2026-07-17 21:22
save_options.cgi
5.6
KB
-rwxr-xr-x
2026-07-17 21:22
save_shared.cgi
2.54
KB
-rwxr-xr-x
2026-07-17 21:22
save_subnet.cgi
3.51
KB
-rwxr-xr-x
2026-07-17 21:22
save_text.cgi
1.45
KB
-rwxr-xr-x
2026-07-17 21:22
start.cgi
472
B
-rwxr-xr-x
2026-07-17 21:22
stop.cgi
468
B
-rwxr-xr-x
2026-07-17 21:22
Save
Rename
# 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 = "e_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 = "e_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 = "e_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 = "e_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;