From 754238695179b52d47eae45e1bf1ffc847d3e333 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 19 Oct 2026 20:17:01 -0600 Subject: [PATCH 01/17] fix: remove unique constraint from Service::$name field #953 --- .../files/usr/local/pkg/RESTAPI/Models/Service.inc | 1 - 1 file changed, 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc index a1a1a167..64b1ba0d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc @@ -33,7 +33,6 @@ class Service extends Model { ); $this->name = new StringField( required: true, - unique: true, verbose_name: 'Name', help_text: 'The internal name of the service.', ); From 4fee4e165d4f8f89b35a3eb84f621f1c1db14ee8 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 19 Oct 2026 20:27:30 -0600 Subject: [PATCH 02/17] feat(ACMECertificateDomain): add support for azuredns_managedidentity field #955 --- .../local/pkg/RESTAPI/Models/ACMECertificateDomain.inc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc index fefdc83e..45d3d497 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc @@ -66,6 +66,7 @@ class ACMECertificateDomain extends Model { public StringField $azuredns_tenantid; public StringField $azuredns_appid; public StringField $azuredns_clientsecret; + public StringField $azuredns_managedidentity; public StringField $bookmyname_username; public StringField $bookmyname_password; public StringField $bunny_api_key; @@ -751,6 +752,14 @@ class ACMECertificateDomain extends Model { verbose_name: 'Azure DNS Tenant ID', help_text: 'Azure Tenant ID', ); + $this->azuredns_managedidentity = new StringField( + default: '', + allow_empty: true, + verbose_name: 'Azure DNS Management Identity', + internal_name: 'dns_azureazuredns_managedidentity', + conditions: ['method' => 'dns_azure'], + help_text: 'Azure Management Identity', + ); $this->azuredns_appid = new StringField( default: '', allow_empty: true, From c2bb51acca068aedeb2d41e80d866176cc9032d2 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Wed, 21 Oct 2026 18:20:53 -0600 Subject: [PATCH 03/17] docs(RESTAPISettings): describe the need to enable hasync on remote peers in help text #954 --- .../files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 c7f750ba..e1074626 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 @@ -198,8 +198,9 @@ class RESTAPISettings extends Model { indicates_true: 'enabled', indicates_false: 'disabled', verbose_name: 'HA sync', - help_text: "Enables or disables syncing API settings to HA peers. When enabled, API settings from this - host will automatically be synced to any hosts defined in `ha_sync_hosts`.", + help_text: "Enables HA sync for REST API settings. When enabled, this host will be allowed to accept sync + requests from peers. If `ha_sync_hosts` are also defined, this host will sync its settings to + those hosts, which must also enable this setting to accept them.", ); $this->ha_sync_validate_certs = new BooleanField( default: true, @@ -225,7 +226,7 @@ class RESTAPISettings extends Model { $this->ha_sync_username = new StringField( default: '', allow_empty: true, - verbose_name: 'HA username', + verbose_name: 'HA sync 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", ); From 1028f7150b02a84c4126456976d0764033e17a03 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Wed, 21 Oct 2026 18:21:13 -0600 Subject: [PATCH 04/17] docs: add mkdocs page for HA sync configuration --- docs/HA_SYNC.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 75 insertions(+) create mode 100644 docs/HA_SYNC.md diff --git a/docs/HA_SYNC.md b/docs/HA_SYNC.md new file mode 100644 index 00000000..636bfe7c --- /dev/null +++ b/docs/HA_SYNC.md @@ -0,0 +1,74 @@ +# HA Sync + +The REST API package supports High Availability (HA) synchronization, allowing you to automatically replicate your +REST API settings from one pfSense host to one or more remote peers. This is especially useful in HA deployments where +you want all nodes to share the same REST API configuration without having to configure each host manually. + +!!! Important + HA sync only synchronizes the **REST API package settings** (such as authentication methods, allowed interfaces, + JWT expiration, and other options found under the REST API settings). It does not synchronize the rest of the + pfSense configuration. Use pfSense's built-in HA/XMLRPC configuration sync for the base system configuration. + +## How It Works + +When HA sync is enabled and one or more sync hosts are defined, the source host will push its REST API settings to each +configured peer whenever those settings change. Each peer receives the sync request and applies the incoming settings +to its own REST API configuration. + +## Requirements + +Before enabling HA sync, ensure the following: + +- The REST API package is installed on every host that will participate in synchronization. +- A dedicated user exists on **every** peer defined in the sync hosts list. This user must hold the `page-all` + privilege and must use the same username and password across all hosts. +- Each peer is reachable from the source host over HTTPS (the webConfigurator port). +- If certificate validation is enabled, each peer's webConfigurator must present a certificate trusted by the source + host. + +!!! Warning + It is expected that the source host and each of its peers are as identical as possible. Changes between REST API + versions or pfSense versions may make the synced configurations incompatible with the remote peers. Additionally, + some settings (such as the allowed interfaces) are configuration-specific, and it is expected that those interfaces + also exist on each remote peer. + +## Configuring HA Sync on the Source Host + +Follow these steps on the host that will send its REST API settings to peers: + +1. In the webConfigurator, navigate to **System > REST API > Settings**. +2. Enable the **HA Sync** toggle. This allows the host to both send sync requests to peers and accept incoming sync + requests from peers. +3. In the **HA Sync Hosts** field, enter the IP address or hostname of each remote peer that should receive this host's + REST API settings. You may define up to 10 hosts. +4. Set the **HA Sync Username** to the username of the account used to authenticate sync requests. This user must exist on + every peer and hold the `page-all` privilege. +5. Set the **HA Sync Password** to the password for the HA username. This must match on all peers. +6. (Recommended) Leave **HA Sync Validate Certs** enabled to validate each peer's webConfigurator certificate. This + helps mitigate Man-in-the-Middle attacks. If you disable it, certificate validation will be skipped. +7. Save your changes. + +## Configuring HA Sync on Remote Peers + +Repeat the following on **every** remote peer listed in the source host's **HA Sync Hosts** field: + +1. In the webConfigurator, navigate to **System > REST API > Settings**. +2. Enable the **HA Sync** toggle. A peer will reject incoming sync requests by default unless the HA Sync toggle is enabled on that + peer. +3. Ensure the HA sync user (with the `page-all` privilege) exists on the peer and uses the same username and password + as configured on the source host. +4. Save your changes. + +Once HA Sync is enabled on both the source host and all peers, any change to the source host's REST API settings will +automatically be synchronized to each configured peer. + +## Configuring HA Sync via the REST API + +All HA sync options are also available through the REST API settings endpoint, so you can automate HA sync configuration +as part of your provisioning workflow. The relevant settings include: + +- `ha_sync` — Enables or disables HA sync on the host. Must be enabled on both the sending and receiving hosts. +- `ha_sync_validate_certs` — Enables or disables certificate validation when syncing to peers. +- `ha_sync_hosts` — A list of IP addresses or hostnames to sync REST API settings to. +- `ha_sync_username` — The username used to authenticate sync requests. Must hold the `page-all` privilege on all peers. +- `ha_sync_password` — The password for the HA sync user. Must match across all peers. diff --git a/mkdocs.yml b/mkdocs.yml index 2c2a8f54..feb087ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - Working with HATEOAS: WORKING_WITH_HATEOAS.md - Common Control Parameters: COMMON_CONTROL_PARAMETERS.md - GraphQL: GRAPHQL.md + - HA Sync: HA_SYNC.md - Securing API Access: SECURING_API_ACCESS.md - Limitations & FAQs: LIMITATIONS_AND_FAQS.md - API Reference: https://pfrest.org/api-docs/ From 2650de19ba850d2499590779aa1a7d23d137cd19 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 23 Oct 2026 21:10:16 -0600 Subject: [PATCH 05/17] feat(HAProxyFrontendACL): add additional validation for supported `expressions` #948 --- .../pkg/RESTAPI/Models/HAProxyFrontendACL.inc | 62 ++++++- .../APIModelsHAProxyFrontendACLTestCase.inc | 170 ++++++++++++++++++ 2 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendACLTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendACL.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendACL.inc index 309d1026..7dd59d00 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendACL.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendACL.inc @@ -2,10 +2,13 @@ namespace RESTAPI\Models; +require_once 'RESTAPI/autoloader.inc'; + use RESTAPI\Core\Model; use RESTAPI\Dispatchers\HAProxyApplyDispatcher; use RESTAPI\Fields\BooleanField; use RESTAPI\Fields\StringField; +use RESTAPI\Responses\ValidationError; use RESTAPI\Validators\RegexValidator; /** @@ -18,7 +21,10 @@ class HAProxyFrontendACL extends Model { public BooleanField $casesensitive; public BooleanField $not; - public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], ...$options) { + /** + * Constructs the HAProxy frontend ACL Model and its fields. + */ + public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { # Set model attributes $this->parent_model_class = 'HAProxyFrontend'; $this->config_path = 'ha_acls/item'; @@ -31,8 +37,8 @@ class HAProxyFrontendACL extends Model { $this->name = new StringField( required: true, minimum_length: 2, - validators: [new RegexValidator(pattern: '/[^a-zA-Z0-9\.\-_]/', invert: true)], verbose_name: 'Name', + validators: [new RegexValidator(pattern: '/[^a-zA-Z0-9\.\-_]/', invert: true)], help_text: 'The unique name for this frontend ACL.', ); $this->expression = new StringField( @@ -89,6 +95,58 @@ class HAProxyFrontendACL extends Model { parent::__construct($id, $parent_id, $data, ...$options); } + /** + * Ensures the expression is supported by the parent frontend's processing type. + * @param string $expression The incoming ACL expression. + * @return string The validated expression. + * @throws ValidationError When the expression is incompatible with the parent frontend type. + */ + public function validate_expression(string $expression): string { + $type = $this->parent_model->type->value; + $supported_expressions = [ + 'http' => [ + 'host_starts_with', + 'host_ends_with', + 'host_matches', + 'host_regex', + 'host_contains', + 'path_starts_with', + 'path_ends_with', + 'path_matches', + 'path_regex', + 'path_contains', + 'path_dir', + 'url_parameter', + 'ssl_c_verify_code', + 'ssl_c_verify', + 'ssl_c_ca_commonname', + 'source_ip', + 'backendservercount', + 'custom', + ], + 'https' => [ + 'source_ip', + 'backendservercount', + 'ssl_sni_matches', + 'ssl_sni_contains', + 'ssl_sni_starts_with', + 'ssl_sni_ends_with', + 'ssl_sni_regex', + 'custom', + ], + 'tcp' => ['source_ip', 'backendservercount', 'traffic_is_http', 'traffic_is_ssl', 'custom'], + ]; + + if (!in_array($expression, $supported_expressions[$type] ?? [], true)) { + throw new ValidationError( + message: "Expression `$expression` is not supported by parent frontend type `$type`.", + response_id: 'HAPROXY_FRONTEND_ACL_EXPRESSION_TYPE_MISMATCH', + ); + } + + return $expression; + } + /** * Ensures haproxy is marked as dirty before applying. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendACLTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendACLTestCase.inc new file mode 100644 index 00000000..5c803794 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendACLTestCase.inc @@ -0,0 +1,170 @@ + [ + 'host_starts_with', + 'host_ends_with', + 'host_matches', + 'host_regex', + 'host_contains', + 'path_starts_with', + 'path_ends_with', + 'path_matches', + 'path_regex', + 'path_contains', + 'path_dir', + 'url_parameter', + 'ssl_c_verify_code', + 'ssl_c_verify', + 'ssl_c_ca_commonname', + 'source_ip', + 'backendservercount', + 'custom', + ], + 'https' => [ + 'source_ip', + 'backendservercount', + 'ssl_sni_matches', + 'ssl_sni_contains', + 'ssl_sni_starts_with', + 'ssl_sni_ends_with', + 'ssl_sni_regex', + 'custom', + ], + 'tcp' => ['source_ip', 'backendservercount', 'traffic_is_http', 'traffic_is_ssl', 'custom'], + ]; + + /** + * Checks every expression against each persisted parent frontend type. + */ + public function test_expression_type_matrix(): void { + $choices = (new HAProxyFrontendACL())->expression->choices; + + foreach (self::SUPPORTED_EXPRESSIONS as $type => $supported) { + $frontend = new HAProxyFrontend(name: "acl_validation_$type", type: $type); + $frontend->create(); + + foreach ($choices as $expression) { + $acl = new HAProxyFrontendACL( + parent_id: $frontend->id, + name: 'test_acl', + expression: $expression, + value: '', + ); + + if (in_array($expression, $supported, true)) { + $this->assert_is_true($acl->validate()); + $this->assert_equals($acl->expression->value, $expression); + } else { + $this->assert_throws_response( + response_id: 'HAPROXY_FRONTEND_ACL_EXPRESSION_TYPE_MISMATCH', + code: 400, + callable: fn() => $acl->validate(), + ); + } + } + } + } + + /** + * Checks nested ACL validation uses the unsaved parent frontend's type. + */ + public function test_nested_expression_type_matrix(): void { + $choices = (new HAProxyFrontendACL())->expression->choices; + + foreach (self::SUPPORTED_EXPRESSIONS as $type => $supported) { + foreach ($choices as $expression) { + $frontend = new HAProxyFrontend( + name: "nested_acl_validation_$type", + type: $type, + ha_acls: [['name' => 'test_acl', 'expression' => $expression, 'value' => '']], + ); + + if (in_array($expression, $supported, true)) { + $this->assert_is_true($frontend->validate()); + $this->assert_equals($frontend->ha_acls->value[0]['expression'], $expression); + } else { + $this->assert_throws_response( + response_id: 'HAPROXY_FRONTEND_ACL_EXPRESSION_TYPE_MISMATCH', + code: 400, + callable: fn() => $frontend->validate(), + ); + } + } + } + } + + /** + * Ensures incompatible expressions cannot be created or written over an existing ACL. + */ + public function test_reject_incompatible_writes(): void { + $frontend = new HAProxyFrontend(name: 'acl_write_validation', type: 'http'); + $frontend->create(); + $acl = new HAProxyFrontendACL( + parent_id: $frontend->id, + name: 'test_acl', + expression: 'traffic_is_ssl', + value: '', + ); + + $this->assert_throws_response( + response_id: 'HAPROXY_FRONTEND_ACL_EXPRESSION_TYPE_MISMATCH', + code: 400, + callable: fn() => $acl->create(), + ); + $this->assert_is_false(HAProxyFrontendACL::query(parent_id: $frontend->id)->exists()); + + $acl = new HAProxyFrontendACL( + parent_id: $frontend->id, + name: 'test_acl', + expression: 'host_matches', + value: 'example.com', + ); + $acl->create(); + $acl->expression->value = 'ssl_sni_matches'; + + $this->assert_throws_response( + response_id: 'HAPROXY_FRONTEND_ACL_EXPRESSION_TYPE_MISMATCH', + code: 400, + callable: fn() => $acl->update(), + ); + $this->assert_equals( + HAProxyFrontendACL::query(parent_id: $frontend->id, id: $acl->id)->first()->expression->value, + 'host_matches', + ); + } + + /** + * Ensures unknown expressions still fail the Field's choice validation. + */ + public function test_reject_unknown_expression(): void { + $frontend = new HAProxyFrontend(name: 'acl_unknown_validation', type: 'http'); + $frontend->create(); + $acl = new HAProxyFrontendACL( + parent_id: $frontend->id, + name: 'test_acl', + expression: 'unknown_expression', + value: '', + ); + + $this->assert_throws_response( + response_id: 'FIELD_INVALID_CHOICE', + code: 400, + callable: fn() => $acl->validate(), + ); + } +} From af53b6f8adb1febfa90098950758df1bc10aded7 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 24 Oct 2026 10:10:48 -0600 Subject: [PATCH 06/17] chore(HAProxyFrontendAddress): sort field arguments --- .../usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc index c8f75dfd..9f8a46c4 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc @@ -36,8 +36,8 @@ class HAProxyFrontendAddress extends Model { ); $this->extaddr_custom = new StringField( required: true, - conditions: ['extaddr' => 'custom'], verbose_name: 'Custom External Address', + conditions: ['extaddr' => 'custom'], help_text: 'The custom IPv4 or IPv6 address to use as the external address.', ); $this->extaddr_port = new PortField( From 2f8eddb01aa70f174fc086acfcbb3f6e90436740 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 24 Oct 2026 10:38:47 -0600 Subject: [PATCH 07/17] feat(HAProxyFrontendAddress): allow interface references as extaddr value #947 --- .../RESTAPI/Fields/InterfaceAddressField.inc | 129 +++++++++++++++ .../RESTAPI/Models/HAProxyFrontendAddress.inc | 22 ++- ...APIFieldsInterfaceAddressFieldTestCase.inc | 154 ++++++++++++++++++ ...PIModelsHAProxyFrontendAddressTestCase.inc | 78 +++++++++ 4 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceAddressField.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendAddressTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceAddressField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceAddressField.inc new file mode 100644 index 00000000..42ae7ccd --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceAddressField.inc @@ -0,0 +1,129 @@ + $description) { + if (in_array($interface, $this->allow_custom, true)) { + continue; + } + + $choices[$interface . '_ipv4'] = "$description (IPv4)"; + $choices[$interface . '_ipv6'] = "$description (IPv6)"; + } + + foreach ($this->allow_custom as $keyword) { + $choices[$keyword] = $keyword; + } + + if ($this->allow_empty) { + $choices[''] = 'None'; + } + + return $choices; + } + + /** + * Converts the represented interface to its internal ID, retaining the address family suffix. + * @param mixed $representation_value The represented interface address or literal value. + * @return string|null The value suitable for storing in configuration. + */ + public function _to_internal(mixed $representation_value): string|null { + $interface = $this->get_interface_name($representation_value); + if ($interface === null) { + return $representation_value; + } + + return parent::_to_internal($interface) . substr($representation_value, -5); + } + + /** + * Represents an internal interface ID according to the API settings, retaining its suffix. + * @param mixed $internal_value The configured interface address or literal value. + * @return string The represented value. + */ + public function _from_internal(mixed $internal_value): string { + $interface = $this->get_interface_name($internal_value); + if ($interface === null) { + return $internal_value; + } + + return parent::_from_internal($interface) . substr($internal_value, -5); + } + + /** + * Obtains the interface referenced by a single address for HATEOAS discovery. + * @return Model|null The referenced interface, or null for literal or unset values. + */ + public function get_related_model(): Model|null { + return $this->many ? null : $this->get_interface_model($this->value); + } + + /** + * Obtains the interfaces referenced by a many-valued field for HATEOAS discovery. + * @return ModelSet The referenced interfaces, excluding literal values. + */ + public function get_related_models(): ModelSet { + $models = new ModelSet(model_objects: []); + foreach ($this->many ? $this->value ?? [] : [] as $value) { + $interface = $this->get_interface_model($value); + if ($interface !== null) { + $models->model_objects[] = $interface; + } + } + + return $models; + } + + /** + * Removes only a trailing address family suffix, leaving allowed literal values untouched. + * @param mixed $value The internal or represented value. + * @return string|null The interface name without its suffix, or null for non-reference values. + */ + private function get_interface_name(mixed $value): string|null { + if (!is_string($value) or in_array($value, $this->allow_custom, true)) { + return null; + } + + if (str_ends_with($value, '_ipv4') or str_ends_with($value, '_ipv6')) { + return substr($value, 0, -5); + } + + return null; + } + + /** + * Looks up an assigned interface using a represented address reference. + * @param mixed $value The represented address reference. + * @return Model|null The referenced interface, or null when no interface matches. + */ + private function get_interface_model(mixed $value): Model|null { + $interface = $this->get_interface_name($value); + if ($interface === null) { + return null; + } + + $query = NetworkInterface::query( + query_params: array_merge($this->interface_query, [$this->represented_as => $interface]), + ); + return $query->exists() ? $query->first() : null; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc index 9f8a46c4..bbec4866 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc @@ -2,9 +2,12 @@ namespace RESTAPI\Models; +require_once 'RESTAPI/autoloader.inc'; + use RESTAPI\Core\Model; use RESTAPI\Dispatchers\HAProxyApplyDispatcher; use RESTAPI\Fields\BooleanField; +use RESTAPI\Fields\InterfaceAddressField; use RESTAPI\Fields\PortField; use RESTAPI\Fields\StringField; @@ -12,13 +15,20 @@ use RESTAPI\Fields\StringField; * Defines a Model for HAProxy Frontend Addresses. */ class HAProxyFrontendAddress extends Model { - public StringField $extaddr; + public InterfaceAddressField $extaddr; public StringField $extaddr_custom; public PortField $extaddr_port; public BooleanField $extaddr_ssl; public StringField $exaddr_advanced; - public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], ...$options) { + /** + * Defines the HAProxy frontend address fields and configuration location. + * @param mixed $id The address ID. + * @param mixed $parent_id The frontend ID. + * @param mixed $data The address data. + * @param mixed ...$options Additional Model options. + */ + public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { # Set model attributes $this->parent_model_class = 'HAProxyFrontend'; $this->config_path = 'a_extaddr/item'; @@ -28,11 +38,13 @@ class HAProxyFrontendAddress extends Model { $this->verbose_name = 'HAProxy Frontend Address'; # Set model fields - $this->extaddr = new StringField( + $this->extaddr = new InterfaceAddressField( required: true, - choices: ['custom', 'any_ipv4', 'any_ipv6', 'localhost_ipv4', 'localhost_ipv6'], + allow_custom: ['custom', 'any_ipv4', 'any_ipv6', 'localhost_ipv4', 'localhost_ipv6'], verbose_name: 'External Address', - help_text: 'The external address to use.', + help_text: 'The external address to use. Interface references must match the format set as the REST APIs ' . + 'interface representation format in the REST API settings, and must end with `_ipv4` or `_ipv6` to ' . + 'select the address family.', ); $this->extaddr_custom = new StringField( required: true, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc new file mode 100644 index 00000000..b193beb9 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc @@ -0,0 +1,154 @@ +represent_interfaces_as->value = $mode; + $settings->update(); + + foreach (['wan', 'lan', 'opt1'] as $id) { + $interface = NetworkInterface::query(query_params: ['id' => $id])->first(); + $represented = $mode === 'id' ? $id : $interface->$mode->value; + foreach (['_ipv4', '_ipv6'] as $suffix) { + $field = $this->make_field(); + $field->value = $represented . $suffix; + $this->assert_is_true(in_array($field->value, $field->choices, true)); + $this->assert_is_true($field->validate()); + $this->assert_equals($field->to_internal(), $id . $suffix); + $field->from_internal($id . $suffix); + $this->assert_equals($field->value, $represented . $suffix); + $this->assert_equals($field->get_related_model()->id, $id); + $this->assert_is_empty($field->get_related_models()->model_objects); + } + } + } + } + + /** + * Checks that literal values are preserved and never identify related interfaces. + */ + public function test_static_choices(): void { + $settings = new RESTAPISettings(); + foreach (['id', 'if', 'descr'] as $mode) { + $settings->represent_interfaces_as->value = $mode; + $settings->update(); + $keywords = ['custom', 'any_ipv4', 'any_ipv6', 'localhost_ipv4', 'localhost_ipv6', 'WAN_ipv4']; + $field = $this->make_field(allow_custom: $keywords); + foreach ($keywords as $keyword) { + $field->value = $keyword; + $this->assert_is_true($field->validate()); + $this->assert_equals($field->to_internal(), $keyword); + $field->from_internal($keyword); + $this->assert_equals($field->value, $keyword); + $this->assert_equals($field->get_related_model(), null); + $this->assert_is_false(in_array($keyword . '_ipv4', $field->choices, true)); + } + } + } + + /** + * Checks that bare, unknown, and malformed interface references are rejected. + */ + public function test_invalid_references(): void { + $settings = new RESTAPISettings(); + $settings->represent_interfaces_as->value = 'descr'; + $settings->update(); + $field = $this->make_field(); + + foreach (['WAN', 'wan_ipv4', 'missing_ipv6', 'WAN_ipv5', 'WAN:ip', 'WAN_ipv4_extra', 'any_ipv4'] as $value) { + $field->value = $value; + $this->assert_throws_response( + response_id: 'FIELD_INVALID_CHOICE', + code: 400, + callable: fn() => $field->validate(), + ); + } + } + + /** + * Checks that interface queries constrain both address families and related models. + */ + public function test_interface_query(): void { + $settings = new RESTAPISettings(); + $settings->represent_interfaces_as->value = 'id'; + $settings->update(); + $field = $this->make_field(interface_query: ['id' => 'lan']); + $this->assert_equals(array_keys($field->get_interface_choices()), ['lan_ipv4', 'lan_ipv6']); + $field->value = 'wan_ipv4'; + $this->assert_equals($field->get_related_model(), null); + $this->assert_throws_response( + response_id: 'FIELD_INVALID_CHOICE', + code: 400, + callable: fn() => $field->validate(), + ); + } + + /** + * Checks many-valued conversion and HATEOAS discovery without treating static values as interfaces. + */ + public function test_many_values(): void { + $settings = new RESTAPISettings(); + $settings->represent_interfaces_as->value = 'descr'; + $settings->update(); + + foreach ([',', null] as $delimiter) { + $field = $this->make_field(many: true, delimiter: $delimiter, allow_custom: ['any_ipv4']); + $field->value = ['WAN_ipv4', 'LAN_ipv6', 'any_ipv4']; + $internal = ['wan_ipv4', 'lan_ipv6', 'any_ipv4']; + $internal = $delimiter === null ? $internal : implode($delimiter, $internal); + $this->assert_is_true($field->validate()); + $this->assert_equals($field->to_internal(), $internal); + $field->from_internal($internal); + $this->assert_equals($field->value, ['WAN_ipv4', 'LAN_ipv6', 'any_ipv4']); + $this->assert_equals($field->get_related_model(), null); + $related = $field->get_related_models()->model_objects; + $this->assert_equals(count($related), 2); + $this->assert_equals($related[0]->id, 'wan'); + $this->assert_equals($related[1]->id, 'lan'); + } + } + + /** + * Checks that optional null and empty values do not create interface references. + */ + public function test_optional_values(): void { + $field = $this->make_field(allow_null: true, allow_empty: true); + foreach ([null, ''] as $value) { + $field->value = $value; + $this->assert_is_true($field->validate()); + $this->assert_equals($field->to_internal(), $value); + $field->from_internal($value); + $this->assert_equals($field->value, $value); + $this->assert_equals($field->get_related_model(), null); + } + } + + /** + * Constructs a field with the context and dynamic choices required for validation. + * @param mixed ...$options Additional field options. + * @return InterfaceAddressField The field to test. + */ + private function make_field(mixed ...$options): InterfaceAddressField { + $field = new InterfaceAddressField(...$options); + $field->name = 'extaddr'; + $field->context = new RESTAPISettings(); + $field->set_choices_from_callable(); + return $field; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendAddressTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendAddressTestCase.inc new file mode 100644 index 00000000..ba17cfc5 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHAProxyFrontendAddressTestCase.inc @@ -0,0 +1,78 @@ +represent_interfaces_as->value = $mode; + $settings->update(); + $interface = NetworkInterface::query(query_params: ['id' => 'wan'])->first(); + $represented = $mode === 'id' ? 'wan' : $interface->$mode->value; + $frontend = new HAProxyFrontend( + name: 'test_interface_addresses_' . $mode, + type: 'http', + a_extaddr: [['extaddr' => $represented . '_ipv4', 'extaddr_port' => '8080']], + ); + $frontend->create(); + + try { + $address = HAProxyFrontendAddress::query(parent_id: $frontend->id, id: 0)->first(); + $this->assert_equals($address->extaddr->value, $represented . '_ipv4'); + $stored = Model::get_config($frontend->get_config_path() . '/' . $frontend->id); + $this->assert_equals($stored['a_extaddr']['item'][0]['extaddr'], 'wan_ipv4'); + $links = $address->get_related_links()->to_representation()['_links']; + $link = $links[HAL_REL_NAMESPACE . ':field:extaddr']; + $this->assert_equals($link['id'], 'wan'); + $this->assert_str_contains($link['href'], '/api/v2/interface?id=wan'); + + $address->extaddr->value = $represented . '_ipv6'; + $address->update(); + $address = HAProxyFrontendAddress::query(parent_id: $frontend->id, id: 0)->first(); + $this->assert_equals($address->extaddr->value, $represented . '_ipv6'); + $this->assert_equals($address->to_internal()['extaddr'], 'wan_ipv6'); + + foreach (['custom', 'any_ipv4', 'any_ipv6', 'localhost_ipv4', 'localhost_ipv6'] as $keyword) { + $address->extaddr->value = $keyword; + $address->extaddr_custom->value = '127.0.0.1'; + $address->update(); + $address = HAProxyFrontendAddress::query(parent_id: $frontend->id, id: 0)->first(); + $this->assert_equals($address->extaddr->value, $keyword); + $this->assert_equals($address->to_internal()['extaddr'], $keyword); + $links = $address->get_related_links()->to_representation()['_links']; + $this->assert_is_false(array_key_exists(HAL_REL_NAMESPACE . ':field:extaddr', $links)); + } + } finally { + $frontend->delete(); + } + } + } finally { + if (!$was_dirty and file_exists($dirty_file)) { + unlink($dirty_file); + } + } + } +} From b5d86067d4f88594adf65ef813a365f4d08099ae Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 24 Oct 2026 12:32:21 -0600 Subject: [PATCH 08/17] feat(Field): allow unique validation to be skipped There is a conflict in some cases where some fields depend on unique as a descriptor and toggle for support ForeignModelField targets, but the field doesn't actually need to validate its uniqueness (e.g. Service actions). This commit allows those cases to opt into skipping unique validation. --- .../usr/local/pkg/RESTAPI/Core/Field.inc | 8 +- .../local/pkg/RESTAPI/Fields/Base64Field.inc | 3 + .../local/pkg/RESTAPI/Fields/BooleanField.inc | 3 + .../pkg/RESTAPI/Fields/DateTimeField.inc | 3 + .../pkg/RESTAPI/Fields/FilterAddressField.inc | 3 + .../local/pkg/RESTAPI/Fields/FloatField.inc | 3 + .../pkg/RESTAPI/Fields/ForeignModelField.inc | 3 + .../local/pkg/RESTAPI/Fields/IntegerField.inc | 3 + .../pkg/RESTAPI/Fields/InterfaceField.inc | 3 + .../local/pkg/RESTAPI/Fields/KeyLenField.inc | 3 + .../local/pkg/RESTAPI/Fields/ObjectField.inc | 3 + .../local/pkg/RESTAPI/Fields/PortField.inc | 3 + .../RESTAPI/Fields/SpecialNetworkField.inc | 3 + .../local/pkg/RESTAPI/Fields/StringField.inc | 3 + .../pkg/RESTAPI/Fields/UnixTimeField.inc | 3 + .../RESTAPI/Tests/APICoreFieldTestCase.inc | 77 +++++++++++++++++++ 16 files changed, 125 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc index 4914f279..46f38741 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc @@ -83,6 +83,9 @@ class Field { * Make this a required field. If false, a valid $default, $default_callable or $allow_null must be set. * @param bool $unique * If true, $value must be unique among all parent Model objects (only for parent Models with $many set to true). + * @param bool $skip_unique_validation + * If true, fields marked as unique will skip enforcement of the unique constraint. This is necessary for + * fields that use $unique for schema or indexing purposes, but do not need the constraint enforced. * @param mixed $default * The default $value for this field if none is assigned. The default specified here should be static. If the * default for this field is dynamic in nature, use a $default_callable instead. @@ -136,6 +139,7 @@ class Field { public string $type = '', public bool $required = false, public bool $unique = false, + public bool $skip_unique_validation = false, public mixed $default = null, public string $default_callable = '', public array $choices = [], @@ -725,8 +729,8 @@ class Field { * @throws ServerError When $unique is enabled, but the parent model $context does not have $many enabled */ private function check_field_unique(mixed $value, ?ModelSet $modelset = null): void { - # Only check this if `unique` is enabled - if ($this->unique) { + # Only check this if `unique` is enabled and the unique constraint is enforced + if ($this->unique and !$this->skip_unique_validation) { # Throw an error if unique is set but the model context is not a 'many' model. if (!$this->context->many) { throw new ServerError( diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc index b36fa094..9fca443b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/Base64Field.inc @@ -18,6 +18,7 @@ class Base64Field extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -72,6 +73,7 @@ class Base64Field extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -101,6 +103,7 @@ class Base64Field extends Field { type: 'string', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/BooleanField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/BooleanField.inc index 05393d06..b6eef564 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/BooleanField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/BooleanField.inc @@ -27,6 +27,7 @@ class BooleanField extends RESTAPI\Core\Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -70,6 +71,7 @@ class BooleanField extends RESTAPI\Core\Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', bool $allow_empty = false, @@ -95,6 +97,7 @@ class BooleanField extends RESTAPI\Core\Field { type: 'boolean', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, allow_empty: $allow_empty, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc index b0a992db..c74634d8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/DateTimeField.inc @@ -19,6 +19,7 @@ class DateTimeField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -78,6 +79,7 @@ class DateTimeField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -117,6 +119,7 @@ class DateTimeField extends Field { type: 'string', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc index ecaa3ccc..3ea8647e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FilterAddressField.inc @@ -22,6 +22,7 @@ class FilterAddressField extends InterfaceField { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -90,6 +91,7 @@ class FilterAddressField extends InterfaceField { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', public bool $allow_ipaddr = true, @@ -128,6 +130,7 @@ class FilterAddressField extends InterfaceField { parent::__construct( required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, interface_query: $interface_query, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc index 234323be..1ccca327 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/FloatField.inc @@ -16,6 +16,7 @@ class FloatField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -70,6 +71,7 @@ class FloatField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -98,6 +100,7 @@ class FloatField extends Field { type: 'double', // gettype() identifies floats as doubles required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc index 9a2920e8..c1869c7d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc @@ -47,6 +47,7 @@ class ForeignModelField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -106,6 +107,7 @@ class ForeignModelField extends Field { public array $allowed_keywords = [], bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -144,6 +146,7 @@ class ForeignModelField extends Field { type: $model_field === 'id' ? 'integer' : $this->models[0]->$model_field->type, required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc index 538887dd..43ba0442 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc @@ -16,6 +16,7 @@ class IntegerField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -70,6 +71,7 @@ class IntegerField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -98,6 +100,7 @@ class IntegerField extends Field { type: 'integer', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc index 27d941ab..eacfbe85 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/InterfaceField.inc @@ -28,6 +28,7 @@ class InterfaceField extends StringField { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -81,6 +82,7 @@ class InterfaceField extends StringField { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', public array $interface_query = [], @@ -115,6 +117,7 @@ class InterfaceField extends StringField { parent::__construct( required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices_callable: 'get_interface_choices', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc index 67ff48ea..861f5592 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/KeyLenField.inc @@ -17,6 +17,7 @@ class KeyLenField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -71,6 +72,7 @@ class KeyLenField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -99,6 +101,7 @@ class KeyLenField extends Field { type: 'integer', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc index a88a52f2..e7602331 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc @@ -21,6 +21,7 @@ class ObjectField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -77,6 +78,7 @@ class ObjectField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -107,6 +109,7 @@ class ObjectField extends Field { type: 'array', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc index afed1b7a..9f142b4d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/PortField.inc @@ -25,6 +25,7 @@ class PortField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param array $choices An array of value choices this Field can be assigned. This can either be an indexed array * of the exact choice values, or an associative array where the array key is the exact choice value and the array @@ -80,6 +81,7 @@ class PortField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, array $choices = [], string $choices_callable = '', @@ -115,6 +117,7 @@ class PortField extends Field { type: 'string', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, choices: $choices, choices_callable: $choices_callable, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc index f3f76b84..5729d183 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/SpecialNetworkField.inc @@ -22,6 +22,7 @@ class SpecialNetworkField extends InterfaceField { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * execute to populate choices for this field. This callable must be a method assigned on this Field object OR the * parent Model object that returns an array of valid choices in the same format as $choices. This is helpful when @@ -84,6 +85,7 @@ class SpecialNetworkField extends InterfaceField { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, public bool $allow_ipaddr = true, public bool $allow_subnet = true, @@ -119,6 +121,7 @@ class SpecialNetworkField extends InterfaceField { parent::__construct( required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, interface_query: $interface_query, allow_interface_groups: $allow_interface_groups, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc index 64ebf30b..e5488dd2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/StringField.inc @@ -16,6 +16,7 @@ class StringField extends Field { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -72,6 +73,7 @@ class StringField extends Field { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -102,6 +104,7 @@ class StringField extends Field { type: 'string', required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc index 1b3a23da..8d8d1524 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc @@ -17,6 +17,7 @@ class UnixTimeField extends IntegerField { * @param bool $required If `true`, this field is required to have a value at all times. * @param bool $unique If `true`, this field must be unique from all other parent model objects. Enabling this * option requires the Model $context to be set AND the Model $context must have a `config_path` set. + * @param bool $skip_unique_validation If `true`, skip uniqueness enforcement while retaining the `unique` metadata. * @param mixed|null $default Assign a default string value to assign this Field if no value is present. * @param string $default_callable Defines a callable method that should be called to populate the default value * for this field. It is strongly encouraged to use a default callable when the default is variable and may change @@ -75,6 +76,7 @@ class UnixTimeField extends IntegerField { public function __construct( bool $required = false, bool $unique = false, + bool $skip_unique_validation = false, mixed $default = null, string $default_callable = '', array $choices = [], @@ -113,6 +115,7 @@ class UnixTimeField extends IntegerField { parent::__construct( required: $required, unique: $unique, + skip_unique_validation: $skip_unique_validation, default: $default, default_callable: $default_callable, choices: $choices, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreFieldTestCase.inc index f21cbb48..59866e54 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreFieldTestCase.inc @@ -2,8 +2,14 @@ namespace RESTAPI\Tests; +require_once 'RESTAPI/autoloader.inc'; + +use ReflectionClass; use RESTAPI; use RESTAPI\Core\Field; +use RESTAPI\Core\ModelSet; +use RESTAPI\Fields\ForeignModelField; +use RESTAPI\Fields\StringField; /** * Defines test case for testing RESTAPI\Core\Field. @@ -152,6 +158,77 @@ class APICoreFieldTestCase extends RESTAPI\Core\TestCase { $this->assert_equals($test_field->internal_name, $internal_name); } + /** + * Checks that every Field subclass supporting unique also forwards skip_unique_validation with a false default. + */ + public function test_subclass_skip_unique_validation_argument(): void { + foreach (glob(__DIR__ . '/../Fields/*.inc') as $file) { + $class = 'RESTAPI\\Fields\\' . basename($file, '.inc'); + $reflection = new ReflectionClass($class); + $parameters = []; + foreach ($reflection->getConstructor()->getParameters() as $parameter) { + $parameters[$parameter->getName()] = $parameter; + } + + if (!isset($parameters['unique'])) { + continue; + } + + $this->assert_is_true(isset($parameters['skip_unique_validation'])); + $this->assert_equals($parameters['skip_unique_validation']->getDefaultValue(), false); + $this->assert_equals((string) $parameters['skip_unique_validation']->getType(), 'bool'); + $options = + $class === ForeignModelField::class + ? ['model_name' => 'Test', 'model_field' => 'test_string_unique'] + : []; + + foreach ([false, true] as $unique) { + $options['unique'] = $unique; + unset($options['skip_unique_validation']); + $field = new $class(...$options); + $this->assert_equals($field->unique, $unique); + $this->assert_is_false($field->skip_unique_validation); + + foreach ([false, true] as $skip_unique_validation) { + $options['skip_unique_validation'] = $skip_unique_validation; + $field = new $class(...$options); + $this->assert_equals($field->unique, $unique); + $this->assert_equals($field->skip_unique_validation, $skip_unique_validation); + } + } + } + } + + /** + * Checks that skipping uniqueness permits duplicates without removing metadata or bypassing type validation. + */ + public function test_skip_unique_validation(): void { + $existing = new RESTAPI\Models\Test(test_string_unique: 'duplicate'); + $existing->id = 0; + $modelset = new ModelSet(model_objects: [$existing]); + $field = new StringField(required: true, unique: true, skip_unique_validation: true); + $field->set_names('test_string_unique'); + $field->context = new RESTAPI\Models\Test(); + $field->value = 'duplicate'; + $this->assert_is_true($field->validate(modelset: $modelset)); + $this->assert_is_true($field->unique); + + $field->skip_unique_validation = false; + $this->assert_throws_response( + response_id: 'FIELD_MUST_BE_UNIQUE', + code: 400, + callable: fn() => $field->validate(modelset: $modelset), + ); + + $field->skip_unique_validation = true; + $field->value = 123; + $this->assert_throws_response( + response_id: 'FIELD_INVALID_TYPE', + code: 400, + callable: fn() => $field->validate(modelset: $modelset), + ); + } + /** * Checks that a unique Field is required to have a parent Model context assigned. */ From b31bf96ea69c89eb0bf34a47a6d05f8bf8ef0aac Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 24 Oct 2026 12:34:47 -0600 Subject: [PATCH 09/17] test(InterfaceAddressField): ensure we test with fields required --- .../RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc index b193beb9..62b338d0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc @@ -26,7 +26,7 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { $interface = NetworkInterface::query(query_params: ['id' => $id])->first(); $represented = $mode === 'id' ? $id : $interface->$mode->value; foreach (['_ipv4', '_ipv6'] as $suffix) { - $field = $this->make_field(); + $field = $this->make_field(required: true); $field->value = $represented . $suffix; $this->assert_is_true(in_array($field->value, $field->choices, true)); $this->assert_is_true($field->validate()); From b7bab4faa0f728033b818eb53b986e388f612f3d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 24 Oct 2026 12:36:19 -0600 Subject: [PATCH 10/17] chore(Service): restore unique arg, but skip its validation --- .../files/usr/local/pkg/RESTAPI/Models/Service.inc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc index 64b1ba0d..19b64ad3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Service.inc @@ -33,6 +33,8 @@ class Service extends Model { ); $this->name = new StringField( required: true, + unique: true, + skip_unique_validation: true, # We aren't creating new services, just actioning. No need to validate. verbose_name: 'Name', help_text: 'The internal name of the service.', ); From 4bf9f3b13437283bf4b8e69ea5c86becf5c0fa8e Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 25 Oct 2026 15:07:52 -0600 Subject: [PATCH 11/17] test(InterfaceField): don't test against opt1 This interface isn't created by default so we shouldn't assume it's present during this test. --- .../RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc index 62b338d0..cd10a6ce 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc @@ -22,7 +22,7 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { $settings->represent_interfaces_as->value = $mode; $settings->update(); - foreach (['wan', 'lan', 'opt1'] as $id) { + foreach (['wan', 'lan'] as $id) { $interface = NetworkInterface::query(query_params: ['id' => $id])->first(); $represented = $mode === 'id' ? $id : $interface->$mode->value; foreach (['_ipv4', '_ipv6'] as $suffix) { From 8d54ebe3780159ac7207a602254ddbfc569a3281 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 27 Oct 2026 09:41:10 -0600 Subject: [PATCH 12/17] test(InterfaceField): ensure test fields are required to avoid errors --- .../Tests/APIFieldsInterfaceAddressFieldTestCase.inc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc index cd10a6ce..15f41a5b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc @@ -49,7 +49,7 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { $settings->represent_interfaces_as->value = $mode; $settings->update(); $keywords = ['custom', 'any_ipv4', 'any_ipv6', 'localhost_ipv4', 'localhost_ipv6', 'WAN_ipv4']; - $field = $this->make_field(allow_custom: $keywords); + $field = $this->make_field(required: true, allow_custom: $keywords); foreach ($keywords as $keyword) { $field->value = $keyword; $this->assert_is_true($field->validate()); @@ -69,7 +69,7 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { $settings = new RESTAPISettings(); $settings->represent_interfaces_as->value = 'descr'; $settings->update(); - $field = $this->make_field(); + $field = $this->make_field(required: true); foreach (['WAN', 'wan_ipv4', 'missing_ipv6', 'WAN_ipv5', 'WAN:ip', 'WAN_ipv4_extra', 'any_ipv4'] as $value) { $field->value = $value; @@ -88,7 +88,7 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { $settings = new RESTAPISettings(); $settings->represent_interfaces_as->value = 'id'; $settings->update(); - $field = $this->make_field(interface_query: ['id' => 'lan']); + $field = $this->make_field(required: true, interface_query: ['id' => 'lan']); $this->assert_equals(array_keys($field->get_interface_choices()), ['lan_ipv4', 'lan_ipv6']); $field->value = 'wan_ipv4'; $this->assert_equals($field->get_related_model(), null); @@ -108,7 +108,7 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { $settings->update(); foreach ([',', null] as $delimiter) { - $field = $this->make_field(many: true, delimiter: $delimiter, allow_custom: ['any_ipv4']); + $field = $this->make_field(required: true, many: true, delimiter: $delimiter, allow_custom: ['any_ipv4']); $field->value = ['WAN_ipv4', 'LAN_ipv6', 'any_ipv4']; $internal = ['wan_ipv4', 'lan_ipv6', 'any_ipv4']; $internal = $delimiter === null ? $internal : implode($delimiter, $internal); From e938d44f7eb51d20e2d37fd1cc86e1aeea98e61b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 29 Oct 2026 09:04:33 -0600 Subject: [PATCH 13/17] test(InterfaceField): remove unnecessary interface query test --- .../APIFieldsInterfaceAddressFieldTestCase.inc | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc index 15f41a5b..59509645 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc @@ -81,24 +81,6 @@ class APIFieldsInterfaceAddressFieldTestCase extends TestCase { } } - /** - * Checks that interface queries constrain both address families and related models. - */ - public function test_interface_query(): void { - $settings = new RESTAPISettings(); - $settings->represent_interfaces_as->value = 'id'; - $settings->update(); - $field = $this->make_field(required: true, interface_query: ['id' => 'lan']); - $this->assert_equals(array_keys($field->get_interface_choices()), ['lan_ipv4', 'lan_ipv6']); - $field->value = 'wan_ipv4'; - $this->assert_equals($field->get_related_model(), null); - $this->assert_throws_response( - response_id: 'FIELD_INVALID_CHOICE', - code: 400, - callable: fn() => $field->validate(), - ); - } - /** * Checks many-valued conversion and HATEOAS discovery without treating static values as interfaces. */ From 1be33c7468cb53d9b334728e6f363d152f7b88b4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 29 Oct 2026 11:40:08 -0600 Subject: [PATCH 14/17] fix(FirewallRule): randomize tracker during replace_all calls Previously, each firewall rule would set it's tracker value to the current timestamp. However, replace_all was fast enough that multiple rules could obtain the same timestamp/tracker. This could break some of the state tracking features in pfSense. This commit ensures trackers are fully randomized during replace_all, and also allows clients to set the tracker if they want to retain existing tracker IDs. --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 14 ++ .../local/pkg/RESTAPI/Models/FirewallRule.inc | 37 ++++- .../Tests/APIModelsFirewallRuleTestCase.inc | 132 ++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) 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 fae9e82e..5b46fe27 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 @@ -1870,6 +1870,17 @@ class Model { $this->apply(); } + /** + * Prepares representation data before validating a `replace_all()` request, including dry runs. + * Override this method to supply operation-specific defaults without changing other CRUD actions. + * @param array $data The requested Model representations. + * @param ModelSet $initial_objects The existing Model objects before replacement. + * @return array The prepared Model representations to validate and replace. + */ + protected function pre_validate_replace_all(array $data, ModelSet $initial_objects): array { + return $data; + } + /** * Performs steps and processes required to apply changes after the `replace_all()` method runs. This can be * overridden to add custom steps to apply after replace all actions only. Defaults to simply calling `apply()`. @@ -2446,6 +2457,9 @@ class Model { # if needed. $initial_objects = $this->query(parent_id: $this->parent_id); + # Prepare operation-specific defaults before validation or any configuration changes + $data = $this->pre_validate_replace_all($data, $initial_objects); + # Obtain any Models that are deemed protected to ensure they do not removed in the next step. $new_objects = new ModelSet(); if ($this->protected_model_query) { 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 537b6dac..90f4653f 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 @@ -7,6 +7,7 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI; use RESTAPI\Core\Auth; use RESTAPI\Core\Model; +use RESTAPI\Core\ModelSet; use RESTAPI\Dispatchers\FirewallApplyDispatcher; use RESTAPI\Fields\BooleanField; use RESTAPI\Fields\FilterAddressField; @@ -334,11 +335,10 @@ class FirewallRule extends Model { 'floating firewall rules.', ); $this->tracker = new UnixTimeField( - editable: false, - read_only: true, auto_add_now: true, verbose_name: 'Tracker', - help_text: 'The internal tracking ID for this firewall rule.', + help_text: 'The internal tracking ID for this firewall rule. Defaults to the current Unix timestamp ' . + 'for single-rule creation, or a unique random numeric value when replacing all rules.', ); $this->associated_rule_id = new StringField( unique: true, @@ -389,6 +389,37 @@ class FirewallRule extends Model { parent::__construct($id, $parent_id, $data, ...$options); } + /** + * Supplies unique random trackers for bulk replacement rules that omit their tracker. + * @param array $data The requested firewall rule representations. + * @param ModelSet $initial_objects The firewall rules present before replacement. + * @return array The representations with missing trackers populated. + */ + protected function pre_validate_replace_all(array $data, ModelSet $initial_objects): array { + # Reserve supplied trackers up front, including those on rules later in the request + $used_trackers = array_column($data, 'tracker'); + foreach ($initial_objects->model_objects as $rule) { + $used_trackers[] = $rule->tracker->value; + } + + foreach ($data as $index => $rule) { + # Leave explicit values and malformed representations to the normal validation flow + if (!is_array($rule) or array_key_exists('tracker', $rule)) { + continue; + } + + # Use timestamp-sized IDs within the signed 32-bit range and retry any collision + do { + $tracker = random_int(1000000000, 2147483647); + } while (in_array($tracker, $used_trackers)); + + $data[$index]['tracker'] = $tracker; + $used_trackers[] = $tracker; + } + + return $data; + } + /** * Adds extra validation to the `interface` field. * @param string $interface The incoming value to be validated. diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 214b7f13..9f1b9a6e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -2,6 +2,8 @@ namespace RESTAPI\Tests; +require_once 'RESTAPI/autoloader.inc'; + use RESTAPI\Core\Auth; use RESTAPI\Core\Command; use RESTAPI\Core\TestCase; @@ -1021,6 +1023,136 @@ class APIModelsFirewallRuleTestCase extends TestCase { } } + /** + * Checks that bulk replacements generate unique timestamp-sized trackers and persist them unchanged. + */ + public function test_replace_all_randomizes_trackers(): void { + $data = array_fill(0, 100, [ + 'type' => 'pass', + 'interface' => ['lan'], + 'ipprotocol' => 'inet', + 'source' => 'any', + 'destination' => 'any', + ]); + $rule_model = new FirewallRule(); + + # Repeat the same replacement to ensure IDs are not reused from the previous ruleset + foreach (range(1, 2) as $attempt) { + $initial_trackers = array_column(FirewallRule::read_all()->to_representation(), 'tracker'); + $rules = $rule_model->replace_all(data: $data); + $trackers = array_column($rules->to_representation(), 'tracker'); + + $this->assert_equals(count($trackers), 100); + $this->assert_equals(count(array_unique($trackers)), 100); + $this->assert_is_empty(array_intersect($initial_trackers, $trackers)); + foreach ($trackers as $tracker) { + $this->assert_type($tracker, 'integer'); + $this->assert_is_greater_than_or_equal($tracker, 1000000000); + $this->assert_is_less_than_or_equal($tracker, 2147483647); + } + $this->assert_equals(array_column(FirewallRule::read_all()->to_representation(), 'tracker'), $trackers); + } + } + + /** + * Checks that bulk replacement preserves supplied trackers alongside generated trackers. + */ + public function test_replace_all_preserves_supplied_trackers(): void { + $data = array_fill(0, 4, [ + 'type' => 'pass', + 'interface' => ['lan'], + 'ipprotocol' => 'inet', + 'source' => 'any', + 'destination' => 'any', + ]); + $data[1]['tracker'] = 1700000000; + $data[3]['tracker'] = 1700000001; + + $rules = (new FirewallRule())->replace_all(data: $data); + $trackers = array_column($rules->to_representation(), 'tracker'); + + $this->assert_equals($trackers[1], 1700000000); + $this->assert_equals($trackers[3], 1700000001); + $this->assert_equals(count(array_unique($trackers)), 4); + $this->assert_equals(array_column(FirewallRule::read_all()->to_representation(), 'tracker'), $trackers); + } + + /** + * Checks that dry-run bulk replacements generate trackers without changing the saved rules. + */ + public function test_replace_all_dry_run_randomizes_trackers(): void { + $initial_rules = FirewallRule::get_config('filter/rule'); + $data = array_fill(0, 20, [ + 'type' => 'pass', + 'interface' => ['lan'], + 'ipprotocol' => 'inet', + 'source' => 'any', + 'destination' => 'any', + ]); + $data[19]['tracker'] = 1700000000; + + $rules = (new FirewallRule(dry_run: true))->replace_all(data: $data); + $trackers = array_column($rules->to_representation(), 'tracker'); + + $this->assert_equals(count($trackers), 20); + $this->assert_equals(count(array_unique($trackers)), 20); + $this->assert_equals($trackers[19], 1700000000); + foreach ($trackers as $tracker) { + $this->assert_type($tracker, 'integer'); + $this->assert_is_greater_than_or_equal($tracker, 1000000000); + $this->assert_is_less_than_or_equal($tracker, 2147483647); + } + $this->assert_equals(FirewallRule::get_config('filter/rule'), $initial_rules); + } + + /** + * Checks that tracker values can be supplied on creation and edited afterward. + */ + public function test_tracker_is_editable(): void { + $rule = new FirewallRule( + data: [ + 'type' => 'pass', + 'interface' => ['lan'], + 'ipprotocol' => 'inet', + 'source' => 'any', + 'destination' => 'any', + 'tracker' => 1700000000, + ], + ); + $rule->create(); + $this->assert_equals((new FirewallRule(id: $rule->id))->tracker->value, 1700000000); + + $rule->from_representation(data: ['tracker' => 1700000001]); + $rule->update(); + $this->assert_equals((new FirewallRule(id: $rule->id))->tracker->value, 1700000001); + } + + /** + * Checks that single-rule creation retains the timestamp default and updates do not regenerate it. + */ + public function test_single_rule_tracker_defaults_to_timestamp(): void { + $before = time(); + $rule = new FirewallRule( + data: [ + 'type' => 'pass', + 'interface' => ['lan'], + 'ipprotocol' => 'inet', + 'source' => 'any', + 'destination' => 'any', + ], + ); + $rule->create(); + $tracker = $rule->tracker->value; + + $this->assert_is_greater_than_or_equal($tracker, $before); + $this->assert_is_less_than_or_equal($tracker, time()); + $this->assert_equals((new FirewallRule(id: $rule->id))->tracker->value, $tracker); + + $rule->from_representation(data: ['descr' => 'Updated without changing tracker']); + $rule->update(); + $this->assert_equals((new FirewallRule(id: $rule->id))->tracker->value, $tracker); + } + /** * Checks that we can create a floating firewall rule with the 'any' interface */ From 83928b94559996c589cc521ab0c5ac42690f97a0 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 30 Oct 2026 09:13:14 -0600 Subject: [PATCH 15/17] fix(FirewallRule): make 'tracker' required field --- .../files/usr/local/pkg/RESTAPI/Models/FirewallRule.inc | 1 + 1 file changed, 1 insertion(+) 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 90f4653f..b2d12517 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 @@ -335,6 +335,7 @@ class FirewallRule extends Model { 'floating firewall rules.', ); $this->tracker = new UnixTimeField( + required: true, auto_add_now: true, verbose_name: 'Tracker', help_text: 'The internal tracking ID for this firewall rule. Defaults to the current Unix timestamp ' . From 06be8cda9f440379b8c6c061a2d398365cabd57c Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 30 Oct 2026 09:29:38 -0600 Subject: [PATCH 16/17] feat(PrivilegesCache): add static privilege for /api/v2/documentation page #961 --- .../usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc index 095278d6..fb9d8dad 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc @@ -17,7 +17,13 @@ class PrivilegesCache extends Cache { */ public function get_data_to_cache(): array { # Use a variable to keep track of the privileges associated with this endpoint - $privs = []; + $privs = [ + "page-system-restapi-documentation" => [ + "name" => "WebCfg - System: RESTAPI Documentation", + "descr" => "Allow access to the /api/v2/documentation page.", + "match" => ["api/v2/documentation*"], + ] + ]; # Obtain privileges for each Endpoint class foreach (get_classes_from_namespace('\\RESTAPI\\Endpoints\\') as $endpoint_class) { From f5dc98a4459f13c2c4dd8313583c358af6bf3218 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 30 Oct 2026 09:29:55 -0600 Subject: [PATCH 17/17] style: run prettier on changed files --- .../usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc index fb9d8dad..fb25a6b3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/PrivilegesCache.inc @@ -18,11 +18,11 @@ class PrivilegesCache extends Cache { public function get_data_to_cache(): array { # Use a variable to keep track of the privileges associated with this endpoint $privs = [ - "page-system-restapi-documentation" => [ - "name" => "WebCfg - System: RESTAPI Documentation", - "descr" => "Allow access to the /api/v2/documentation page.", - "match" => ["api/v2/documentation*"], - ] + 'page-system-restapi-documentation' => [ + 'name' => 'WebCfg - System: RESTAPI Documentation', + 'descr' => 'Allow access to the /api/v2/documentation page.', + 'match' => ['api/v2/documentation*'], + ], ]; # Obtain privileges for each Endpoint class