diff --git a/kea-dhcp/acl_security.pl b/kea-dhcp/acl_security.pl
new file mode 100644
index 000000000..d663e4b85
--- /dev/null
+++ b/kea-dhcp/acl_security.pl
@@ -0,0 +1,42 @@
+use strict;
+use warnings;
+no warnings 'redefine';
+no warnings 'uninitialized';
+
+require 'kea-dhcp-lib.pl'; ## no critic
+
+our (%in, %text);
+
+# acl_security_form(&options)
+# Output HTML for editing security options for the Kea DHCP module
+sub acl_security_form
+{
+my ($o) = @_;
+
+# Keep read-only capabilities separate from actions that change files or daemon
+# state, matching the way the module's tabs and buttons are gated.
+print ui_table_span(&ui_tag('b', &html_escape($text{'acl_section_view'})));
+foreach my $a (qw(dhcp4 dhcp6 ddns services runtime)) {
+ print ui_table_row($text{'acl_'.$a},
+ ui_yesno_radio($a, kea_check_acl($a, $o)), 3);
+ }
+print ui_table_hr();
+print ui_table_span(&ui_tag('b', &html_escape($text{'acl_section_change'})));
+foreach my $a (qw(edit4 edit6 editddns manual apply install)) {
+ print ui_table_row($text{'acl_'.$a},
+ ui_yesno_radio($a, kea_check_acl($a, $o)), 3);
+ }
+}
+
+# acl_security_save(&options)
+# Parse the form for security options for the Kea DHCP module
+sub acl_security_save
+{
+my ($o) = @_;
+
+foreach my $a (kea_acl_keys()) {
+ $o->{$a} = $in{$a} || 0;
+ }
+}
+
+1;
diff --git a/kea-dhcp/backup_config.pl b/kea-dhcp/backup_config.pl
new file mode 100644
index 000000000..472c4a54f
--- /dev/null
+++ b/kea-dhcp/backup_config.pl
@@ -0,0 +1,46 @@
+
+use strict;
+use warnings;
+require 'kea-dhcp-lib.pl';
+
+# backup_config_files()
+# Returns files that can be backed up.
+sub backup_config_files
+{
+return &get_all_config_files();
+}
+
+# pre_backup()
+# Runs before Webmin backs up Kea configuration files.
+sub pre_backup
+{
+# No pre-backup daemon action is needed; Kea configs are ordinary files.
+return;
+}
+
+# post_backup()
+# Runs after Webmin completes a Kea configuration backup.
+sub post_backup
+{
+# Backups are read-only, so leave running services untouched.
+return;
+}
+
+# pre_restore()
+# Runs before Webmin restores Kea configuration files.
+sub pre_restore
+{
+# Restore writes happen before service reload, so there is nothing to prepare.
+return;
+}
+
+# post_restore()
+# Runs after Webmin restores Kea configuration files.
+sub post_restore
+{
+# If Kea was active before restore, reload it so restored files take effect.
+return &kea_run_action('restart') if (&kea_running_pids());
+return;
+}
+
+1;
diff --git a/kea-dhcp/config b/kea-dhcp/config
new file mode 100644
index 000000000..3db87310c
--- /dev/null
+++ b/kea-dhcp/config
@@ -0,0 +1,22 @@
+dhcp4_conf=/etc/kea/kea-dhcp4.conf
+dhcp6_conf=/etc/kea/kea-dhcp6.conf
+ddns_conf=/etc/kea/kea-dhcp-ddns.conf
+ctrl_agent_conf=/etc/kea/kea-ctrl-agent.conf
+dhcp4_path=/usr/sbin/kea-dhcp4
+dhcp6_path=/usr/sbin/kea-dhcp6
+ddns_path=/usr/sbin/kea-dhcp-ddns
+ctrl_agent_path=/usr/sbin/kea-ctrl-agent
+keactrl_path=/usr/sbin/keactrl
+dhcp4_lease_file=/var/lib/kea/kea-leases4.csv
+dhcp6_lease_file=/var/lib/kea/kea-leases6.csv
+dhcp4_pid_file=/run/kea/kea-dhcp4.kea-dhcp4.pid
+dhcp6_pid_file=/run/kea/kea-dhcp6.kea-dhcp6.pid
+ddns_pid_file=/run/kea/kea-dhcp-ddns.kea-dhcp-ddns.pid
+ctrl_agent_pid_file=/run/kea/kea-ctrl-agent.kea-ctrl-agent.pid
+dhcp4_unit=kea-dhcp4-server.service
+dhcp6_unit=kea-dhcp6-server.service
+ddns_unit=kea-dhcp-ddns-server.service
+ctrl_agent_unit=kea-ctrl-agent.service
+start_cmd=systemctl start kea-dhcp4-server.service kea-dhcp6-server.service kea-dhcp-ddns-server.service kea-ctrl-agent.service
+stop_cmd=systemctl stop kea-dhcp4-server.service kea-dhcp6-server.service kea-dhcp-ddns-server.service kea-ctrl-agent.service
+restart_cmd=systemctl restart kea-dhcp4-server.service kea-dhcp6-server.service kea-dhcp-ddns-server.service kea-ctrl-agent.service
diff --git a/kea-dhcp/config-freebsd b/kea-dhcp/config-freebsd
new file mode 100644
index 000000000..0d28c672a
--- /dev/null
+++ b/kea-dhcp/config-freebsd
@@ -0,0 +1,22 @@
+dhcp4_conf=/usr/local/etc/kea/kea-dhcp4.conf
+dhcp6_conf=/usr/local/etc/kea/kea-dhcp6.conf
+ddns_conf=/usr/local/etc/kea/kea-dhcp-ddns.conf
+ctrl_agent_conf=/usr/local/etc/kea/kea-ctrl-agent.conf
+dhcp4_path=/usr/local/sbin/kea-dhcp4
+dhcp6_path=/usr/local/sbin/kea-dhcp6
+ddns_path=/usr/local/sbin/kea-dhcp-ddns
+ctrl_agent_path=/usr/local/sbin/kea-ctrl-agent
+keactrl_path=/usr/local/sbin/keactrl
+dhcp4_lease_file=/var/db/kea/kea-leases4.csv
+dhcp6_lease_file=/var/db/kea/kea-leases6.csv
+dhcp4_pid_file=/var/run/kea/kea-dhcp4.kea-dhcp4.pid
+dhcp6_pid_file=/var/run/kea/kea-dhcp6.kea-dhcp6.pid
+ddns_pid_file=/var/run/kea/kea-dhcp-ddns.kea-dhcp-ddns.pid
+ctrl_agent_pid_file=/var/run/kea/kea-ctrl-agent.kea-ctrl-agent.pid
+dhcp4_unit=
+dhcp6_unit=
+ddns_unit=
+ctrl_agent_unit=
+start_cmd=/usr/local/sbin/keactrl start
+stop_cmd=/usr/local/sbin/keactrl stop
+restart_cmd=/usr/local/sbin/keactrl reload
diff --git a/kea-dhcp/config-redhat-linux b/kea-dhcp/config-redhat-linux
new file mode 100644
index 000000000..fde84f112
--- /dev/null
+++ b/kea-dhcp/config-redhat-linux
@@ -0,0 +1,22 @@
+dhcp4_conf=/etc/kea/kea-dhcp4.conf
+dhcp6_conf=/etc/kea/kea-dhcp6.conf
+ddns_conf=/etc/kea/kea-dhcp-ddns.conf
+ctrl_agent_conf=/etc/kea/kea-ctrl-agent.conf
+dhcp4_path=/usr/sbin/kea-dhcp4
+dhcp6_path=/usr/sbin/kea-dhcp6
+ddns_path=/usr/sbin/kea-dhcp-ddns
+ctrl_agent_path=/usr/sbin/kea-ctrl-agent
+keactrl_path=/usr/sbin/keactrl
+dhcp4_lease_file=/var/lib/kea/kea-leases4.csv
+dhcp6_lease_file=/var/lib/kea/kea-leases6.csv
+dhcp4_pid_file=/run/kea/kea-dhcp4.kea-dhcp4.pid
+dhcp6_pid_file=/run/kea/kea-dhcp6.kea-dhcp6.pid
+ddns_pid_file=/run/kea/kea-dhcp-ddns.kea-dhcp-ddns.pid
+ctrl_agent_pid_file=/run/kea/kea-ctrl-agent.kea-ctrl-agent.pid
+dhcp4_unit=kea-dhcp4.service
+dhcp6_unit=kea-dhcp6.service
+ddns_unit=kea-dhcp-ddns.service
+ctrl_agent_unit=kea-ctrl-agent.service
+start_cmd=systemctl start kea-dhcp4.service kea-dhcp6.service kea-dhcp-ddns.service kea-ctrl-agent.service
+stop_cmd=systemctl stop kea-dhcp4.service kea-dhcp6.service kea-dhcp-ddns.service kea-ctrl-agent.service
+restart_cmd=systemctl restart kea-dhcp4.service kea-dhcp6.service kea-dhcp-ddns.service kea-ctrl-agent.service
diff --git a/kea-dhcp/config.info b/kea-dhcp/config.info
new file mode 100644
index 000000000..d74e28106
--- /dev/null
+++ b/kea-dhcp/config.info
@@ -0,0 +1,28 @@
+line1=Configuration files,11
+dhcp4_conf=Kea DHCPv4 config file,0
+dhcp6_conf=Kea DHCPv6 config file,0
+ddns_conf=Kea DHCP-DDNS config file,0
+ctrl_agent_conf=Kea Control Agent config file,0
+line2=Executables,11
+dhcp4_path=Kea DHCPv4 executable,0
+dhcp6_path=Kea DHCPv6 executable,0
+ddns_path=Kea DHCP-DDNS executable,0
+ctrl_agent_path=Kea Control Agent executable,0
+keactrl_path=Kea control script,0
+line3=Lease files,11
+dhcp4_lease_file=Kea DHCPv4 memfile leases file,0
+dhcp6_lease_file=Kea DHCPv6 memfile leases file,0
+line4=PID files,11
+dhcp4_pid_file=Kea DHCPv4 PID file,3,None
+dhcp6_pid_file=Kea DHCPv6 PID file,3,None
+ddns_pid_file=Kea DHCP-DDNS PID file,3,None
+ctrl_agent_pid_file=Kea Control Agent PID file,3,None
+line5=Systemd units,11
+dhcp4_unit=Kea DHCPv4 systemd unit,0
+dhcp6_unit=Kea DHCPv6 systemd unit,0
+ddns_unit=Kea DHCP-DDNS systemd unit,0
+ctrl_agent_unit=Kea Control Agent systemd unit,0
+line6=Commands,11
+start_cmd=Command to start Kea services,3,Use systemd service units
+stop_cmd=Command to stop Kea services,3,Use systemd service units
+restart_cmd=Command to apply Kea configuration,3,Restart systemd service units
diff --git a/kea-dhcp/defaultacl b/kea-dhcp/defaultacl
new file mode 100644
index 000000000..04e4e6352
--- /dev/null
+++ b/kea-dhcp/defaultacl
@@ -0,0 +1,11 @@
+dhcp4=1
+dhcp6=1
+ddns=1
+services=1
+runtime=1
+edit4=1
+edit6=1
+editddns=1
+manual=1
+apply=1
+install=1
diff --git a/kea-dhcp/delete_objects.cgi b/kea-dhcp/delete_objects.cgi
new file mode 100755
index 000000000..758434fd6
--- /dev/null
+++ b/kea-dhcp/delete_objects.cgi
@@ -0,0 +1,53 @@
+#!/usr/local/bin/perl
+# Delete selected Kea DHCP subnets and shared networks.
+
+use strict;
+use warnings;
+require './kea-dhcp-lib.pl';
+&ReadParse();
+our (%in, %text);
+&error_setup($text{'eacl_aviol'});
+
+my $ver = $in{'version'} == 6 ? 6 : 4;
+&kea_assert_acl('edit'.$ver);
+my ($c, $root, $data, $err) = &kea_read_dhcp_config($ver);
+&error($err) if ($err);
+
+&error_setup($text{'delete_failsave'});
+my @shared = split(/\0/, defined($in{'d_shared'}) ? $in{'d_shared'} : "");
+my @subnets = split(/\0/, defined($in{'d_subnet'}) ? $in{'d_subnet'} : "");
+@shared || @subnets || &error($text{'delete_enone'});
+
+# Group subnet deletions by parent so indexes can be removed descending within
+# each array without disturbing later deletions.
+my %subnets_by_parent;
+foreach my $v (@subnets) {
+ $v =~ /^(\d*):(\d+)$/ || &error($text{'subnet_enone'});
+ my ($sidx, $idx) = ($1, $2);
+ &error($text{'subnet_enone'})
+ if (!&kea_valid_subnet_parent($root, $sidx));
+ my $list = &kea_subnet_list($root, $ver, $sidx);
+ &error($text{'subnet_enone'}) if (!$list->[$idx]);
+ push(@{$subnets_by_parent{$sidx}}, $idx);
+ }
+foreach my $sidx (keys %subnets_by_parent) {
+ my $list = &kea_subnet_list($root, $ver, $sidx);
+ foreach my $idx (sort { $b <=> $a } @{$subnets_by_parent{$sidx}}) {
+ splice(@$list, $idx, 1);
+ }
+ }
+
+# Shared networks are top-level siblings, so delete them after nested subnets.
+my $shareds = &kea_shared_networks($root);
+foreach my $idx (sort { $b <=> $a } @shared) {
+ $idx =~ /^\d+$/ || &error($text{'shared_enone'});
+ &error($text{'shared_enone'}) if (!$shareds->[$idx]);
+ my $subs = &kea_subnet_list($root, $ver, $idx);
+ &error($text{'shared_enonempty'}) if (@$subs);
+ splice(@$shareds, $idx, 1);
+ }
+
+my $saveerr = &kea_save_component_config($c, $data);
+&error($saveerr) if ($saveerr);
+&webmin_log("delete", "objects", scalar(@shared) + scalar(@subnets), \%in);
+&redirect("index.cgi?mode=dhcp$ver");
diff --git a/kea-dhcp/edit_ddns.cgi b/kea-dhcp/edit_ddns.cgi
new file mode 100755
index 000000000..5f63dccbe
--- /dev/null
+++ b/kea-dhcp/edit_ddns.cgi
@@ -0,0 +1,100 @@
+#!/usr/local/bin/perl
+# Edit settings for the Kea DHCP-DDNS daemon.
+
+use strict;
+use warnings;
+require './kea-dhcp-lib.pl';
+&ReadParse();
+our (%in, %text);
+&error_setup($text{'eacl_aviol'});
+&kea_assert_acl('editddns');
+
+my $c = &kea_component('ddns');
+my ($root, $err) = &kea_read_component_config($c);
+&error($err) if ($err);
+
+# D2 is a separate daemon shared by DHCPv4 and DHCPv6, so keep its settings
+# outside the protocol-specific global DHCP pages.
+&ui_print_header(undef, $text{'ddns_title'}, "", undef, 1, 1);
+print &kea_comment_loss_warning($c);
+print &ui_form_start("save_ddns.cgi", "post");
+
+my @tabs = (
+ [ 'listener', $text{'tab_listener'} ],
+ [ 'zones', $text{'tab_zones'} ],
+ [ 'tsig', $text{'tab_tsig'} ],
+ [ 'logging', $text{'tab_logging'} ],
+ );
+print &ui_tabs_start(\@tabs, "mode", $in{'mode'} || "listener", 1);
+
+# The listener receives name-change requests from DHCPv4/DHCPv6 and exposes a
+# local control socket for daemon management.
+print &ui_tabs_start_tab("mode", "listener");
+print &ui_div($text{'ddns_listener_desc'});
+print &ui_alert_box($text{'ddns_listener_warn'}, "warn", undef, undef, "")
+ if (&kea_ddns_listener_non_loopback($root));
+print &ui_alert_box($text{'ddns_listener_warn_loopback'}, "warn", undef, undef, "")
+ if (!&kea_ddns_listener_non_loopback($root) &&
+ &kea_ddns_listener_non_default_loopback($root));
+print &ui_table_start($text{'ddns_listener'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('ddns-ip-address',
+ $text{'ddns_ip_address'}),
+ &ui_textbox("ip_address", $root->{'ip-address'} || "", 24));
+print &ui_table_row(&kea_field_hlink('ddns-port', $text{'ddns_port'}),
+ &ui_textbox("port", defined($root->{'port'}) ? $root->{'port'} : "",
+ 8));
+print &ui_table_row(&kea_field_hlink('ddns-timeout', $text{'ddns_timeout'}),
+ &ui_textbox("dns_server_timeout",
+ defined($root->{'dns-server-timeout'}) ?
+ $root->{'dns-server-timeout'} : "", 8));
+print &ui_table_row(&kea_field_hlink('ncr-protocol',
+ $text{'ddns_ncr_protocol'}),
+ &ui_select("ncr_protocol", $root->{'ncr-protocol'} || "UDP",
+ &kea_select_options($root->{'ncr-protocol'}, $text{'socket_default'},
+ 'UDP')));
+print &ui_table_row(&kea_field_hlink('ncr-format', $text{'ddns_ncr_format'}),
+ &ui_select("ncr_format", $root->{'ncr-format'} || "JSON",
+ &kea_select_options($root->{'ncr-format'}, $text{'socket_default'},
+ 'JSON')));
+print &ui_table_end();
+
+my $socket = ref($root->{'control-socket'}) eq 'HASH' ?
+ $root->{'control-socket'} : { };
+print &ui_table_start($text{'control_socket'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('control-socket-type',
+ $text{'control_socket_type'}),
+ &ui_select("control_socket_type", $socket->{'socket-type'} || "",
+ [ [ "", $text{'socket_default'} ],
+ [ "unix", "Unix" ] ]));
+print &ui_table_row(&kea_field_hlink('control-socket-name',
+ $text{'control_socket_name'}),
+ &ui_textbox("control_socket_name", $socket->{'socket-name'} || "", 50));
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "listener");
+
+# Forward and reverse DDNS domains tell D2 where to send DNS updates.
+print &ui_tabs_start_tab("mode", "zones");
+print &ui_div($text{'ddns_zones_desc'});
+print &ui_subheading($text{'ddns_forward'});
+&kea_ddns_domain_rows(&kea_ddns_domains($root, 'forward-ddns'), "fwd_");
+print &ui_subheading($text{'ddns_reverse'});
+&kea_ddns_domain_rows(&kea_ddns_domains($root, 'reverse-ddns'), "rev_");
+print &ui_tabs_end_tab("mode", "zones");
+
+# TSIG keys are referenced by update domains using key-name.
+print &ui_tabs_start_tab("mode", "tsig");
+print &ui_div($text{'ddns_tsig_desc'});
+print &ui_subheading($text{'ddns_tsig_keys'});
+&kea_tsig_key_rows($root->{'tsig-keys'}, "key_");
+print &ui_tabs_end_tab("mode", "tsig");
+
+# D2 uses the same Kea logger format as the DHCP daemons.
+print &ui_tabs_start_tab("mode", "logging");
+print &ui_div($text{'logging_desc'});
+print &ui_subheading($text{'logging_loggers'});
+&kea_logger_rows($root->{'loggers'}, "log_");
+print &ui_tabs_end_tab("mode", "logging");
+print &ui_tabs_end();
+
+print &ui_form_end([ [ "save", $text{'save'} ] ]);
+&ui_print_footer("index.cgi?mode=ddns", $text{'index_return'});
diff --git a/kea-dhcp/edit_options.cgi b/kea-dhcp/edit_options.cgi
new file mode 100755
index 000000000..1e735446d
--- /dev/null
+++ b/kea-dhcp/edit_options.cgi
@@ -0,0 +1,245 @@
+#!/usr/local/bin/perl
+# Edit global Kea DHCP options for DHCPv4 or DHCPv6.
+
+use strict;
+use warnings;
+require './kea-dhcp-lib.pl';
+&ReadParse();
+our (%in, %text);
+&error_setup($text{'eacl_aviol'});
+
+my $ver = $in{'version'} == 6 ? 6 : 4;
+&kea_assert_acl('edit'.$ver);
+my ($c, $root, $data, $err) = &kea_read_dhcp_config($ver);
+&error($err) if ($err);
+
+# Render one global settings form for either Dhcp4 or Dhcp6. Each tab writes
+# back to the same JSON root object, so the save handler can update all fields
+# in one pass without losing hidden tab values.
+&ui_print_header(undef, &text('options_title', $ver), "", undef, 1, 1);
+print &kea_comment_loss_warning($c);
+print &ui_alert_box($text{'dhcp6_ra_warn'}, "warn", undef, undef, "")
+ if ($ver == 6);
+print &ui_form_start("save_options.cgi", "post");
+print &ui_hidden("version", $ver);
+
+my @tabs = (
+ [ 'interfaces', $text{'tab_interfaces'} ],
+ [ 'storage', $text{'tab_storage'} ],
+ [ 'logging', $text{'tab_logging'} ],
+ [ 'ddns_sender', $text{'tab_ddns_sender'} ],
+ [ 'timers', $text{'tab_timers'} ],
+ [ 'options', $text{'tab_options'} ],
+ [ 'advanced', $text{'tab_advanced'} ],
+ );
+print &ui_tabs_start(\@tabs, "mode", $in{'mode'} || "interfaces", 1);
+
+# Interfaces decide whether Kea listens at all. The DHCPv4 socket mode is kept
+# beside the interface list because it only affects packet capture on DHCPv4.
+print &ui_tabs_start_tab("mode", "interfaces");
+print &ui_div($text{'interfaces_desc'});
+my $ifconf = ref($root->{'interfaces-config'}) eq 'HASH' ?
+ $root->{'interfaces-config'} : { };
+my $ifaces = ref($ifconf->{'interfaces'}) eq 'ARRAY' ?
+ join(" ", @{$ifconf->{'interfaces'}}) : "";
+print &ui_table_start($text{'interfaces_title'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('interfaces', $text{'interfaces_list'}),
+ &ui_textbox("interfaces", $ifaces, 60));
+if ($ver == 4) {
+ print &ui_table_row(&kea_field_hlink('dhcp-socket-type',
+ $text{'interfaces_socket'}),
+ &ui_select("dhcp-socket-type", $ifconf->{'dhcp-socket-type'} || "",
+ [ [ "", $text{'socket_default'} ],
+ [ "raw", $text{'socket_raw'} ],
+ [ "udp", $text{'socket_udp'} ] ]));
+ }
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "interfaces");
+
+# Storage and control sockets are global daemon settings, not subnet settings.
+print &ui_tabs_start_tab("mode", "storage");
+print &ui_div($text{'storage_desc'});
+my $lease = ref($root->{'lease-database'}) eq 'HASH' ?
+ $root->{'lease-database'} : { };
+print &ui_table_start($text{'lease_database'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('lease-database-type', $text{'lease_type'}),
+ &ui_textbox("lease_type", $lease->{'type'} || "", 20));
+print &ui_table_row(&kea_field_hlink('lfc-interval',
+ $text{'lease_lfc_interval'}),
+ &ui_textbox("lease_lfc_interval", $lease->{'lfc-interval'} || "", 12));
+print &ui_table_row(&kea_field_hlink('lease-database-name',
+ $text{'lease_name'}),
+ &ui_textbox("lease_name", $lease->{'name'} || "", 30));
+print &ui_table_row(&kea_field_hlink('lease-database-host',
+ $text{'lease_host'}),
+ &ui_textbox("lease_host", $lease->{'host'} || "", 30));
+print &ui_table_row(&kea_field_hlink('lease-database-port',
+ $text{'lease_port'}),
+ &ui_textbox("lease_port", $lease->{'port'} || "", 8));
+print &ui_table_row(&kea_field_hlink('lease-database-user',
+ $text{'lease_user'}),
+ &ui_textbox("lease_user", $lease->{'user'} || "", 24));
+my $password_note = $lease->{'password'} ?
+ " ".&ui_tag('small', $text{'secret_keep_blank'}, {
+ 'style' => 'color:var(--text-color-light, #777)' }) : "";
+print &ui_table_row(&kea_field_hlink('lease-database-password',
+ $text{'lease_password'}),
+ &ui_password("lease_password", "", 24).$password_note);
+print &ui_table_end();
+
+my $socket = ref($root->{'control-socket'}) eq 'HASH' ?
+ $root->{'control-socket'} : { };
+print &ui_table_start($text{'control_socket'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('control-socket-type',
+ $text{'control_socket_type'}),
+ &ui_select("control_socket_type", $socket->{'socket-type'} || "",
+ [ [ "", $text{'socket_default'} ],
+ [ "unix", "Unix" ] ]));
+print &ui_table_row(&kea_field_hlink('control-socket-name',
+ $text{'control_socket_name'}),
+ &ui_textbox("control_socket_name", $socket->{'socket-name'} || "", 50));
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "storage");
+
+# Logger settings live at the daemon root, beside lease database and timers.
+print &ui_tabs_start_tab("mode", "logging");
+print &ui_div($text{'logging_desc'});
+print &ui_subheading($text{'logging_loggers'});
+&kea_logger_rows($root->{'loggers'}, "log_");
+print &ui_tabs_end_tab("mode", "logging");
+
+# DHCP-DDNS sender settings control whether this daemon submits name-change
+# requests to the standalone D2 daemon. They are distinct from D2's own
+# listener/zones/keys settings.
+print &ui_tabs_start_tab("mode", "ddns_sender");
+print &ui_div(&text('ddns_sender_settings_desc', $ver));
+my $ddns = ref($root->{'dhcp-ddns'}) eq 'HASH' ?
+ $root->{'dhcp-ddns'} : { };
+my $bool_opts = [
+ [ "", $text{'inherit_default'} ],
+ [ "true", $text{'yes'} ],
+ [ "false", $text{'no'} ],
+ ];
+print &ui_table_start($text{'ddns_sender_connectivity'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('ddns-enable-updates',
+ $text{'ddns_enable_updates'}),
+ &ui_select("ddns_enable_updates",
+ &kea_bool_value($ddns->{'enable-updates'}), $bool_opts));
+print &ui_table_row(&kea_field_hlink('ddns-server-ip',
+ $text{'ddns_server_ip'}),
+ &ui_textbox("ddns_server_ip", $ddns->{'server-ip'} || "", 24));
+print &ui_table_row(&kea_field_hlink('ddns-server-port',
+ $text{'ddns_server_port'}),
+ &ui_textbox("ddns_server_port",
+ defined($ddns->{'server-port'}) ? $ddns->{'server-port'} : "",
+ 8));
+print &ui_table_row(&kea_field_hlink('ddns-sender-ip',
+ $text{'ddns_sender_ip'}),
+ &ui_textbox("ddns_sender_ip", $ddns->{'sender-ip'} || "", 24));
+print &ui_table_row(&kea_field_hlink('ddns-sender-port',
+ $text{'ddns_sender_port'}),
+ &ui_textbox("ddns_sender_port",
+ defined($ddns->{'sender-port'}) ? $ddns->{'sender-port'} : "",
+ 8));
+print &ui_table_row(&kea_field_hlink('ddns-max-queue-size',
+ $text{'ddns_max_queue_size'}),
+ &ui_textbox("ddns_max_queue_size",
+ defined($ddns->{'max-queue-size'}) ?
+ $ddns->{'max-queue-size'} : "", 10));
+print &ui_table_row(&kea_field_hlink('ncr-protocol',
+ $text{'ddns_ncr_protocol'}),
+ &ui_select("ddns_ncr_protocol", $ddns->{'ncr-protocol'} || "",
+ &kea_select_options($ddns->{'ncr-protocol'},
+ $text{'socket_default'}, 'UDP')));
+print &ui_table_row(&kea_field_hlink('ncr-format',
+ $text{'ddns_ncr_format'}),
+ &ui_select("ddns_ncr_format", $ddns->{'ncr-format'} || "",
+ &kea_select_options($ddns->{'ncr-format'},
+ $text{'socket_default'}, 'JSON')));
+print &ui_table_end();
+
+print &ui_table_start($text{'ddns_sender_behavior'}, "width=100%", 4);
+my %ddns_bool_labels = (
+ 'ddns-send-updates' => $text{'ddns_send_updates'},
+ 'ddns-override-no-update' => $text{'ddns_override_no_update'},
+ 'ddns-override-client-update' => $text{'ddns_override_client_update'},
+ 'ddns-update-on-renew' => $text{'ddns_update_on_renew'},
+ );
+foreach my $k ('ddns-send-updates', 'ddns-override-no-update',
+ 'ddns-override-client-update', 'ddns-update-on-renew') {
+ print &ui_table_row(&kea_field_hlink($k, $ddns_bool_labels{$k}),
+ &ui_select($k, &kea_bool_value($root->{$k}), $bool_opts));
+ }
+print &ui_table_row(&kea_field_hlink('ddns-replace-client-name',
+ $text{'ddns_replace_client_name'}),
+ &ui_select("ddns-replace-client-name",
+ $root->{'ddns-replace-client-name'} || "",
+ &kea_select_options($root->{'ddns-replace-client-name'},
+ $text{'socket_default'},
+ 'never', 'when-present',
+ 'when-not-present', 'always')));
+my %ddns_text_labels = (
+ 'ddns-generated-prefix' => $text{'ddns_generated_prefix'},
+ 'ddns-qualifying-suffix' => $text{'ddns_qualifying_suffix'},
+ 'ddns-conflict-resolution-mode' => $text{'ddns_conflict_resolution_mode'},
+ 'hostname-char-set' => $text{'hostname_char_set'},
+ 'hostname-char-replacement' => $text{'hostname_char_replacement'},
+ );
+foreach my $k ('ddns-generated-prefix', 'ddns-qualifying-suffix',
+ 'ddns-conflict-resolution-mode', 'hostname-char-set',
+ 'hostname-char-replacement') {
+ print &ui_table_row(&kea_field_hlink($k, $ddns_text_labels{$k}),
+ &ui_textbox($k, defined($root->{$k}) ? $root->{$k} : "", 32));
+ }
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "ddns_sender");
+
+# Timer defaults apply only when shared networks or subnets do not override.
+print &ui_tabs_start_tab("mode", "timers");
+print &ui_div($text{'timers_desc'});
+print &ui_table_start($text{'options_timers'}, "width=100%", 4);
+foreach my $k ('renew-timer', 'rebind-timer', 'valid-lifetime',
+ 'min-valid-lifetime', 'max-valid-lifetime') {
+ print &ui_table_row(&kea_field_hlink($k),
+ &ui_textbox($k, defined($root->{$k}) ? $root->{$k} : "", 12));
+ }
+print &ui_table_row(&kea_field_hlink('preferred-lifetime'),
+ &ui_textbox("preferred-lifetime",
+ defined($root->{'preferred-lifetime'}) ? $root->{'preferred-lifetime'} : "", 12))
+ if ($ver == 6);
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "timers");
+
+# Common options get named fields; everything else remains editable in the
+# additional option-data table below them.
+print &ui_tabs_start_tab("mode", "options");
+print &ui_div($text{'options_desc'});
+&kea_common_option_rows($root->{'option-data'}, $ver, "common_");
+&kea_option_data_section($root->{'option-data'}, "opt_", $ver, 1);
+print &ui_tabs_end_tab("mode", "options");
+
+# Advanced fields are valid Kea globals but are easy to misuse, so keep them
+# away from the everyday options page.
+print &ui_tabs_start_tab("mode", "advanced");
+print &ui_div(&text('global_advanced_desc', $ver));
+print &ui_table_start($text{'global_advanced'}, "width=100%", 4);
+if ($ver == 4) {
+ print &ui_table_row(&kea_field_hlink('authoritative'),
+ &ui_select("authoritative", &kea_bool_value($root->{'authoritative'}),
+ [ [ "", $text{'inherit_default'} ],
+ [ "true", $text{'yes'} ],
+ [ "false", $text{'no'} ] ]));
+ }
+&kea_advanced_option_rows($root->{'option-data'}, $ver, "adv_");
+if ($ver == 4) {
+ foreach my $k ('next-server', 'server-hostname', 'boot-file-name') {
+ print &ui_table_row(&kea_field_hlink($k),
+ &ui_textbox($k, defined($root->{$k}) ? $root->{$k} : "", 40));
+ }
+ }
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "advanced");
+print &ui_tabs_end();
+
+print &ui_form_end([ [ "save", $text{'save'} ] ]);
+&ui_print_footer("index.cgi?mode=dhcp$ver", $text{'index_return'});
diff --git a/kea-dhcp/edit_shared.cgi b/kea-dhcp/edit_shared.cgi
new file mode 100755
index 000000000..cb505c9ae
--- /dev/null
+++ b/kea-dhcp/edit_shared.cgi
@@ -0,0 +1,128 @@
+#!/usr/local/bin/perl
+# Edit or create a Kea shared network.
+
+use strict;
+use warnings;
+require './kea-dhcp-lib.pl';
+&ReadParse();
+our (%in, %text);
+&error_setup($text{'eacl_aviol'});
+
+my $ver = $in{'version'} == 6 ? 6 : 4;
+&kea_assert_acl('edit'.$ver);
+my ($c, $root, $data, $err) = &kea_read_dhcp_config($ver);
+&error($err) if ($err);
+my $shareds = &kea_shared_networks($root);
+&error($text{'shared_enone'})
+ if (!$in{'new'} && (!defined($in{'idx'}) || $in{'idx'} !~ /^\d+$/));
+my $shared = $in{'new'} ? { } : $shareds->[$in{'idx'}];
+&error($text{'shared_enone'}) if (!$shared);
+
+# Shared networks are containers for same-link subnets. New shared networks do
+# not show the Subnets tab until they have a stable index to attach subnets to.
+my $title = $in{'new'} ? $text{'shared_create'} : $text{'shared_edit'};
+&ui_print_header(undef, $title, "", undef, 1, 1);
+print &kea_comment_loss_warning($c);
+print &ui_form_start("save_shared.cgi", "post");
+print &ui_hidden("version", $ver);
+print &ui_hidden("new", 1) if ($in{'new'});
+print &ui_hidden("idx", $in{'idx'}) if (!$in{'new'});
+
+my @tabs = (
+ [ 'general', $text{'tab_general'} ],
+ [ 'options', $text{'tab_options'} ],
+ [ 'advanced', $text{'tab_advanced'} ],
+ );
+splice(@tabs, 1, 0, [ 'subnets', $text{'tab_subnets'} ])
+ if (!$in{'new'});
+my $mode = $in{'mode'} || "general";
+$mode = "general" if ($in{'new'} && $mode eq "subnets");
+print &ui_tabs_start(\@tabs, "mode", $mode, 1);
+
+# General data identifies the shared network and optionally scopes it to an
+# interface or relay address used by Kea during subnet selection.
+print &ui_tabs_start_tab("mode", "general");
+print &ui_div($text{'shared_general_desc'});
+print &ui_table_start($text{'shared_general'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('shared-network-name',
+ $text{'shared_name'}),
+ &ui_textbox("name", $shared->{'name'} || "", 40));
+print &ui_table_row(&kea_field_hlink('description', $text{'shared_desc'}),
+ &ui_textbox("desc", &kea_get_comment($shared) || "", 60));
+print &ui_table_row(&kea_field_hlink('interface'),
+ &ui_textbox("interface", $shared->{'interface'} || "", 30));
+print &ui_table_row(&kea_field_hlink('relay_ip_addresses'),
+ &ui_textbox("relay_ip_addresses",
+ join(" ", &kea_relay_addresses($shared)), 50));
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "general");
+
+if (!$in{'new'}) {
+ # Existing shared networks can show their member subnets and provide a
+ # shortcut for creating a subnet directly under this parent.
+ print &ui_tabs_start_tab("mode", "subnets");
+ print &ui_div($text{'shared_subnets_desc'});
+ my $subs = &kea_subnet_list($root, $ver, $in{'idx'});
+ print &ui_columns_start([
+ $text{'col_id'}, $text{'col_subnet'}, $text{'col_pools'},
+ $text{'col_reservations'}, $text{'col_options'} ], 100);
+for(my $i=0; $i<@$subs; $i++) {
+ my $s = $subs->[$i];
+ print &ui_columns_row([
+ $s->{'id'} || "",
+ &ui_link("edit_subnet.cgi?version=$ver&sidx=$in{'idx'}&idx=$i",
+ &html_escape($s->{'subnet'} || "")),
+ &kea_count_array($s, 'pools'),
+ &kea_count_array($s, 'reservations'),
+ &kea_count_array($s, 'option-data'),
+ ]);
+ }
+print &ui_columns_row([ &ui_tag('i', &html_escape($text{'index_empty'})) ],
+ [ "colspan=5" ])
+ if (!@$subs);
+print &ui_columns_end();
+print &ui_link_button("edit_subnet.cgi?version=$ver&sidx=$in{'idx'}&new=1",
+ $text{'index_add_subnet'});
+print &ui_tabs_end_tab("mode", "subnets");
+}
+
+# Shared-network options are inherited by subnets unless a more specific scope
+# overrides them.
+print &ui_tabs_start_tab("mode", "options");
+print &ui_div($text{'shared_options_desc'});
+&kea_common_option_rows($shared->{'option-data'}, $ver, "common_");
+&kea_option_data_section($shared->{'option-data'}, "opt_", $ver);
+print &ui_tabs_end_tab("mode", "options");
+
+# Advanced shared-network settings mirror Kea fields that affect all member
+# subnets, including timers and protocol-specific behavior flags.
+print &ui_tabs_start_tab("mode", "advanced");
+print &ui_div($text{'shared_advanced_desc'});
+print &ui_table_start($text{'shared_advanced'}, "width=100%", 4);
+if ($ver == 4) {
+ print &ui_table_row(&kea_field_hlink('authoritative'),
+ &ui_select("authoritative", &kea_bool_value($shared->{'authoritative'}),
+ [ [ "", $text{'inherit_default'} ],
+ [ "true", $text{'yes'} ],
+ [ "false", $text{'no'} ] ]));
+ }
+foreach my $k ('renew-timer', 'rebind-timer', 'valid-lifetime',
+ 'min-valid-lifetime', 'max-valid-lifetime') {
+ print &ui_table_row(&kea_field_hlink($k),
+ &ui_textbox($k, defined($shared->{$k}) ? $shared->{$k} : "", 12));
+ }
+print &ui_table_row(&kea_field_hlink('preferred-lifetime'),
+ &ui_textbox("preferred-lifetime",
+ defined($shared->{'preferred-lifetime'}) ? $shared->{'preferred-lifetime'} : "", 12))
+ if ($ver == 6);
+&kea_advanced_option_rows($shared->{'option-data'}, $ver, "adv_");
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "advanced");
+
+print &ui_tabs_end();
+
+my @buttons = $in{'new'} ? ([ "save", $text{'create'} ]) :
+ ([ "save", $text{'save'} ],
+ [ "delete", $text{'delete'} ]);
+print &ui_form_end(\@buttons);
+&ui_print_footer("", $text{'index_return'});
diff --git a/kea-dhcp/edit_subnet.cgi b/kea-dhcp/edit_subnet.cgi
new file mode 100755
index 000000000..24f012af1
--- /dev/null
+++ b/kea-dhcp/edit_subnet.cgi
@@ -0,0 +1,257 @@
+#!/usr/local/bin/perl
+# Edit or create a Kea subnet.
+
+use strict;
+use warnings;
+require './kea-dhcp-lib.pl';
+&ReadParse();
+our (%in, %text);
+&error_setup($text{'eacl_aviol'});
+
+my $ver = $in{'version'} == 6 ? 6 : 4;
+&kea_assert_acl('edit'.$ver);
+my ($c, $root, $data, $err) = &kea_read_dhcp_config($ver);
+&error($err) if ($err);
+my $sidx = defined($in{'sidx'}) ? $in{'sidx'} : "";
+&error($text{'subnet_enone'}) if (!&kea_valid_subnet_parent($root, $sidx));
+&error($text{'subnet_enone'})
+ if (!$in{'new'} && (!defined($in{'idx'}) || $in{'idx'} !~ /^\d+$/));
+my $sub = $in{'new'} ? { 'id' => &kea_next_subnet_id($root, $ver) }
+ : &kea_get_subnet($root, $ver, $sidx, $in{'idx'});
+&error($text{'subnet_enone'}) if (!$sub);
+
+# Main request flow: render the tabbed subnet editor, then delegate repeated
+# row-heavy controls to helpers below.
+my $title = $in{'new'} ? $text{'subnet_create'} : $text{'subnet_edit'};
+&ui_print_header(undef, $title, "", undef, 1, 1);
+print &kea_comment_loss_warning($c);
+print &ui_form_start("save_subnet.cgi", "post");
+print &ui_hidden("version", $ver);
+print &ui_hidden("new", 1) if ($in{'new'});
+print &ui_hidden("idx", $in{'idx'}) if (!$in{'new'});
+print &ui_hidden("sidx", $sidx) if ($sidx ne '');
+
+my @tabs = (
+ [ 'general', $text{'tab_general'} ],
+ [ 'pools', $text{'tab_pools'} ],
+ [ 'reservations', $text{'tab_reservations'} ],
+ [ 'options', $text{'tab_options'} ],
+ [ 'advanced', $text{'tab_advanced'} ],
+ );
+print &ui_tabs_start(\@tabs, "mode", $in{'mode'} || "general", 1);
+
+# General owns the required subnet identity plus the parent shared-network
+# pointer, which determines where the subnet is stored in Kea JSON.
+print &ui_tabs_start_tab("mode", "general");
+print &ui_div($text{'subnet_general_desc'});
+print &ui_table_start($text{'subnet_general'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('subnet-id', $text{'subnet_id'}),
+ &ui_textbox("id", $sub->{'id'} || "", 8));
+print &ui_table_row(&kea_field_hlink('subnet-prefix',
+ $text{'subnet_prefix'}),
+ &ui_textbox("subnet", $sub->{'subnet'} || "", 40));
+print &ui_table_row(&kea_field_hlink('calculated-subnet-mask',
+ $text{'subnet_mask_auto'}),
+ &ui_tag('tt', &html_escape(&kea_ipv4_mask_from_subnet($sub->{'subnet'} || "")
+ || $text{'index_empty'})))
+ if ($ver == 4);
+print &ui_table_row(&kea_field_hlink('description', $text{'subnet_desc'}),
+ &ui_textbox("desc", &kea_get_comment($sub) || "", 60));
+
+# A subnet may be top-level or nested under a shared network. Kea stores those
+# in different arrays, so the selected parent is carried through saves.
+my @shared_opts = ([ "", "<$text{'shared_none'}>" ]);
+my $shareds = &kea_shared_networks($root);
+for(my $i=0; $i<@$shareds; $i++) {
+ push(@shared_opts, [ $i, &kea_scope_name($shareds->[$i], &text('index_shared_num', $i+1)) ]);
+ }
+print &ui_table_row(&kea_field_hlink('shared-network',
+ $text{'subnet_shared'}),
+ &ui_select("parent", $sidx ne '' ? $sidx : "", \@shared_opts));
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "general");
+
+# Pools are row editors: DHCPv4/DHCPv6 address pools are common, while prefix
+# delegation is rendered only for DHCPv6.
+print &ui_tabs_start_tab("mode", "pools");
+print &ui_div($text{'subnet_pools_desc'});
+&pool_rows($sub->{'pools'});
+if ($ver == 6) {
+ print &ui_subheading($text{'pd_pools'});
+ &pd_pool_rows($sub->{'pd-pools'});
+ }
+print &ui_tabs_end_tab("mode", "pools");
+
+# Reservations stay compact here, but the parser preserves advanced fields that
+# the UI does not expose.
+print &ui_tabs_start_tab("mode", "reservations");
+print &ui_div($text{'subnet_reservations_desc'});
+&reservation_rows($sub->{'reservations'}, $ver);
+print &ui_tabs_end_tab("mode", "reservations");
+
+# Option editing is split between named common fields and generic option-data
+# rows so uncommon options can still round-trip.
+print &ui_tabs_start_tab("mode", "options");
+print &ui_div($text{'subnet_options_desc'});
+&kea_common_option_rows($sub->{'option-data'}, $ver, "common_");
+&kea_option_data_section($sub->{'option-data'}, "opt_", $ver);
+print &ui_tabs_end_tab("mode", "options");
+
+# Advanced values affect subnet selection, relay matching, lease timing, and
+# DHCPv4 boot fields. They are top-level subnet keys, not normal options.
+print &ui_tabs_start_tab("mode", "advanced");
+print &ui_div($text{'subnet_advanced_desc'});
+print &ui_table_start($text{'subnet_advanced'}, "width=100%", 4);
+print &ui_table_row(&kea_field_hlink('interface'),
+ &ui_textbox("interface", &text_value($sub->{'interface'}), 30));
+print &ui_table_row(&kea_field_hlink('relay_ip_addresses'),
+ &ui_textbox("relay_ip_addresses",
+ join(" ", &kea_relay_addresses($sub)), 50));
+if ($ver == 4) {
+ print &ui_table_row(&kea_field_hlink('authoritative'),
+ &ui_select("authoritative", &kea_bool_value($sub->{'authoritative'}),
+ [ [ "", $text{'inherit_default'} ],
+ [ "true", $text{'yes'} ],
+ [ "false", $text{'no'} ] ]));
+ }
+foreach my $k ('renew-timer', 'rebind-timer', 'valid-lifetime',
+ 'min-valid-lifetime', 'max-valid-lifetime') {
+ print &ui_table_row(&kea_field_hlink($k),
+ &ui_textbox($k, &text_value($sub->{$k}), 12));
+ }
+print &ui_table_row(&kea_field_hlink('preferred-lifetime'),
+ &ui_textbox("preferred-lifetime", &text_value($sub->{'preferred-lifetime'}), 12))
+ if ($ver == 6);
+foreach my $k ('next-server', 'server-hostname', 'boot-file-name') {
+ print &ui_table_row(&kea_field_hlink($k),
+ &ui_textbox($k, &text_value($sub->{$k}), 40))
+ if ($ver == 4);
+ }
+&kea_advanced_option_rows($sub->{'option-data'}, $ver, "adv_");
+print &ui_table_end();
+print &ui_tabs_end_tab("mode", "advanced");
+
+print &ui_tabs_end();
+
+my @buttons = $in{'new'} ? ([ "save", $text{'create'} ]) :
+ ([ "save", $text{'save'} ],
+ [ "delete", $text{'delete'} ]);
+print &ui_form_end(\@buttons);
+&ui_print_footer("", $text{'index_return'});
+
+# text_value(value)
+# Returns a defined scalar for textboxes without hiding valid zero values.
+sub text_value
+{
+my ($v) = @_;
+return defined($v) ? $v : "";
+}
+
+# pool_rows(&pools)
+# Renders address pools with one extra empty row for adding a pool.
+sub pool_rows
+{
+my ($pools) = @_;
+$pools = [ ] if (ref($pools) ne 'ARRAY');
+print &ui_table_start($text{'tab_pools'}, "width=100%", 2);
+
+# Always include one empty row so adding a pool does not need a separate
+# client-side table mutation.
+for(my $i=0; $i<=$#$pools+1; $i++) {
+ my $p = $pools->[$i] || { };
+ print &ui_table_row(&kea_field_hlink('address-pool',
+ $text{'pool_range'}),
+ &ui_textbox("pool_pool_$i", $p->{'pool'} || "", 60));
+ }
+print &ui_table_end();
+}
+
+# pd_pool_rows(&pd-pools)
+# Renders DHCPv6 prefix delegation pools with room for one new entry.
+sub pd_pool_rows
+{
+my ($pools) = @_;
+$pools = [ ] if (ref($pools) ne 'ARRAY');
+
+# Prefix delegation rows are wider than standard form rows, so keep them in the
+# same table wrapper used by generic option-data editors.
+print &ui_tag_start('div', { 'class' => 'option-data-table' });
+print &ui_columns_start([
+ &kea_field_hlink('pd-prefix', $text{'pd_prefix'}),
+ &kea_field_hlink('pd-prefix-len', $text{'pd_prefix_len'}),
+ &kea_field_hlink('pd-delegated-len', $text{'pd_delegated_len'}),
+ &kea_field_hlink('pd-excluded-prefix', $text{'pd_excluded_prefix'}),
+ &kea_field_hlink('pd-excluded-prefix-len',
+ $text{'pd_excluded_prefix_len'}) ], 100);
+for(my $i=0; $i<=$#$pools+1; $i++) {
+ my $p = $pools->[$i] || { };
+ print &ui_columns_row([
+ &ui_textbox("pd_prefix_$i", $p->{'prefix'} || "", 26),
+ &ui_textbox("pd_prefix_len_$i", $p->{'prefix-len'} || "", 5),
+ &ui_textbox("pd_delegated_len_$i", $p->{'delegated-len'} || "", 5),
+ &ui_textbox("pd_excluded_prefix_$i", $p->{'excluded-prefix'} || "", 26),
+ &ui_textbox("pd_excluded_prefix_len_$i", $p->{'excluded-prefix-len'} || "", 5),
+ ]);
+ }
+print &ui_columns_end();
+print &ui_tag_end('div');
+}
+
+# reservation_rows(&reservations, version)
+# Renders host reservations without trying to flatten every advanced Kea field.
+sub reservation_rows
+{
+my ($reservations, $ver) = @_;
+$reservations = [ ] if (ref($reservations) ne 'ARRAY');
+
+# Kea accepts different reservation identifiers per protocol; the dropdown is
+# limited to identifiers that the selected daemon can actually use.
+my @types = $ver == 6 ?
+ ([ 'duid', 'DUID' ], [ 'hw-address', $text{'res_hw'} ],
+ [ 'flex-id', 'Flex ID' ]) :
+ ([ 'hw-address', $text{'res_hw'} ], [ 'client-id', $text{'res_client'} ],
+ [ 'duid', 'DUID' ], [ 'circuit-id', $text{'res_circuit'} ],
+ [ 'flex-id', 'Flex ID' ]);
+my @heads = ( &kea_field_hlink('reservation-identifier-type',
+ $text{'res_type'}),
+ &kea_field_hlink('reservation-identifier',
+ $text{'res_identifier'}),
+ $ver == 6 ?
+ &kea_field_hlink('reservation-addresses',
+ $text{'res_addresses'}) :
+ &kea_field_hlink('reservation-address',
+ $text{'res_address'}),
+ &kea_field_hlink('reservation-hostname',
+ $text{'res_hostname'}) );
+push(@heads, &kea_field_hlink('reservation-prefixes',
+ $text{'res_prefixes'})) if ($ver == 6);
+print &ui_tag_start('div', { 'class' => 'option-data-table' });
+print &ui_columns_start(\@heads, 100);
+
+# Pick the first identifier field already present, otherwise default to the
+# common identifier for the protocol.
+for(my $i=0; $i<=$#$reservations+1; $i++) {
+ my $r = $reservations->[$i] || { };
+ my $rtype = "";
+ foreach my $k (map { $_->[0] } @types) {
+ if (defined($r->{$k})) {
+ $rtype = $k;
+ last;
+ }
+ }
+ $rtype ||= $types[0]->[0];
+ my $addr = $ver == 6 ? join(" ", @{$r->{'ip-addresses'} || [ ]}) :
+ $r->{'ip-address'};
+ my @cols = (
+ &ui_select("res_type_$i", $rtype, \@types),
+ &ui_textbox("res_identifier_$i", $r->{$rtype} || "", 28),
+ &ui_textbox("res_address_$i", $addr || "", 32),
+ &ui_textbox("res_hostname_$i", $r->{'hostname'} || "", 22),
+ );
+ push(@cols, &ui_textbox("res_prefixes_$i",
+ join(" ", @{$r->{'prefixes'} || [ ]}), 30)) if ($ver == 6);
+ print &ui_columns_row(\@cols);
+ }
+print &ui_columns_end();
+print &ui_tag_end('div');
+}
diff --git a/kea-dhcp/edit_text.cgi b/kea-dhcp/edit_text.cgi
new file mode 100755
index 000000000..094c33aac
--- /dev/null
+++ b/kea-dhcp/edit_text.cgi
@@ -0,0 +1,42 @@
+#!/usr/local/bin/perl
+# Edit Kea files as raw text.
+
+use strict;
+use warnings;
+require './kea-dhcp-lib.pl';
+&ReadParse();
+our (%in, %text);
+&error_setup($text{'eacl_aviol'});
+&kea_assert_acl('manual');
+
+my @files = &kea_manual_edit_files();
+&error($text{'edit_enofile'}) if (!@files);
+my $info = &kea_manual_edit_file($in{'file'}) || $files[0];
+my $file = $info->{'file'};
+&error($text{'save_efile'}) if (!$file);
+
+# The manual editor is intentionally constrained to known Kea config files and
+# Control Agent password files discovered by the library.
+my $data = "";
+if (-r $file) {
+ &lock_file($file);
+ $data = &read_file_contents($file);
+ &unlock_file($file);
+ }
+
+&ui_print_header(undef, $text{'index_edit_manual'}, "", undef, 1, 1);
+
+# Keep file selection and file contents as separate forms, matching nftables.
+print &ui_form_start("edit_text.cgi");
+print &ui_tag('b', &html_escape($text{'edit_select'})),"\n";
+print &ui_select("file", $file, [ map { $_->{'file'} } @files ]),"\n";
+print &ui_submit($text{'edit_ok'});
+print &ui_form_end();
+
+print &ui_form_start("save_text.cgi", "form-data");
+print &ui_hidden("file", $file);
+print &ui_table_start(undef, undef, 2);
+print &ui_table_row(undef, &ui_textarea("data", $data, 30, 120), 2);
+print &ui_table_end();
+print &ui_form_end([ [ "save", $text{'save'} ] ]);
+&ui_print_footer("index.cgi", $text{'index_return'});
diff --git a/kea-dhcp/help/field_address_pool.html b/kea-dhcp/help/field_address_pool.html
new file mode 100644
index 000000000..318d4c8bf
--- /dev/null
+++ b/kea-dhcp/help/field_address_pool.html
@@ -0,0 +1,9 @@
+
+
+Dynamic lease range inside the subnet. DHCPv4 pools are usually ranges such as 192.0.2.10 - 192.0.2.200; DHCPv6 pools are often prefixes such as 2001:db8:1::/80.
+
+
+If a subnet has no pools, ordinary clients will not receive dynamic leases unless matching reservations exist.
+
+
+