From be9918b01042642980513d0c9e2ca51452c27ce4 Mon Sep 17 00:00:00 2001 From: TechAsen Date: Sat, 6 Jun 2026 03:18:31 +0300 Subject: [PATCH 01/20] feat: Add System HA Sync endpoint (#901) Co-authored-by: asenchooo --- .../Endpoints/SystemHASyncEndpoint.inc | 25 +++ .../usr/local/pkg/RESTAPI/Models/HASync.inc | 168 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc new file mode 100644 index 00000000..83f47bf8 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc @@ -0,0 +1,25 @@ +url = '/api/v2/system/hasync'; + $this->model_name = 'HASync'; + $this->request_method_options = ['GET', 'PATCH']; + $this->tag = 'System'; + + $this->get_help_text = 'Reads pfSense High Availability synchronization settings.'; + $this->patch_help_text = 'Updates pfSense High Availability synchronization settings and applies the configuration.'; + + parent::__construct(); + } +} \ No newline at end of file diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc new file mode 100644 index 00000000..9fe1345d --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc @@ -0,0 +1,168 @@ +config_path = 'hasync'; + $this->many = false; + $this->always_apply = true; + $this->verbose_name = 'HA Sync Settings'; + $this->verbose_name_plural = 'HA Sync Settings'; + + # State Synchronization Settings (pfsync) + $this->pfsyncenabled = $this->sync_flag('Enable pfsync state synchronization.'); + + $this->pfsyncinterface = new InterfaceField( + default: '', + allow_empty: true, + help_text: 'The interface used by pfsync state synchronization.', + ); + + $this->pfhostid = new StringField( + default: '', + allow_empty: true, + maximum_length: 8, + help_text: 'Custom pf host identifier carried in state data.', + ); + + $this->pfsyncpeerip = new StringField( + default: '', + allow_empty: true, + validators: [ + new IPAddressValidator( + allow_ipv4: true, + allow_ipv6: false, + ), + ], + help_text: 'The peer IP address used by pfsync.', + ); + + # Configuration Synchronization Settings (XMLRPC Sync) + $this->synchronizetoip = new StringField( + default: '', + allow_empty: true, + validators: [ + new IPAddressValidator( + allow_ipv4: true, + allow_ipv6: false, + ), + ], + help_text: 'The remote pfSense host IP address used for XMLRPC configuration synchronization.', + ); + + $this->username = new StringField( + default: '', + allow_empty: true, + help_text: 'The remote pfSense username used for XMLRPC synchronization.', + ); + + # pfSense stores the XMLRPC password in config.xml as . + # The API exposes it as "password" but writes it internally as "passwordfld". + $this->password = new StringField( + default: '', + allow_empty: true, + write_only: true, + sensitive: true, + internal_name: 'passwordfld', + help_text: 'The remote pfSense password used for XMLRPC synchronization.', + ); + + $this->adminsync = $this->sync_flag( + 'Synchronize admin accounts and automatically update the XMLRPC sync password.', + ); + + # Select options to sync + $this->synchronizeusers = $this->sync_flag('Synchronize users and groups.'); + $this->synchronizeauthservers = $this->sync_flag('Synchronize authentication servers.'); + $this->synchronizecerts = $this->sync_flag('Synchronize certificates.'); + $this->synchronizerules = $this->sync_flag('Synchronize firewall rules.'); + $this->synchronizeschedules = $this->sync_flag('Synchronize firewall schedules.'); + $this->synchronizealiases = $this->sync_flag('Synchronize firewall aliases.'); + $this->synchronizenat = $this->sync_flag('Synchronize NAT configuration.'); + $this->synchronizeipsec = $this->sync_flag('Synchronize IPsec configuration.'); + $this->synchronizeopenvpn = $this->sync_flag('Synchronize OpenVPN configuration.'); + $this->synchronizedhcpd = $this->sync_flag('Synchronize DHCP server configuration.'); + $this->synchronizedhcpdv6 = $this->sync_flag('Synchronize DHCPv6 server configuration.'); + $this->synchronizekea6 = $this->sync_flag('Synchronize Kea DHCPv6 server configuration.'); + $this->synchronizedhcrelay = $this->sync_flag('Synchronize DHCP relay configuration.'); + $this->synchronizedhcrelay6 = $this->sync_flag('Synchronize DHCPv6 relay configuration.'); + $this->synchronizewol = $this->sync_flag('Synchronize Wake-on-LAN configuration.'); + $this->synchronizestaticroutes = $this->sync_flag('Synchronize static routes.'); + $this->synchronizevirtualip = $this->sync_flag('Synchronize virtual IP addresses.'); + $this->synchronizetrafficshaper = $this->sync_flag('Synchronize traffic shaper queues.'); + $this->synchronizetrafficshaperlimiter = $this->sync_flag('Synchronize traffic shaper limiters.'); + $this->synchronizednsforwarder = $this->sync_flag('Synchronize DNS Forwarder and DNS Resolver configuration.'); + $this->synchronizecaptiveportal = $this->sync_flag('Synchronize captive portal configuration.'); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * pfSense HA Sync checkboxes are stored as the string "on" when enabled. + * When disabled, the XML key should be removed. + */ + private function sync_flag(string $help_text): BooleanField { + return new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: $help_text, + ); + } + + /** + * Applies HA Sync configuration changes. + */ + public function apply(): bool|null { + filter_configure_sync(); + filter_configure(); + + return true; + } +} \ No newline at end of file From a2d8bfd087822851674661b9241bd53bd53d6f6a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 23 Jul 2026 09:56:59 -0600 Subject: [PATCH 02/20] style: run prettier on changed files --- .../Endpoints/SystemHASyncEndpoint.inc | 5 ++-- .../local/pkg/RESTAPI/Models/FirewallRule.inc | 27 ++++++++++++++++++- .../usr/local/pkg/RESTAPI/Models/HASync.inc | 23 +++------------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc index 83f47bf8..1b692c38 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc @@ -18,8 +18,9 @@ class SystemHASyncEndpoint extends Endpoint { $this->tag = 'System'; $this->get_help_text = 'Reads pfSense High Availability synchronization settings.'; - $this->patch_help_text = 'Updates pfSense High Availability synchronization settings and applies the configuration.'; + $this->patch_help_text = + 'Updates pfSense High Availability synchronization settings and applies the configuration.'; parent::__construct(); } -} \ No newline at end of file +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallRule.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallRule.inc index e9880c96..c4fb89f5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallRule.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallRule.inc @@ -193,7 +193,32 @@ class FirewallRule extends Model { ); $this->dscp = new StringField( default: '', - choices: ['af11', 'af12', 'af13', 'af21', 'af22', 'af23', 'af31', 'af32', 'af33', 'af41', 'af42', 'af43', 'VA', 'EF', 'cs1', 'cs2', 'cs3', 'cs4', 'cs5', 'cs6', 'cs7', '0x01', '0x02', '0x04'], + choices: [ + 'af11', + 'af12', + 'af13', + 'af21', + 'af22', + 'af23', + 'af31', + 'af32', + 'af33', + 'af41', + 'af42', + 'af43', + 'VA', + 'EF', + 'cs1', + 'cs2', + 'cs3', + 'cs4', + 'cs5', + 'cs6', + 'cs7', + '0x01', + '0x02', + '0x04', + ], allow_empty: true, verbose_name: 'DSCP', help_text: 'The DSCP value this firewall rule should match.', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc index 9fe1345d..32357c78 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc @@ -74,12 +74,7 @@ class HASync extends Model { $this->pfsyncpeerip = new StringField( default: '', allow_empty: true, - validators: [ - new IPAddressValidator( - allow_ipv4: true, - allow_ipv6: false, - ), - ], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], help_text: 'The peer IP address used by pfsync.', ); @@ -87,12 +82,7 @@ class HASync extends Model { $this->synchronizetoip = new StringField( default: '', allow_empty: true, - validators: [ - new IPAddressValidator( - allow_ipv4: true, - allow_ipv6: false, - ), - ], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], help_text: 'The remote pfSense host IP address used for XMLRPC configuration synchronization.', ); @@ -148,12 +138,7 @@ class HASync extends Model { * When disabled, the XML key should be removed. */ private function sync_flag(string $help_text): BooleanField { - return new BooleanField( - default: false, - indicates_true: 'on', - indicates_false: null, - help_text: $help_text, - ); + return new BooleanField(default: false, indicates_true: 'on', indicates_false: null, help_text: $help_text); } /** @@ -165,4 +150,4 @@ class HASync extends Model { return true; } -} \ No newline at end of file +} From 52e75e179f690bf6e1983a1b177c6a78c64fa069 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 12:48:51 -0600 Subject: [PATCH 03/20] fix(RESTAPISettingsSync): re-validate incoming settings sync-set data --- .../local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index 71cecbd4..6a27e9e3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -50,7 +50,7 @@ class RESTAPISettingsSync extends Model { */ public function validate_sync_data(string $sync_data): string { # Attempt to unserialize the sync data - $unserialized_sync_data = unserialize($this->sync_data->value); + $unserialized_sync_data = json_decode($this->sync_data->value, associative: true); # Throw a validation error if $sync_data could not be unserialized if (!$unserialized_sync_data) { @@ -60,6 +60,11 @@ class RESTAPISettingsSync extends Model { ); } + # Re-validate the incoming data + $settings = new RESTAPISettings(); + $settings->from_internal_object($unserialized_sync_data); + $settings->validate(); + return $sync_data; } @@ -68,7 +73,7 @@ class RESTAPISettingsSync extends Model { */ public function _create(): void { # Unserialize the sync data - $unserialized_sync_data = unserialize($this->sync_data->value); + $unserialized_sync_data = json_decode($this->sync_data->value, associative: true); # Disable HA sync on this system since it is being managed by a remote peer. $unserialized_sync_data['ha_sync'] = 'disabled'; @@ -111,7 +116,7 @@ class RESTAPISettingsSync extends Model { $response = \RESTAPI\Core\Tools\http_request( url: "$wc_protocol://$ha_sync_host:$wc_port$endpoint_url", method: 'POST', - data: ['sync_data' => serialize($settings->get_pkg_config())], + data: ['sync_data' => json_encode($settings->get_pkg_config())], username: $settings->ha_sync_username->value, password: $settings->ha_sync_password->value, validate_certs: false, From dee8a0eeb15689d72aaa18e98cdf663ad7399859 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 12:55:54 -0600 Subject: [PATCH 04/20] feat(Endpoint): allow endpoints to disable scoped privileges and enforce page-all --- .../usr/local/pkg/RESTAPI/Core/Endpoint.inc | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc index 19d8ae8d..15857358 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc @@ -239,6 +239,13 @@ class Endpoint { */ public array $delete_privileges = []; + /** + * @var bool $requires_page_all_privilege + * When enabled, this endpoint will always require the page-all privilege and will + * disable auto-generated/scoped REST API privileges for this endpoint. + */ + public bool $requires_page_all_privilege = false; + /** * @var string $get_help_text * Sets the GET request's OpenAPI documentation for this Endpoint. This will be @@ -534,6 +541,17 @@ class Endpoint { */ private function get_default_privs(): void { $page_all_priv = 'page-all'; + + # If this endpoint requires page-all, then only assign page-all to the privileges for each method + if ($this->requires_page_all_privilege) { + $this->get_privileges = [$page_all_priv]; + $this->post_privileges = [$page_all_priv]; + $this->patch_privileges = [$page_all_priv]; + $this->put_privileges = [$page_all_priv]; + $this->delete_privileges = [$page_all_priv]; + return; + } + $this->get_privileges = [$page_all_priv, $this->get_method_priv_name('GET')]; $this->post_privileges = [$page_all_priv, $this->get_method_priv_name('POST')]; $this->patch_privileges = [$page_all_priv, $this->get_method_priv_name('PATCH')]; @@ -564,6 +582,11 @@ class Endpoint { * @returns array The pfSense priv list entry array corresponding to the privileges of this Endpoint. */ public function generate_pfsense_privs(): array { + # If this endpoint requires page-all, do not generate privileges + if ($this->requires_page_all_privilege) { + return []; + } + # Set an array to populate pfSense priv entries for this Endpoint $privs = []; From a30b2805d4c06c49dbc41adad77d78e1be5e5f4d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 13:06:52 -0600 Subject: [PATCH 05/20] fix: ensure /api/v2/system/restapi/settings/sync requires page-all --- .../pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc index 66231769..fe32a602 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc @@ -22,6 +22,7 @@ class SystemRESTAPISettingsSyncEndpoint extends Endpoint { $this->ignore_interfaces = true; $this->ignore_read_only = true; $this->auth_methods = ['BasicAuth']; + $this->requires_page_all_privilege = true; # Construct the parent Endpoint object parent::__construct(); From d2553a57fe4c1948f36e1086c0dcf72a60f57e5e Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 13:18:44 -0600 Subject: [PATCH 06/20] fix(RESTAPISettingsSync): reject sync requests when ha sync is disabled --- .../pkg/RESTAPI/Models/RESTAPISettingsSync.inc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index 6a27e9e3..03070acd 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -7,6 +7,7 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; use RESTAPI\Endpoints\SystemRESTAPISettingsSyncEndpoint; use RESTAPI\Fields\StringField; +use RESTAPI\Responses\NotAcceptableError; use RESTAPI\Responses\ValidationError; use function RESTAPI\Core\Tools\cprint; @@ -42,6 +43,23 @@ class RESTAPISettingsSync extends Model { return []; } + /** + * Adds extra validation to the entire RESTAPISettingsSync model + * @throws NotAcceptableError When the REST API settings sync feature is not enabled on this host + */ + public function validate_extra(): void + { + $settings = new RESTAPISettings(); + + # Do not accept requests if HA sync features are disabled + if (!$settings->ha_sync->value) { + throw new NotAcceptableError( + message: 'REST API settings sync is not enabled on this host.', + response_id: 'RESTAPI_SETTINGS_SYNC_NOT_ENABLED', + ); + } + } + /** * Adds extra validation to the `sync_data` field. * @param string $sync_data The incoming value to be validated. From 21060f587e6dac6ba8949378c1da48fb7273a77c Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 13:19:46 -0600 Subject: [PATCH 07/20] chore: do not remove ha sync flags --- .../files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc | 1 - 1 file changed, 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index 03070acd..fffe4f2a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -94,7 +94,6 @@ class RESTAPISettingsSync extends Model { $unserialized_sync_data = json_decode($this->sync_data->value, associative: true); # Disable HA sync on this system since it is being managed by a remote peer. - $unserialized_sync_data['ha_sync'] = 'disabled'; $unserialized_sync_data['ha_sync_hosts'] = ''; $unserialized_sync_data['ha_sync_username'] = ''; $unserialized_sync_data['ha_sync_password'] = ''; From cc0c2f10f80ffd23fa111b382ec1e85e85761459 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 13:53:14 -0600 Subject: [PATCH 08/20] test: include e2e tests for rest api ha sync endpoint --- ...temRESTAPISettingsSyncEndpointTestCase.inc | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc new file mode 100644 index 00000000..5e2a933e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc @@ -0,0 +1,139 @@ +update(); + } + + /** + * Restores HA sync and login protection to their original values after tests complete. + */ + public function teardown(): void { + $settings = new RESTAPISettings(ha_sync: false, login_protection: true); + $settings->update(); + } + + /** + * Ensures a POST request to the sync endpoint is rejected with a 406 when ha_sync is disabled. + */ + public function test_post_rejected_when_ha_sync_disabled(): void { + # Disable HA sync + $settings = new RESTAPISettings(ha_sync: false); + $settings->update(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 406); + $this->assert_equals($resp->response_id, 'RESTAPI_SETTINGS_SYNC_NOT_ENABLED'); + + # Re-enable HA sync for subsequent tests + $settings->ha_sync->value = true; + $settings->update(); + } + + /** + * Ensures a POST request with invalid (non-JSON) sync_data is rejected with a 400. + */ + public function test_post_rejected_when_sync_data_is_invalid(): void { + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => 'not valid json'], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 400); + $this->assert_equals($resp->response_id, 'RESTAPI_SETTINGS_SYNC_SYNC_DATA_COULD_NOT_BE_UNSERIALIZED'); + } + + /** + * Ensures a POST request with valid sync_data succeeds. + */ + public function test_post_accepted_with_valid_sync_data(): void { + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 200); + } + + /** + * Ensures a POST request from a user without the page-all privilege is rejected with a 403. + */ + public function test_post_rejected_without_page_all_privilege(): void { + # Create a user with no privileges + $user = new User( + data: ['name' => 'sync_test_nopageall', 'password' => 'testpassword', 'priv' => []], + ); + $user->create(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: $user->name->value, + password: 'testpassword', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 403); + + $user->delete(); + } + + /** + * Ensures a POST request from a user with the page-all privilege is accepted. + */ + public function test_post_accepted_with_page_all_privilege(): void { + # Create a user with the page-all privilege + $user = new User( + data: ['name' => 'sync_test_pageall', 'password' => 'testpassword', 'priv' => ['page-all']], + ); + $user->create(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: $user->name->value, + password: 'testpassword', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 200); + + $user->delete(); + } +} + From 64e1e43cf8b6a9be980a79042180bf1c3e1796f4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 13:53:40 -0600 Subject: [PATCH 09/20] test: update RESTAPISettingsSync model tests to accommodate changes --- .../APIModelsRESTAPISettingsSyncTestCase.inc | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc index 28cfa8e6..2dbbc99b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc @@ -8,47 +8,88 @@ use RESTAPI\Models\RESTAPISettingsSync; class APIModelsRESTAPISettingsSyncTestCase extends TestCase { /** - * Checks that the 'serialize_settings()` method correctly populates the 'sync_data' field. + * Enables HA sync before running tests so that requests are accepted. + */ + public function setup(): void { + $settings = new RESTAPISettings(); + $settings->ha_sync->value = true; + $settings->update(); + } + + /** + * Restores HA sync to its original disabled state after tests complete. + */ + public function teardown(): void { + $settings = new RESTAPISettings(); + $settings->ha_sync->value = false; + $settings->update(); + } + + /** + * Checks that create() is rejected with a NotAcceptableError when ha_sync is disabled. + */ + public function test_create_rejected_when_ha_sync_disabled(): void { + # Disable HA sync + $settings = new RESTAPISettings(); + $settings->ha_sync->value = false; + $settings->update(); + + $this->assert_throws_response( + response_id: 'RESTAPI_SETTINGS_SYNC_NOT_ENABLED', + code: 406, + callable: function () { + $settings_sync = new RESTAPISettingsSync( + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + ); + $settings_sync->create(); + }, + ); + + # Re-enable HA sync for subsequent tests + $settings->ha_sync->value = true; + $settings->update(); + } + + /** + * Checks that the 'validate_sync_data()' method correctly validates the 'sync_data' field. */ public function test_validate_sync_data(): void { - # Ensure a validation error is thrown if 'sync_data' cannot be unserialized + # Ensure a validation error is thrown if 'sync_data' is not valid JSON $this->assert_throws_response( response_id: 'RESTAPI_SETTINGS_SYNC_SYNC_DATA_COULD_NOT_BE_UNSERIALIZED', code: 400, callable: function () { - # Create a sync object to test with $settings_sync = new RESTAPISettingsSync(); - $settings_sync->sync_data->value = 'not a serialized value'; + $settings_sync->sync_data->value = 'not valid json'; $settings_sync->validate(); }, ); - # Ensure serialized values are accepted + # Ensure valid JSON-encoded settings are accepted $this->assert_does_not_throw( callable: function () { - # Create a sync object to test with $settings_sync = new RESTAPISettingsSync(); - $settings_sync->sync_data->value = serialize(RESTAPISettings::get_pkg_config()); + $settings_sync->sync_data->value = json_encode(RESTAPISettings::get_pkg_config()); $settings_sync->validate(); }, ); } /** - * Checks that the 'create()' method correctly syncs the API configuration with the serialized sync data + * Checks that the 'create()' method correctly syncs the API configuration with the JSON-encoded sync data. */ public function test_create(): void { # Get the current API configuration $original_api_config = RESTAPISettings::get_pkg_config(); $sync_api_config = $original_api_config; - # Make some modifications to the API and serialize it + # Make some modifications to the API config and JSON-encode it $sync_api_config['enable'] = 'disabled'; $sync_api_config['read_only'] = 'enabled'; $sync_api_config['allowed_interfaces'] = 'lan'; # Perform the sync against ourself and ensure it updates the API config - $sync = new RESTAPISettingsSync(data: ['sync_data' => serialize($sync_api_config)]); + $sync = new RESTAPISettingsSync(data: ['sync_data' => json_encode($sync_api_config)]); $sync->create(); # Read the API config now and ensure it matches the config that was synced @@ -57,7 +98,7 @@ class APIModelsRESTAPISettingsSyncTestCase extends TestCase { $this->assert_equals($sync_api_config, $new_api_config); # Revert the API configuration to its original values via sync - $sync->sync_data->value = serialize($original_api_config); + $sync->sync_data->value = json_encode($original_api_config); $sync->create(); # Read the API config now and ensure it matches the original config From 5248fc0c8905f925d480c106a8c807819dd7b7f5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 13:54:29 -0600 Subject: [PATCH 10/20] test: include Endpoint tests to cover new privilege generation gate --- .../RESTAPI/Tests/APICoreEndpointTestCase.inc | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc index 7ee51940..25cd03f3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc @@ -133,6 +133,51 @@ class APICoreEndpointTestCase extends TestCase { ]); } + /** + * Checks that when $requires_page_all_privilege is false (the default), each method's privilege set contains both + * the page-all privilege and an auto-generated method-scoped privilege. + */ + public function test_privileges_auto_generated_when_requires_page_all_privilege_is_false(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $endpoint = new FirewallAliasEndpoint(); + + # Confirm the flag is off by default + $this->assert_is_false($endpoint->requires_page_all_privilege); + + # Each privilege array must contain page-all AND the scoped method privilege + $this->assert_equals($endpoint->get_privileges, ['page-all', 'api-v2-firewall-alias-get']); + $this->assert_equals($endpoint->post_privileges, ['page-all', 'api-v2-firewall-alias-post']); + $this->assert_equals($endpoint->patch_privileges, ['page-all', 'api-v2-firewall-alias-patch']); + $this->assert_equals($endpoint->put_privileges, ['page-all', 'api-v2-firewall-alias-put']); + $this->assert_equals($endpoint->delete_privileges, ['page-all', 'api-v2-firewall-alias-delete']); + + # generate_pfsense_privs() must return a non-empty array of method-scoped privileges + $this->assert_is_not_empty($endpoint->generate_pfsense_privs()); + } + + /** + * Checks that when $requires_page_all_privilege is true, every method's privilege set contains only page-all + * and that generate_pfsense_privs() returns an empty array (no auto-generated scoped privileges). + */ + public function test_privileges_only_page_all_when_requires_page_all_privilege_is_true(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $endpoint = new FirewallAliasEndpoint(); + $endpoint->requires_page_all_privilege = true; + + # Re-run privilege assignment to apply the flag + $endpoint->__construct(); + + # Each privilege array must contain ONLY page-all + $this->assert_equals($endpoint->get_privileges, ['page-all']); + $this->assert_equals($endpoint->post_privileges, ['page-all']); + $this->assert_equals($endpoint->patch_privileges, ['page-all']); + $this->assert_equals($endpoint->put_privileges, ['page-all']); + $this->assert_equals($endpoint->delete_privileges, ['page-all']); + + # generate_pfsense_privs() must return an empty array — no scoped privileges are registered + $this->assert_is_empty($endpoint->generate_pfsense_privs()); + } + /** * Checks that the check_auth() method correct enforces authentication */ From 9e504cc7147abb2179331317da2b8d5358d54333 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 14:08:35 -0600 Subject: [PATCH 11/20] chore: remove leftover var dump from Endpoint test case --- .../usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc | 1 - 1 file changed, 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc index 25cd03f3..79303527 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc @@ -971,7 +971,6 @@ class APICoreEndpointTestCase extends TestCase { $_SERVER['QUERY_STRING'] = "name=$unique_name&type=host&dry_run=true"; $endpoint = new FirewallAliasEndpoint(); $json_resp = json_decode($endpoint->process_request()); - var_dump($json_resp); $this->assert_equals($json_resp->code, 200); # Ensure no new alias was actually persisted From 0042059879e28344e4a201f328019f87af787055 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 14:31:14 -0600 Subject: [PATCH 12/20] style: run prettier on changed files --- .../usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc | 3 +-- ...ndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc | 9 ++------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index fffe4f2a..2614fe2b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -47,8 +47,7 @@ class RESTAPISettingsSync extends Model { * Adds extra validation to the entire RESTAPISettingsSync model * @throws NotAcceptableError When the REST API settings sync feature is not enabled on this host */ - public function validate_extra(): void - { + public function validate_extra(): void { $settings = new RESTAPISettings(); # Do not accept requests if HA sync features are disabled diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc index 5e2a933e..8b0ba4b9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc @@ -91,9 +91,7 @@ class APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase extends TestCase { */ public function test_post_rejected_without_page_all_privilege(): void { # Create a user with no privileges - $user = new User( - data: ['name' => 'sync_test_nopageall', 'password' => 'testpassword', 'priv' => []], - ); + $user = new User(data: ['name' => 'sync_test_nopageall', 'password' => 'testpassword', 'priv' => []]); $user->create(); $json_resp = \RESTAPI\Core\Tools\http_request( @@ -116,9 +114,7 @@ class APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase extends TestCase { */ public function test_post_accepted_with_page_all_privilege(): void { # Create a user with the page-all privilege - $user = new User( - data: ['name' => 'sync_test_pageall', 'password' => 'testpassword', 'priv' => ['page-all']], - ); + $user = new User(data: ['name' => 'sync_test_pageall', 'password' => 'testpassword', 'priv' => ['page-all']]); $user->create(); $json_resp = \RESTAPI\Core\Tools\http_request( @@ -136,4 +132,3 @@ class APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase extends TestCase { $user->delete(); } } - From f7021a671640f4a175682e322424fe93a51896d4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 14:40:43 -0600 Subject: [PATCH 13/20] docs: add note that ha sync requires page-all privileges --- .../files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc index 1793e099..38967df2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc @@ -227,7 +227,7 @@ class RESTAPISettings extends Model { allow_empty: true, verbose_name: 'HA username', help_text: "Sets the username to use when authenticating for HA sync processes. This user must be the present - on all hosts defined in `ha_sync_hosts`.", + on all hosts defined in `ha_sync_hosts` and must hold the `page-all` privilege" ); $this->ha_sync_password = new StringField( default: '', From e4f3ff76ef0d550293dcc5fa079b7e32813212b2 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 14:41:03 -0600 Subject: [PATCH 14/20] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc index 38967df2..c7f750ba 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc @@ -227,7 +227,7 @@ class RESTAPISettings extends Model { allow_empty: true, verbose_name: 'HA username', help_text: "Sets the username to use when authenticating for HA sync processes. This user must be the present - on all hosts defined in `ha_sync_hosts` and must hold the `page-all` privilege" + on all hosts defined in `ha_sync_hosts` and must hold the `page-all` privilege", ); $this->ha_sync_password = new StringField( default: '', From f3fe0677f8440175a73464a5e0c321f77afe9e2d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 21:05:55 -0600 Subject: [PATCH 15/20] chore: cleanup HASync model for consistency --- .../usr/local/pkg/RESTAPI/Models/HASync.inc | 193 +++++++++++++----- 1 file changed, 141 insertions(+), 52 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc index 32357c78..28dc5a95 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc @@ -3,9 +3,9 @@ namespace RESTAPI\Models; require_once 'RESTAPI/autoloader.inc'; -require_once 'filter.inc'; use RESTAPI\Core\Model; +use RESTAPI\Dispatchers\FirewallApplyDispatcher; use RESTAPI\Fields\BooleanField; use RESTAPI\Fields\InterfaceField; use RESTAPI\Fields\StringField; @@ -21,10 +21,8 @@ class HASync extends Model { public StringField $pfhostid; public StringField $username; public StringField $password; - public BooleanField $pfsyncenabled; public BooleanField $adminsync; - public BooleanField $synchronizeusers; public BooleanField $synchronizeauthservers; public BooleanField $synchronizecerts; @@ -55,8 +53,12 @@ class HASync extends Model { $this->verbose_name = 'HA Sync Settings'; $this->verbose_name_plural = 'HA Sync Settings'; - # State Synchronization Settings (pfsync) - $this->pfsyncenabled = $this->sync_flag('Enable pfsync state synchronization.'); + $this->pfsyncenabled = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Enable pfsync state synchronization.', + ); $this->pfsyncinterface = new InterfaceField( default: '', @@ -70,30 +72,23 @@ class HASync extends Model { maximum_length: 8, help_text: 'Custom pf host identifier carried in state data.', ); - $this->pfsyncpeerip = new StringField( default: '', allow_empty: true, validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], help_text: 'The peer IP address used by pfsync.', ); - - # Configuration Synchronization Settings (XMLRPC Sync) $this->synchronizetoip = new StringField( default: '', allow_empty: true, validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], help_text: 'The remote pfSense host IP address used for XMLRPC configuration synchronization.', ); - $this->username = new StringField( default: '', allow_empty: true, help_text: 'The remote pfSense username used for XMLRPC synchronization.', ); - - # pfSense stores the XMLRPC password in config.xml as . - # The API exposes it as "password" but writes it internally as "passwordfld". $this->password = new StringField( default: '', allow_empty: true, @@ -102,52 +97,146 @@ class HASync extends Model { internal_name: 'passwordfld', help_text: 'The remote pfSense password used for XMLRPC synchronization.', ); - - $this->adminsync = $this->sync_flag( - 'Synchronize admin accounts and automatically update the XMLRPC sync password.', + $this->adminsync = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize admin accounts and automatically update the XMLRPC sync password.', + ); + $this->synchronizeusers = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize users and groups.', + ); + $this->synchronizeauthservers = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize authentication servers.', + ); + $this->synchronizecerts = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize certificates.', + ); + $this->synchronizerules = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize firewall rules.', + ); + $this->synchronizeschedules = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize firewall schedules.', + ); + $this->synchronizealiases = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize firewall aliases.', + ); + $this->synchronizenat = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize NAT configuration.', + ); + $this->synchronizeipsec = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize IPsec configuration.', + ); + $this->synchronizeopenvpn = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize OpenVPN configuration.', + ); + $this->synchronizedhcpd = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCP server configuration.', + ); + $this->synchronizedhcpdv6 = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCPv6 server configuration.', + ); + $this->synchronizekea6 = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize Kea DHCPv6 server configuration.', + ); + $this->synchronizedhcrelay = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCP relay configuration.', + ); + $this->synchronizedhcrelay6 = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCPv6 relay configuration.', + ); + $this->synchronizewol = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize Wake-on-LAN configuration.', + ); + $this->synchronizestaticroutes = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize static routes.', + ); + $this->synchronizevirtualip = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize virtual IP addresses.', + ); + $this->synchronizetrafficshaper = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize traffic shaper queues.', + ); + $this->synchronizetrafficshaperlimiter = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize traffic shaper limiters.', + ); + $this->synchronizednsforwarder = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DNS Forwarder and DNS Resolver configuration.', + ); + $this->synchronizecaptiveportal = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize captive portal configuration.', ); - - # Select options to sync - $this->synchronizeusers = $this->sync_flag('Synchronize users and groups.'); - $this->synchronizeauthservers = $this->sync_flag('Synchronize authentication servers.'); - $this->synchronizecerts = $this->sync_flag('Synchronize certificates.'); - $this->synchronizerules = $this->sync_flag('Synchronize firewall rules.'); - $this->synchronizeschedules = $this->sync_flag('Synchronize firewall schedules.'); - $this->synchronizealiases = $this->sync_flag('Synchronize firewall aliases.'); - $this->synchronizenat = $this->sync_flag('Synchronize NAT configuration.'); - $this->synchronizeipsec = $this->sync_flag('Synchronize IPsec configuration.'); - $this->synchronizeopenvpn = $this->sync_flag('Synchronize OpenVPN configuration.'); - $this->synchronizedhcpd = $this->sync_flag('Synchronize DHCP server configuration.'); - $this->synchronizedhcpdv6 = $this->sync_flag('Synchronize DHCPv6 server configuration.'); - $this->synchronizekea6 = $this->sync_flag('Synchronize Kea DHCPv6 server configuration.'); - $this->synchronizedhcrelay = $this->sync_flag('Synchronize DHCP relay configuration.'); - $this->synchronizedhcrelay6 = $this->sync_flag('Synchronize DHCPv6 relay configuration.'); - $this->synchronizewol = $this->sync_flag('Synchronize Wake-on-LAN configuration.'); - $this->synchronizestaticroutes = $this->sync_flag('Synchronize static routes.'); - $this->synchronizevirtualip = $this->sync_flag('Synchronize virtual IP addresses.'); - $this->synchronizetrafficshaper = $this->sync_flag('Synchronize traffic shaper queues.'); - $this->synchronizetrafficshaperlimiter = $this->sync_flag('Synchronize traffic shaper limiters.'); - $this->synchronizednsforwarder = $this->sync_flag('Synchronize DNS Forwarder and DNS Resolver configuration.'); - $this->synchronizecaptiveportal = $this->sync_flag('Synchronize captive portal configuration.'); parent::__construct($id, $parent_id, $data, ...$options); } /** - * pfSense HA Sync checkboxes are stored as the string "on" when enabled. - * When disabled, the XML key should be removed. - */ - private function sync_flag(string $help_text): BooleanField { - return new BooleanField(default: false, indicates_true: 'on', indicates_false: null, help_text: $help_text); - } - - /** - * Applies HA Sync configuration changes. + * Applies HA Sync configuration changes via the FirewallApplyDispatcher. */ - public function apply(): bool|null { - filter_configure_sync(); - filter_configure(); - - return true; + public function apply(): void { + (new FirewallApplyDispatcher(async: $this->async))->spawn_process(); } } From 440942e949d6787d7cd8d4dd29d5e48f7b65c0d5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 21:06:03 -0600 Subject: [PATCH 16/20] test: add tests for HASync model --- .../RESTAPI/Tests/APIModelsHASyncTestCase.inc | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc new file mode 100644 index 00000000..2864ed0f --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc @@ -0,0 +1,145 @@ +assert_does_not_throw( + callable: function () { + $hasync = new HASync(); + $hasync->pfsyncenabled->value = true; + $hasync->pfhostid->value = 'testid'; + $hasync->pfsyncpeerip->value = '192.168.1.2'; + $hasync->update(); + }, + ); + + # Restore defaults + $hasync = new HASync(); + $hasync->pfsyncenabled->value = false; + $hasync->pfhostid->value = ''; + $hasync->pfsyncpeerip->value = ''; + $hasync->update(); + } + + /** + * Checks that XMLRPC sync connection settings can be updated without error. + */ + public function test_update_xmlrpc_connection_settings(): void { + $this->assert_does_not_throw( + callable: function () { + $hasync = new HASync(); + $hasync->synchronizetoip->value = '10.0.0.1'; + $hasync->username->value = 'syncuser'; + $hasync->password->value = 'syncpassword'; + $hasync->adminsync->value = true; + $hasync->update(); + }, + ); + + # Restore defaults + $hasync = new HASync(); + $hasync->synchronizetoip->value = ''; + $hasync->username->value = ''; + $hasync->password->value = ''; + $hasync->adminsync->value = false; + $hasync->update(); + } + + /** + * Checks that all synchronize* boolean flags can be enabled and disabled without error. + */ + public function test_update_synchronize_flags(): void { + $this->assert_does_not_throw( + callable: function () { + $hasync = new HASync( + data: [ + 'synchronizeusers' => true, + 'synchronizeauthservers' => true, + 'synchronizecerts' => true, + 'synchronizerules' => true, + 'synchronizeschedules' => true, + 'synchronizealiases' => true, + 'synchronizenat' => true, + 'synchronizeipsec' => true, + 'synchronizeopenvpn' => true, + 'synchronizedhcpd' => true, + 'synchronizedhcpdv6' => true, + 'synchronizekea6' => true, + 'synchronizedhcrelay' => true, + 'synchronizedhcrelay6' => true, + 'synchronizewol' => true, + 'synchronizestaticroutes' => true, + 'synchronizevirtualip' => true, + 'synchronizetrafficshaper' => true, + 'synchronizetrafficshaperlimiter' => true, + 'synchronizednsforwarder' => true, + 'synchronizecaptiveportal' => true, + ], + ); + $hasync->update(); + }, + ); + + # Restore all flags to disabled + $this->assert_does_not_throw( + callable: function () { + $hasync = new HASync( + data: [ + 'synchronizeusers' => false, + 'synchronizeauthservers' => false, + 'synchronizecerts' => false, + 'synchronizerules' => false, + 'synchronizeschedules' => false, + 'synchronizealiases' => false, + 'synchronizenat' => false, + 'synchronizeipsec' => false, + 'synchronizeopenvpn' => false, + 'synchronizedhcpd' => false, + 'synchronizedhcpdv6' => false, + 'synchronizekea6' => false, + 'synchronizedhcrelay' => false, + 'synchronizedhcrelay6' => false, + 'synchronizewol' => false, + 'synchronizestaticroutes' => false, + 'synchronizevirtualip' => false, + 'synchronizetrafficshaper' => false, + 'synchronizetrafficshaperlimiter' => false, + 'synchronizednsforwarder' => false, + 'synchronizecaptiveportal' => false, + ], + ); + $hasync->update(); + }, + ); + } + + /** + * Checks that updated values are persisted correctly and can be read back. + */ + public function test_updated_values_are_persisted(): void { + $hasync = new HASync(); + $hasync->synchronizerules->value = true; + $hasync->synchronizealiases->value = true; + $hasync->username->value = 'hauser'; + $hasync->update(); + + # Read the settings back and verify the values were stored + $hasync_read = new HASync(); + $this->assert_is_true($hasync_read->synchronizerules->value); + $this->assert_is_true($hasync_read->synchronizealiases->value); + $this->assert_equals($hasync_read->username->value, 'hauser'); + + # Restore defaults + $hasync_read->synchronizerules->value = false; + $hasync_read->synchronizealiases->value = false; + $hasync_read->username->value = ''; + $hasync_read->update(); + } +} From 81eb3c2000e80617f111d8e5fa5d78bae8074487 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 21:07:11 -0600 Subject: [PATCH 17/20] chore: cleanup SystemHASyncEndpoint definition for consistency --- .../usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc index 1b692c38..bc8b5cf1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc @@ -15,11 +15,9 @@ class SystemHASyncEndpoint extends Endpoint { $this->url = '/api/v2/system/hasync'; $this->model_name = 'HASync'; $this->request_method_options = ['GET', 'PATCH']; - $this->tag = 'System'; $this->get_help_text = 'Reads pfSense High Availability synchronization settings.'; - $this->patch_help_text = - 'Updates pfSense High Availability synchronization settings and applies the configuration.'; + $this->patch_help_text = 'Updates pfSense High Availability synchronization settings.'; parent::__construct(); } From fb65fd615e27312c412b1789a7deb3cd6bbd239e Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 27 Jul 2026 21:10:19 -0600 Subject: [PATCH 18/20] chore(SystemDNS): sort parameters --- .../files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc index 2da5e16a..95376334 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc @@ -42,8 +42,8 @@ class SystemDNS extends Model { allow_empty: true, many: true, delimiter: null, - validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], verbose_name: 'DNS Servers', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], help_text: 'The remote DNS server IPv4 or IPv6 addresses.', ); From 73adfce306385cd77d31d83f201026c3ce4e1cac Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 28 Jul 2026 17:16:38 -0600 Subject: [PATCH 19/20] feat: add WireGuard status endpoints #790 --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 2 +- .../StatusWireGuardPeersEndpoint.inc | 23 +++ .../StatusWireGuardTunnelsEndpoint.inc | 23 +++ .../RESTAPI/Models/WireGuardPeerStatus.inc | 137 ++++++++++++++ .../RESTAPI/Models/WireGuardTunnelStatus.inc | 172 ++++++++++++++++++ 5 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc index 80be5bda..9fed4dd1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc @@ -1769,7 +1769,7 @@ class Model { * @throws NotFoundError When the Model requires a pfSense package that is not installed. * @throws ServerError When a package requires a PHP include file that could not be found. */ - private function check_packages(): void { + protected function check_packages(): void { # Check if the user has opted in to using development (-devel) package variants $pkg_config = RESTAPI\Models\RESTAPISettings::get_pkg_config(); $allow_development_packages = ($pkg_config['allow_development_packages'] ?? '') === 'enabled'; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc new file mode 100644 index 00000000..b5770c8f --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/status/wireguard/peers'; + $this->model_name = 'WireGuardPeerStatus'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc new file mode 100644 index 00000000..ebde4980 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/status/wireguard/tunnels'; + $this->model_name = 'WireGuardTunnelStatus'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc new file mode 100644 index 00000000..1430055d --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc @@ -0,0 +1,137 @@ +many = true; + $this->internal_callable = 'get_wireguard_status'; + $this->verbose_name = 'WireGuard Tunnel Status Peer'; + $this->packages = ['pfSense-pkg-WireGuard']; + $this->package_includes = [ + 'wireguard/includes/wg_service.inc', + 'wireguard/includes/wg.inc', + 'wireguard/includes/wg_globals.inc', + 'wireguard/includes/wg_api.inc', + ]; + + # Fields + $this->tunnel_device = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Tunnel Device', + help_text: 'The tunnel device this peer is associated with.', + ); + $this->public_key = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Public Key', + help_text: 'The public key of the peer.', + ); + $this->preshared_key = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + sensitive: true, + verbose_name: 'Preshared Key', + help_text: 'The preshared key negotiated with this peer, if any.', + ); + $this->endpoint = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + verbose_name: 'Endpoint', + help_text: 'The remote endpoint (IP:port) the peer is connected from.', + ); + $this->allowed_ips = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + many: true, + verbose_name: 'Allowed IPs', + help_text: 'The comma-separated list of IP prefixes that are routed to this peer.', + ); + $this->latest_handshake = new UnixTimeField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Latest Handshake', + help_text: 'The Unix timestamp of the most recent handshake with this peer.', + ); + $this->transfer_rx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer RX', + help_text: 'The total number of bytes received from this peer.', + ); + $this->transfer_tx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer TX', + help_text: 'The total number of bytes sent to this peer.', + ); + $this->persistent_keepalive = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + verbose_name: 'Persistent Keepalive', + help_text: 'The persistent keepalive interval for this peer, or "off" if disabled.', + ); + $this->descr = new StringField( + default: '', + allow_empty: true, + read_only: true, + verbose_name: 'Description', + help_text: 'The description of this peer as stored in the pfSense configuration.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Retrieves the live WireGuard status from the `wg` CLI tool and maps it into the flat array format + * expected by the Model framework's internal_callable mechanism. + * @return array An indexed array of tunnel status records, each containing peer status as a sub-array. + */ + protected function get_wireguard_status(): array { + $peers = []; + $tunnels = (new WireGuardTunnelStatus())->get_internal_objects(); + + foreach ($tunnels as $tunnel) { + $peers = $peers + $tunnel['peers']; + } + + return $peers; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc new file mode 100644 index 00000000..510a5a60 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc @@ -0,0 +1,172 @@ +internal_callable = 'get_wireguard_status'; + $this->many = true; + $this->verbose_name = 'WireGuard Tunnel Status'; + $this->packages = ['pfSense-pkg-WireGuard']; + $this->package_includes = [ + 'wireguard/includes/wg_service.inc', + 'wireguard/includes/wg.inc', + 'wireguard/includes/wg_globals.inc', + 'wireguard/includes/wg_api.inc', + ]; + + # Fields + $this->name = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Name', + help_text: 'The WireGuard tunnel interface name (e.g. wg0).', + ); + $this->status = new StringField( + default: null, + choices: ['up', 'down'], + allow_null: true, + read_only: true, + verbose_name: 'Status', + help_text: 'The current operational status of the tunnel interface.', + ); + $this->public_key = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Public Key', + help_text: 'The public key of this tunnel.', + ); + $this->listen_port = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Listen Port', + help_text: 'The UDP port this tunnel is listening on.', + ); + $this->mtu = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'MTU', + help_text: 'The MTU of the tunnel interface.', + ); + $this->transfer_rx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer RX', + help_text: 'The total number of bytes received on the tunnel interface.', + ); + $this->transfer_tx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer TX', + help_text: 'The total number of bytes transmitted on the tunnel interface.', + ); + $this->inpkts = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Inbound Packets', + help_text: 'The total number of inbound packets observed on the tunnel interface.', + ); + $this->outpkts = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Outbound Packets', + help_text: 'The total number of outbound packets observed on the tunnel interface.', + ); + $this->descr = new StringField( + default: '', + allow_empty: true, + read_only: true, + verbose_name: 'Description', + help_text: 'The description of this tunnel as stored in the pfSense configuration.', + ); + $this->peers = new NestedModelField( + model_class: 'WireGuardPeerStatus', + default: [], + allow_empty: true, + read_only: true, + verbose_name: 'Peers', + help_text: 'The live status of each peer associated with this WireGuard tunnel.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Retrieves the live WireGuard status from the `wg` CLI tool and maps it into the flat array format + * expected by the Model framework's internal_callable mechanism. + * @return array An indexed array of tunnel status records, each containing peer status as a sub-array. + */ + protected function get_wireguard_status(): array { + $tunnels = []; + + $this->check_packages(); + + foreach (wg_get_status() as $device_name => $device) { + # Build the indexed peer list from the public-key-keyed peers array + $peers = []; + + foreach ($device['peers'] as $peer) { + $peers[] = [ + 'tunnel_device' => $device_name, + 'public_key' => $peer['public_key'] ?? null, + 'preshared_key' => $peer['preshared_key'] ?? null, + 'endpoint' => $peer['endpoint'] ?? null, + 'allowed_ips' => $peer['allowed_ips'] ?? null, + 'latest_handshake' => isset($peer['latest_handshake']) ? (int) $peer['latest_handshake'] : null, + 'transfer_rx' => isset($peer['transfer_rx']) ? (int) $peer['transfer_rx'] : null, + 'transfer_tx' => isset($peer['transfer_tx']) ? (int) $peer['transfer_tx'] : null, + 'persistent_keepalive' => $peer['persistent_keepalive'] ?? null, + 'descr' => $peer['config']['descr'] ?? '', + ]; + } + + $tunnels[] = [ + 'name' => $device_name, + 'status' => $device['status'] ?? null, + 'public_key' => $device['public_key'] ?? null, + 'listen_port' => $device['listen_port'] ?? null, + 'mtu' => isset($device['mtu']) ? (int) $device['mtu'] : null, + 'transfer_rx' => isset($device['transfer_rx']) ? (int) $device['transfer_rx'] : null, + 'transfer_tx' => isset($device['transfer_tx']) ? (int) $device['transfer_tx'] : null, + 'inpkts' => isset($device['inpkts']) ? (int) $device['inpkts'] : null, + 'outpkts' => isset($device['outpkts']) ? (int) $device['outpkts'] : null, + 'descr' => $device['config']['descr'] ?? '', + 'peers' => $peers, + ]; + } + + return $tunnels; + } +} From 27e7fd9f28b8f8d25abe594aefa09803a81434a8 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 28 Jul 2026 17:19:37 -0600 Subject: [PATCH 20/20] fix(HASync): allow loopback interfaces --- .../files/usr/local/pkg/RESTAPI/Models/HASync.inc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc index 28dc5a95..e0d3ee85 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc @@ -59,13 +59,12 @@ class HASync extends Model { indicates_false: null, help_text: 'Enable pfsync state synchronization.', ); - $this->pfsyncinterface = new InterfaceField( - default: '', + default: 'lo0', + allow_localhost_interface: true, allow_empty: true, help_text: 'The interface used by pfsync state synchronization.', ); - $this->pfhostid = new StringField( default: '', allow_empty: true,