diff --git a/plist b/plist index 97fecfcad7..b9bb538684 100644 --- a/plist +++ b/plist @@ -410,6 +410,7 @@ /usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/agentSettings.xml /usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/ddnsSettings.xml +/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogOption4.xml /usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPDPool6.xml /usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPeer4.xml /usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPeer6.xml @@ -838,6 +839,7 @@ /usr/local/opnsense/mvc/app/models/OPNsense/Interfaces/VxLan.xml /usr/local/opnsense/mvc/app/models/OPNsense/Kea/ACL/ACL.xml /usr/local/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaClasslessStaticRouteField.php +/usr/local/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaOptionDataField.php /usr/local/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaPoolsField.php /usr/local/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaStaticRoutesField.php /usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaCtrlAgent.php @@ -1294,6 +1296,7 @@ /usr/local/opnsense/scripts/ipsec/spddelete.py /usr/local/opnsense/scripts/ipsec/updown_event.py /usr/local/opnsense/scripts/kea/get_kea_leases.py +/usr/local/opnsense/scripts/kea/kea_dhcp_options.py /usr/local/opnsense/scripts/kea/kea_prefix_watcher.py /usr/local/opnsense/scripts/monit/carp_status.php /usr/local/opnsense/scripts/monit/gateway_alert.php diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Dhcpv4Controller.php b/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Dhcpv4Controller.php index 21d0b160ff..1eadb1acc0 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Dhcpv4Controller.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Dhcpv4Controller.php @@ -137,6 +137,31 @@ class Dhcpv4Controller extends ApiMutableModelControllerBase } } + public function searchOptionAction() + { + return $this->searchBase("options.option", null, "option"); + } + + public function setOptionAction($uuid) + { + return $this->setBase("option", "options.option", $uuid); + } + + public function addOptionAction() + { + return $this->addBase("option", "options.option"); + } + + public function getOptionAction($uuid = null) + { + return $this->getBase("option", "options.option", $uuid); + } + + public function delOptionAction($uuid) + { + return $this->delBase("options.option", $uuid); + } + public function searchPeerAction() { return $this->searchBase("ha_peers.peer", null, "name"); diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php b/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php index 1debab8d16..48ec21205b 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php @@ -63,6 +63,9 @@ class DhcpController extends \OPNsense\Base\IndexController $this->view->formDialogReservation = $this->getForm("dialogReservation4"); $this->view->formGridReservation = $this->getFormGrid("dialogReservation4", 'reservation', null, 'reservation'); + $this->view->formDialogOption = $this->getForm("dialogOption4"); + $this->view->formGridOption = $this->getFormGrid("dialogOption4", 'option', null, 'option'); + $this->view->formDialogPeer = $this->getForm("dialogPeer4"); $this->view->formGridPeer = $this->getFormGrid("dialogPeer4", 'peer'); } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogOption4.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogOption4.xml new file mode 100644 index 0000000000..809744f3ca --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogOption4.xml @@ -0,0 +1,37 @@ +
+ + option.code + + dropdown + DHCPv4 option to offer to the client. + + + option.encoding + + dropdown + Choose between auto-converting string data to binary, or providing hexadecimal data yourself. For encapsulated and other complex options, constructing your own binary payload can be a requirement. + + + option.data + + text + Payload to send to a client. + + + option.force + + checkbox + Always send the option, also when the client does not ask for it in the parameter request list. + + boolean + boolean + false + + + + option.description + + text + You may enter a description here for your reference. + +
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogReservation4.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogReservation4.xml index b325e798a8..6cc21be13c 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogReservation4.xml +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogReservation4.xml @@ -143,4 +143,13 @@ false + + reservation.option + + select_multiple + Select custom DHCPv4 options that were created in the options tab. + + false + + diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml index 2bceae6b4c..cf8aaec2a5 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml @@ -173,6 +173,15 @@ true + + subnet4.option + + select_multiple + Select custom DHCPv4 options that were created in the options tab. + + false + + header diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaOptionDataField.php b/src/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaOptionDataField.php new file mode 100644 index 0000000000..1c04bc89ca --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaOptionDataField.php @@ -0,0 +1,67 @@ +internalValue)) { + $validators[] = new CallbackValidator([ + "callback" => function ($data) { + + $messages = []; + $encoding = $this->getParentNode()->encoding->getValue(); + + if ($encoding === "hex") { + if (!preg_match('/^([0-9A-F]{2})+$/', $data)) { + $messages[] = gettext("Hex value must contain uppercase hexadecimal byte pairs."); + } + } + + if ($encoding === "string") { + if (preg_match('/[\'"]/', $data)) { + $messages[] = gettext("String value must not contain quotes."); + } + } + + return $messages; + } + ]); + } + return $validators; + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php index 56f4a74f56..593967f14e 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php +++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php @@ -196,12 +196,53 @@ class KeaDhcpv4 extends BaseModel // Add DHCP option-data elements for reservations $optdata = $this->collectOptionData($reservation->option_data); + // Append raw options + foreach ($reservation->option->getValues() as $uuid) { + $option = $this->getNodeByReference("options.option.$uuid"); + if ($option === null) { + continue; + } + // Kea autoconverts strings to binary when providing 'data' => "'data to convert'" + $data = $option->data->getValue(); + if ($option->encoding->isEqual('string')) { + $data = "'{$data}'"; + } + + $optdata[] = [ + 'code' => $option->code->asInt(), + 'csv-format' => false, + 'data' => $data, + 'always-send' => !$option->force->isEmpty(), + ]; + } if (!empty($optdata)) { $res['option-data'] = $optdata; } $record['reservations'][] = $res; } + /* append raw options */ + foreach ($subnet->option->getValues() as $uuid) { + $option = $this->getNodeByReference("options.option.$uuid"); + if ($option === null) { + continue; + } + + // Kea autoconverts strings to binary when providing 'data' => "'data to convert'" + $data = $option->data->getValue(); + if ($option->encoding->isEqual('string')) { + $data = "'{$data}'"; + } + + $entry = [ + 'code' => $option->code->asInt(), + 'csv-format' => false, + 'data' => $data, + 'always-send' => !$option->force->isEmpty(), + ]; + + $record['option-data'][] = $entry; + } $result[] = $record; } return $result; diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml index fe7ae18d52..403c539781 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml @@ -129,6 +129,16 @@ /^([^\n"])*$/u + 1 Y @@ -286,8 +296,46 @@ /^([^\n"])*$/u + + + + diff --git a/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv4.volt b/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv4.volt index 9b8ce19488..3f518ea5da 100644 --- a/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv4.volt +++ b/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv4.volt @@ -52,6 +52,9 @@ case '#reservations': grid_ids = ["{{formGridReservation['table_id']}}"]; break; + case '#options': + grid_ids = ["{{formGridOption['table_id']}}"]; + break; case '#ha-peers': grid_ids = ["{{formGridPeer['table_id']}}"]; break; @@ -203,6 +206,7 @@
  • {{ lang._('Settings') }}
  • {{ lang._('Subnets') }}
  • {{ lang._('Reservations') }}
  • +
  • {{ lang._('Options') }}
  • {{ lang._('HA Peers') }}
  • @@ -220,6 +224,10 @@ partial('layout_partials/base_bootgrid_table', formGridReservation) }}
    + +
    + {{ partial('layout_partials/base_bootgrid_table', formGridOption)}} +
    {{ partial('layout_partials/base_bootgrid_table', formGridPeer)}} @@ -229,4 +237,5 @@ {{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/kea/service/reconfigure', 'data_service_widget': 'kea'}) }} {{ partial("layout_partials/base_dialog",['fields':formDialogSubnet,'id':formGridSubnet['edit_dialog_id'],'label':lang._('Edit Subnet')])}} {{ partial("layout_partials/base_dialog",['fields':formDialogReservation,'id':formGridReservation['edit_dialog_id'],'label':lang._('Edit Reservation')])}} +{{ partial("layout_partials/base_dialog",['fields':formDialogOption,'id':formGridOption['edit_dialog_id'],'label':lang._('Edit Option')])}} {{ partial("layout_partials/base_dialog",['fields':formDialogPeer,'id':formGridPeer['edit_dialog_id'],'label':lang._('Edit Peer')])}} diff --git a/src/opnsense/scripts/kea/kea_dhcp_options.py b/src/opnsense/scripts/kea/kea_dhcp_options.py new file mode 100755 index 0000000000..c7ad0533d8 --- /dev/null +++ b/src/opnsense/scripts/kea/kea_dhcp_options.py @@ -0,0 +1,73 @@ +#!/usr/local/bin/python3 + +""" + Copyright (c) 2026 Deciso B.V. + Copyright (c) 2025 Ad Schellevis + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +""" + +import csv +import json +import argparse + +option_src = { + 'dhcp': 'iana/dhcpv4-options.csv', # https://www.iana.org/assignments/bootp-dhcp-parameters/ + 'dhcp6': 'iana/dhcpv6-parameters-2.csv' # https://www.iana.org/assignments/dhcpv6-parameters/ +} + +parser = argparse.ArgumentParser() +parser.add_argument("mode", nargs="?", default="dhcp", choices=["dhcp", "dhcp6"]) +args = parser.parse_args() + +assigned = {} +unassigned = {} + +# load IANA data +with open('/usr/local/opnsense/contrib/' + option_src[args.mode], 'r') as csvfile: + for r in csv.reader(csvfile, delimiter=',', quotechar='"'): + if not r or not r[0]: + continue + r_range = [int(x) for x in r[0].split('-') if x.isdigit()] + if not r_range or len(r) < 2: + continue + + name = r[1].strip().lower() + + for code in range(r_range[0], (r_range[1] if len(r_range) > 1 else r_range[0]) + 1): + key = str(code) + + if name in ['unassigned', 'removed/unassigned']: + # Only track unassigned for DHCPv4 (256 total), not DHCPv6 (65535 total) + if args.mode == 'dhcp': + unassigned[key] = f"{name} [{code}]" + + elif name not in ['pad', 'end']: + cleaned_name = name.replace('\n', ' ') + assigned[key] = f"{cleaned_name} [{code}]" + +print(json.dumps({ + "Assigned": assigned, + "Unassigned": unassigned +})) diff --git a/src/opnsense/service/conf/actions.d/actions_kea.conf b/src/opnsense/service/conf/actions.d/actions_kea.conf index 4a04780011..167109e2c6 100644 --- a/src/opnsense/service/conf/actions.d/actions_kea.conf +++ b/src/opnsense/service/conf/actions.d/actions_kea.conf @@ -23,6 +23,12 @@ parameters: type:script_output message:get kea daemon status +[list.dhcp_options] +command:/usr/local/opnsense/scripts/kea/kea_dhcp_options.py dhcp +type:script_output +message:request dhcp options +cache_ttl:86400 + [list.leases4] command:/usr/local/opnsense/scripts/kea/get_kea_leases.py parameters:--proto inet