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/ 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..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 @@ -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) { 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/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc index fae9e82e..d6a1ce75 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) { @@ -2456,21 +2470,28 @@ class Model { # do not mutate or persist the configuration. This still allows clients to detect duplicate or invalid # entries before applying the replacement. if ($this->dry_run) { - foreach ($data as $representation_object) { - unset($representation_object['id']); - $model_object = new $this(data: $representation_object, skip_init: true, client: $this->client); - # Assign a synthetic ID for uniqueness comparisons during validation - $model_object->id = count($new_objects->model_objects); - $model_object->validate(modelset: $new_objects); - $new_objects->model_objects[] = $model_object; + # Use the root signature so self-references resolve against the simulated replacement set + $new_objects = new ModelSet(model_objects: $new_objects->model_objects, signature: $this->get_class_fqn()); - # In the event that the model can reference itself (e.g. FirewallAlias), ensure the model cache is - # updated so ForeignModelField can accurately find referenced objects. - self::get_model_cache()::cache_modelset($new_objects); - } + try { + foreach ($data as $representation_object) { + # Discard stale queries and indexes before caching the growing replacement set + self::clear_model_cache(); + self::get_model_cache()::cache_modelset($new_objects); + + unset($representation_object['id']); + $model_object = new $this(data: $representation_object, skip_init: true, client: $this->client); + # Assign a synthetic ID for uniqueness comparisons during validation + $model_object->id = count($new_objects->model_objects); + $model_object->validate(modelset: $new_objects); + $new_objects->model_objects[] = $model_object; + } - self::clear_model_cache(); - return $new_objects; + return $new_objects; + } finally { + # Never retain simulated objects, including when validation throws an error + self::clear_model_cache(); + } } # Before beginning validation, empty the stored values in the config path. Retain protected Models (if any). 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/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/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/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, 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..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 @@ -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,11 @@ class FirewallRule extends Model { 'floating firewall rules.', ); $this->tracker = new UnixTimeField( - editable: false, - read_only: true, + required: 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 +390,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/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/Models/HAProxyFrontendAddress.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyFrontendAddress.inc index c8f75dfd..d6f50f89 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 StringField $extaddr_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,16 +38,18 @@ 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, - 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( @@ -55,7 +67,7 @@ class HAProxyFrontendAddress extends Model { verbose_name: 'External Address SSL', help_text: 'Enables or disables using SSL/TLS for this address.', ); - $this->exaddr_advanced = new StringField( + $this->extaddr_advanced = new StringField( default: '', allow_empty: true, verbose_name: 'Advanced External Address', 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", ); 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..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 @@ -34,6 +34,7 @@ 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.', ); 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. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc index 28c91a8a..a014ef0d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc @@ -2,6 +2,8 @@ namespace RESTAPI\Tests; +require_once 'RESTAPI/autoloader.inc'; + use ReflectionClass; use RESTAPI; use RESTAPI\Caches\RESTAPIVersionReleasesCache; @@ -1423,6 +1425,54 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { $this->assert_is_false(FirewallAlias::get_model_cache()::has_modelset(FirewallAlias::get_class_fqn())); } + /** + * Checks that dry-run replacement caches a signed simulated set for nested references without persisting it. + */ + public function test_replace_all_dry_run_cache_supports_self_references(): void { + $initial_config = FirewallAlias::get_config('aliases/alias'); + $initial_names = array_column(FirewallAlias::read_all()->to_representation(), 'name'); + $aliases = new FirewallAlias(dry_run: true); + $replacement = $aliases->replace_all( + data: [ + ['name' => 'PFREST_DRYRUN_BASE', 'type' => 'host', 'address' => ['127.0.0.1']], + ['name' => 'PFREST_DRYRUN_CHILD', 'type' => 'host', 'address' => ['PFREST_DRYRUN_BASE']], + ['name' => 'PFREST_DRYRUN_NESTED', 'type' => 'host', 'address' => ['PFREST_DRYRUN_CHILD']], + ], + ); + + $this->assert_equals($replacement->signature, FirewallAlias::get_class_fqn()); + $this->assert_equals($replacement->count(), 3); + $this->assert_equals($replacement->model_objects[2]->address->value, ['PFREST_DRYRUN_CHILD']); + $this->assert_is_false(FirewallAlias::get_model_cache()::has_modelset(FirewallAlias::get_class_fqn())); + $this->assert_equals(FirewallAlias::get_config('aliases/alias'), $initial_config); + $this->assert_equals(array_column(FirewallAlias::read_all()->to_representation(), 'name'), $initial_names); + } + + /** + * Checks that failed dry-run replacement does not leave partially validated objects in the cache. + */ + public function test_replace_all_dry_run_clears_cache_on_validation_error(): void { + $initial_config = FirewallAlias::get_config('aliases/alias'); + $initial_names = array_column(FirewallAlias::read_all()->to_representation(), 'name'); + + $this->assert_throws_response( + response_id: 'FIELD_MUST_BE_UNIQUE', + code: 400, + callable: function () { + (new FirewallAlias(dry_run: true))->replace_all( + data: [ + ['name' => 'PFREST_DRYRUN_DUPLICATE', 'type' => 'host', 'address' => ['127.0.0.1']], + ['name' => 'PFREST_DRYRUN_DUPLICATE', 'type' => 'host', 'address' => ['127.0.0.2']], + ], + ); + }, + ); + + $this->assert_is_false(FirewallAlias::get_model_cache()::has_modelset(FirewallAlias::get_class_fqn())); + $this->assert_equals(FirewallAlias::get_config('aliases/alias'), $initial_config); + $this->assert_equals(array_column(FirewallAlias::read_all()->to_representation(), 'name'), $initial_names); + } + /** * Checks that read_all() on a Model with a parent Model class returns all child Model objects from all parents. */ 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..59509645 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsInterfaceAddressFieldTestCase.inc @@ -0,0 +1,136 @@ +represent_interfaces_as->value = $mode; + $settings->update(); + + 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) { + $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()); + $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(required: true, 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(required: true); + + 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 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(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); + $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/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 214b7f13..f6f78dc6 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,162 @@ 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 a single ICMP rule can be dry-run replaced without a missing ModelSet signature error. + */ + public function test_replace_all_dry_run_single_icmp_rule(): void { + $initial_rules = FirewallRule::get_config('filter/rule'); + $rules = (new FirewallRule(dry_run: true))->replace_all( + data: [ + [ + 'interface' => ['wan'], + 'ipprotocol' => 'inet', + 'protocol' => 'icmp', + 'source' => 'any', + 'destination' => 'any', + 'type' => 'pass', + ], + ], + ); + + $this->assert_equals($rules->signature, FirewallRule::get_class_fqn()); + $this->assert_equals($rules->count(), 1); + $this->assert_equals($rules->first()->protocol->value, 'icmp'); + $this->assert_type($rules->first()->tracker->value, 'integer'); + $this->assert_is_false(FirewallRule::get_model_cache()::has_modelset(FirewallRule::get_class_fqn())); + $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 */ 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(), + ); + } +} 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); + } + } + } +}