From 2879a2286dec1a195e264ab5a06abc40a05def9b Mon Sep 17 00:00:00 2001 From: vitspec99 <97901972+vitspec99@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:13:14 +0300 Subject: [PATCH 01/73] - motp_enable optional now (#767) - framed_ip_address/framed_ip_netmask added to support Framed-IP-Address / Framed-IP-Netmask attributes --- .../pkg/RESTAPI/Models/FreeRADIUSUser.inc | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc index d5c2355e..ce4679c2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc @@ -10,6 +10,7 @@ use RESTAPI\Fields\IntegerField; use RESTAPI\Fields\StringField; use RESTAPI\Responses\ValidationError; use RESTAPI\Validators\RegexValidator; +use RESTAPI\Validators\IPAddressValidator; /** * Defines a Model that represents FreeRADIUS Users @@ -58,7 +59,8 @@ class FreeRADIUSUser extends Model { help_text: 'The encryption method for the password.', ); $this->motp_enable = new BooleanField( - required: true, + required: false, + default: false, indicates_true: 'on', indicates_false: '', internal_name: 'varusersmotpenable', @@ -110,6 +112,24 @@ class FreeRADIUSUser extends Model { ], help_text: 'A description for this user.', ); + $this->framed_ip_address = new StringField( + required: false, + default: '', + internal_name: 'varusersframedipaddress', + allow_empty: true, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + help_text: 'Framed-IP-Address MUST be supported by NAS. ' . + 'If the OpenVPN server uses a subnet style Topology the RADIUS server MUST ' . + 'also send back an appropriate Framed-IP-Netmask value matching the VPN Tunnel Network.' + ); + $this->framed_ip_netmask = new StringField( + required: false, + default: '', + internal_name: 'varusersframedipnetmask', + allow_empty: true, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + help_text: 'Framed-IP-Netmask MUST be supported by NAS' + ); parent::__construct($id, $parent_id, $data, ...$options); } From 720a38c14883717f407e24c26e1e5e1d223c0011 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 17 Nov 2025 17:31:06 -0700 Subject: [PATCH 02/73] perf: setup simple caching for model classes --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 4c42b30c..8a51a83b 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 @@ -142,6 +142,13 @@ class Model { */ public Cache|null $cache = null; + /** + * @var array $object_cache + * An array used internally to cache already loaded ModelSets for any Model class. This prevents redundant loading + * of ModelSet objects during operations that may require multiple references to the same ModelSet. + */ + public static array $object_cache = []; + /** * @var ModelSet $related_objects * A ModelSet containing foreign Model objects related to this Model. These are primarily populated by @@ -1882,6 +1889,7 @@ class Model { $model_objects = []; $is_parent_model_many = $model->is_parent_model_many(); $requests_pagination = ($limit or $offset); + $cache_exempt = ($requests_pagination or $reverse); # Throw an error if this Model has a $many parent Model, but no parent Model ID was given if ($is_parent_model_many and !isset($parent_id)) { @@ -1900,6 +1908,11 @@ class Model { ); } + # Load from cache if it is not exempt and cached objects exist + if (!$cache_exempt and Model::$object_cache[$model_name]) { + return Model::$object_cache[$model_name]; + } + # Obtain all of this Model's internally stored objects $internal_objects = $model->get_internal_objects(); @@ -1926,8 +1939,16 @@ class Model { $model_objects[] = $model_object; } + # Load the ModelSet with all obtained Model objects + $modelset = new ModelSet(model_objects: $model_objects); + + # For many models, cache the ModelSet if not exempt + if ($model->many and !$cache_exempt) { + Model::$object_cache[$model_name] = new ModelSet(model_objects: $model_objects); + } + # For many enabled Models return a ModelSet, otherwise return a single Model object - return $model->many ? new ModelSet(model_objects: $model_objects) : $model_objects[0]; + return $model->many ? $modelset : $modelset->first(); } /** From 362009e8e68abbca60641c8a43b68ee6081f95f1 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 17 Nov 2025 22:18:03 -0700 Subject: [PATCH 03/73] perf: clear object cache after any change --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 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 8a51a83b..9d0dbccd 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 @@ -527,6 +527,16 @@ class Model { return new $class(); } + /** + * Clears the object cache for this Model class. + */ + public static function clear_object_cache(): void { + $class = get_called_class(); + if (isset(self::$object_cache[$class])) { + unset(self::$object_cache[$class]); + } + } + /** * Provides this Model's Field object properties relevant information like its field name and provides this Model * object as this Field's $context Model object. @@ -2108,6 +2118,9 @@ class Model { # Refresh the initial object $this->initial_object = $this->copy(); + + # Clear the object cache for this Model class + $this->clear_object_cache(); } # Return the current representation of this object @@ -2198,6 +2211,9 @@ class Model { # Refresh the initial object $this->initial_object = $this->copy(); + + # Clear the object cache for this Model class + $this->clear_object_cache(); } # Return the current representation of this object @@ -2293,6 +2309,9 @@ class Model { clear_subsystem_dirty($this->subsystem); } + # Clear the object cache for this Model class + $this->clear_object_cache(); + return $new_objects; } @@ -2376,6 +2395,9 @@ class Model { # Refresh the initial object $this->initial_object = $this->copy(); + + # Clear the object cache for this Model class + $this->clear_object_cache(); } # Return the current representation of this object @@ -2425,8 +2447,10 @@ class Model { offset: $offset, ); - # Delete the Model objects that matched the query - return $model_objects->delete(); + # Delete the Model objects that matched the query and clear the object cache + $deleted_model_objects = $model_objects->delete(); + self::clear_object_cache(); + return $deleted_model_objects; } /** @@ -2436,8 +2460,10 @@ class Model { # Obtain all Model objects for this Model $model_objects = self::read_all(); - # Delete all Model objects for this Model - return $model_objects->delete(); + # Delete all Model objects for this Model and clear the object cache + $deleted_model_objects = $model_objects->delete(); + self::clear_object_cache(); + return $deleted_model_objects; } /** From 96f5e6fa0c8a55962470619d8e020493ca1f69ac Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 17 Nov 2025 23:26:06 -0700 Subject: [PATCH 04/73] perf(ForeignModelField): index in-scope model objects --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 7 ++- .../pkg/RESTAPI/Fields/ForeignModelField.inc | 61 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 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 9d0dbccd..8eb6f442 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 @@ -39,7 +39,9 @@ class Model { use BaseTraits; /** - * @const string $READ_LOCK_FILE + * @var string $WRITE_LOCK_FILE + * The file path used to create a lock when writing to the pfSense configuration file. This prevents multiple + * simultaneous writes to the config file that could corrupt the config. */ const WRITE_LOCK_FILE = '/tmp/.RESTAPI.write_config.lock'; @@ -535,6 +537,9 @@ class Model { if (isset(self::$object_cache[$class])) { unset(self::$object_cache[$class]); } + + # Clear foreign model indices + RESTAPI\Fields\ForeignModelField::clear_model_index(); } /** 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 6020a752..40ec7cf1 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 @@ -16,8 +16,25 @@ use RESTAPI\Responses\ServerError; * For example, a ForeignModelField can be used to relate a static route to its parent Gateway model object. */ class ForeignModelField extends Field { + /** + * @var string $MODELS_NAMESPACE The namespace prefix for all Model classes. + */ const MODELS_NAMESPACE = 'RESTAPI\\Models\\'; + + /** + * @var array $models An array of instantiated Model objects for each assigned $model_name. + */ public array $models = []; + + /** + * @var array $model_index + * An associative array that maps objects of the assigned $model_name classes by their $model_field values + * for quick lookup. This ensures the most taxing reference operations are only performed once per request. + * Indices for each corresponding $model_name will be cleared automatically when any Model object of that class + * is created, updated, or deleted. + */ + public static array $model_index = []; + /** * Defines the ForeignModelField object and sets its options. * @param string|array $model_name The name(s) of the foreign Model class(es) this field relates to. This should be @@ -323,6 +340,39 @@ class ForeignModelField extends Field { return $in_scope_models->query($this->model_query); } + /** + * Indexes all in scope Model objects by their $model_field values for quick lookup and returns + * an associative array of the indexed Model objects. This method will also cache the indices + * in the static $model_index property to prevent redundant indexing operations during the same request. + * @return array An associative array mapping $model_field values to their corresponding Model objects. + */ + public function get_model_index(): array { + # Get the name of the Model this Field belongs to + $model_context_name = $this->context->get_class_shortname(); + + # Check if we have already indexed Model objects for this Field in this request + if (self::$model_index[$model_context_name][$this->name]) { + return self::$model_index[$model_context_name][$this->name]; + } + + # Loop through each in scope Model object and index them by their $model_field values + foreach($this->get_in_scope_models()->model_objects as $model_object) { + $foreign_model_field_value = $model_object->{$this->model_field_internal}->value; + self::$model_index[$model_context_name][$this->name][$foreign_model_field_value] = $model_object; + } + + # Return the indexed Model objects for this Field + return self::$model_index[$model_context_name][$this->name]; + } + + /** + * Clears the cached model indices. + */ + public static function clear_model_index(): void + { + self::$model_index = []; + } + /** * Obtains a ModelSet of the Model(s) that match this field's criteria. * @param string $field_name The name of the field used to check for matching values. This is typically set to the @@ -331,7 +381,16 @@ class ForeignModelField extends Field { * to the same value as $this->value. */ private function __get_matches(string $field_name, mixed $field_value): ModelSet { - return $this->get_in_scope_models()->query(query_params: [$field_name => $field_value]); + # Create a ModelSet we can use to store matching objects + $modelset = new ModelSet(); + $match = $this->get_model_index()[$field_value] ?? null; + + # Only add the Model object if it exists + if ($match) { + $modelset->model_objects[] = $match; + } + + return $modelset; } /** From 85cddb6f84afbd084c1ef1b4b40833f145c3c5d9 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 20 Nov 2025 23:12:37 -0700 Subject: [PATCH 05/73] perf(ForeignModelField): adjust foreign model indexing --- .../pkg/RESTAPI/Fields/ForeignModelField.inc | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) 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 40ec7cf1..32e711a2 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 @@ -341,10 +341,20 @@ class ForeignModelField extends Field { } /** - * Indexes all in scope Model objects by their $model_field values for quick lookup and returns - * an associative array of the indexed Model objects. This method will also cache the indices + * Indexes all in scope Model objects by their $model_field and $model_field_internal values for quick lookup and + * returns an associative array of the indexed Model objects. This method will also cache the indices * in the static $model_index property to prevent redundant indexing operations during the same request. - * @return array An associative array mapping $model_field values to their corresponding Model objects. + * + * Index is structured as: + * [ + * 'ModelClassShortName' => [ + * 'this_field_name' => [ + * 'model_field_value' => ModelObject, + * 'model_field_internal_value' => ModelObject, + * ], + * + * @return array An associative array mapping $model_field and $model_field_internal values to their corresponding + * Model objects. */ public function get_model_index(): array { # Get the name of the Model this Field belongs to @@ -356,34 +366,37 @@ class ForeignModelField extends Field { } # Loop through each in scope Model object and index them by their $model_field values - foreach($this->get_in_scope_models()->model_objects as $model_object) { - $foreign_model_field_value = $model_object->{$this->model_field_internal}->value; - self::$model_index[$model_context_name][$this->name][$foreign_model_field_value] = $model_object; + foreach ($this->get_in_scope_models()->model_objects as $model_object) { + $foreign_model_field_value = $model_object->{$this->model_field}->value; + $foreign_model_field_internal_value = $model_object->{$this->model_field_internal}->value; + self::$model_index[$model_context_name][$this->name][$this->model_field][ + $foreign_model_field_value + ] = $model_object; + self::$model_index[$model_context_name][$this->name][$this->model_field_internal][ + $foreign_model_field_internal_value + ] = $model_object; } # Return the indexed Model objects for this Field - return self::$model_index[$model_context_name][$this->name]; + return self::$model_index[$model_context_name][$this->name] ?? []; } /** * Clears the cached model indices. */ - public static function clear_model_index(): void - { + public static function clear_model_index(): void { self::$model_index = []; } /** * Obtains a ModelSet of the Model(s) that match this field's criteria. - * @param string $field_name The name of the field used to check for matching values. This is typically set to the - * same value as $this->field_name. * @param mixed $field_value The value of the $field_name that indicates there is a match. This is typically set * to the same value as $this->value. */ private function __get_matches(string $field_name, mixed $field_value): ModelSet { # Create a ModelSet we can use to store matching objects $modelset = new ModelSet(); - $match = $this->get_model_index()[$field_value] ?? null; + $match = $this->get_model_index()[$field_name][$field_value] ?? null; # Only add the Model object if it exists if ($match) { @@ -399,6 +412,11 @@ class ForeignModelField extends Field { * @returns Model|null Returns the Model object associated with this Field's current value. */ public function get_related_model(): Model|null { + # Skip for non-many fields + if ($this->many) { + return null; + } + # Get the Model objects that match this field's criteria $query_modelset = $this->__get_matches($this->model_field, $this->value); From 91044c5db43166e10e3d7c9d163c292fbedea4fe Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 20 Nov 2025 23:12:55 -0700 Subject: [PATCH 06/73] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc index ce4679c2..e028e6b8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSUser.inc @@ -120,7 +120,7 @@ class FreeRADIUSUser extends Model { validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], help_text: 'Framed-IP-Address MUST be supported by NAS. ' . 'If the OpenVPN server uses a subnet style Topology the RADIUS server MUST ' . - 'also send back an appropriate Framed-IP-Netmask value matching the VPN Tunnel Network.' + 'also send back an appropriate Framed-IP-Netmask value matching the VPN Tunnel Network.', ); $this->framed_ip_netmask = new StringField( required: false, @@ -128,7 +128,7 @@ class FreeRADIUSUser extends Model { internal_name: 'varusersframedipnetmask', allow_empty: true, validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], - help_text: 'Framed-IP-Netmask MUST be supported by NAS' + help_text: 'Framed-IP-Netmask MUST be supported by NAS', ); parent::__construct($id, $parent_id, $data, ...$options); From 15b8fd6278950dc562fa1fe5800ece34decc6801 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 21 Nov 2025 21:56:22 -0700 Subject: [PATCH 07/73] refactor: clear object cache on config modification, not model modification --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 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 8eb6f442..9a9c6476 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 @@ -659,6 +659,8 @@ class Model { * @returns mixed $val or $default if the path prefix does not exist */ public static function set_config(string $path, mixed $value, mixed $default = null): mixed { + # Clear the object cache for this Model class since config is being modified + self::clear_object_cache(); return config_set_path(self::normalize_config_path($path), $value, $default); } @@ -703,6 +705,8 @@ class Model { * @returns array copy of the removed value or null */ public static function del_config(string $path): mixed { + # Clear the object cache for this Model class since config is being modified + self::clear_object_cache(); return config_del_path(self::normalize_config_path($path)); } @@ -739,6 +743,9 @@ class Model { write_config(sprintf(gettext(' ' . $change_note))); session_destroy(); + # Clear the object cache for this Model class since config has been modified + self::clear_object_cache(); + # If a subsystem is specified for this Model, mark it as dirty if ($this->subsystem) { mark_subsystem_dirty($this->subsystem); @@ -766,6 +773,10 @@ class Model { */ public static function reload_config(bool $force_parse = false): void { global $config; + + # Clear the object cache for all Model classes since config is being reloaded + self::clear_object_cache(); + $config = parse_config(parse: $force_parse); } @@ -2123,9 +2134,6 @@ class Model { # Refresh the initial object $this->initial_object = $this->copy(); - - # Clear the object cache for this Model class - $this->clear_object_cache(); } # Return the current representation of this object @@ -2216,9 +2224,6 @@ class Model { # Refresh the initial object $this->initial_object = $this->copy(); - - # Clear the object cache for this Model class - $this->clear_object_cache(); } # Return the current representation of this object @@ -2314,9 +2319,6 @@ class Model { clear_subsystem_dirty($this->subsystem); } - # Clear the object cache for this Model class - $this->clear_object_cache(); - return $new_objects; } @@ -2400,9 +2402,6 @@ class Model { # Refresh the initial object $this->initial_object = $this->copy(); - - # Clear the object cache for this Model class - $this->clear_object_cache(); } # Return the current representation of this object @@ -2452,10 +2451,8 @@ class Model { offset: $offset, ); - # Delete the Model objects that matched the query and clear the object cache - $deleted_model_objects = $model_objects->delete(); - self::clear_object_cache(); - return $deleted_model_objects; + # Delete the Model objects that matched the query + return $model_objects->delete(); } /** @@ -2465,10 +2462,8 @@ class Model { # Obtain all Model objects for this Model $model_objects = self::read_all(); - # Delete all Model objects for this Model and clear the object cache - $deleted_model_objects = $model_objects->delete(); - self::clear_object_cache(); - return $deleted_model_objects; + # Delete all Model objects for this Model + return $model_objects->delete(); } /** From 01a09680478e07c3c100efe583e8ab0f2d0d2da0 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 21 Nov 2025 22:52:20 -0700 Subject: [PATCH 08/73] refactor: completely clear object cache on config change --- .../files/usr/local/pkg/RESTAPI/Core/Model.inc | 6 ++---- 1 file changed, 2 insertions(+), 4 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 9a9c6476..aecd2211 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 @@ -533,10 +533,8 @@ class Model { * Clears the object cache for this Model class. */ public static function clear_object_cache(): void { - $class = get_called_class(); - if (isset(self::$object_cache[$class])) { - unset(self::$object_cache[$class]); - } + # Clear the object cache + self::$object_cache = []; # Clear foreign model indices RESTAPI\Fields\ForeignModelField::clear_model_index(); From 63c2a0920a0d89e01c87d02a7f88d42c80c44a6c Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 22 Nov 2025 08:59:51 -0700 Subject: [PATCH 09/73] revert: revert ForeignModelField changes --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 3 - .../pkg/RESTAPI/Fields/ForeignModelField.inc | 89 ++----------------- 2 files changed, 6 insertions(+), 86 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 aecd2211..476df72a 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 @@ -535,9 +535,6 @@ class Model { public static function clear_object_cache(): void { # Clear the object cache self::$object_cache = []; - - # Clear foreign model indices - RESTAPI\Fields\ForeignModelField::clear_model_index(); } /** 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 32e711a2..be6b4127 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 @@ -16,25 +16,8 @@ use RESTAPI\Responses\ServerError; * For example, a ForeignModelField can be used to relate a static route to its parent Gateway model object. */ class ForeignModelField extends Field { - /** - * @var string $MODELS_NAMESPACE The namespace prefix for all Model classes. - */ const MODELS_NAMESPACE = 'RESTAPI\\Models\\'; - - /** - * @var array $models An array of instantiated Model objects for each assigned $model_name. - */ public array $models = []; - - /** - * @var array $model_index - * An associative array that maps objects of the assigned $model_name classes by their $model_field values - * for quick lookup. This ensures the most taxing reference operations are only performed once per request. - * Indices for each corresponding $model_name will be cleared automatically when any Model object of that class - * is created, updated, or deleted. - */ - public static array $model_index = []; - /** * Defines the ForeignModelField object and sets its options. * @param string|array $model_name The name(s) of the foreign Model class(es) this field relates to. This should be @@ -194,7 +177,7 @@ class ForeignModelField extends Field { if (!class_exists($model_name)) { throw new ServerError( message: "ForeignModelField's `model_name` property must be an existing Model class FQN, " . - "received `$model_name`.", + "received `$model_name`.", response_id: 'FOREIGN_MODEL_FIELD_WITH_UNKNOWN_MODEL_NAME', ); } @@ -295,7 +278,7 @@ class ForeignModelField extends Field { if (!$query_modelset->exists()) { throw new NotFoundError( message: "Field `$this->name` could not locate `$model_names` object with " . - "`$this->model_field` set to `$value`", + "`$this->model_field` set to `$value`", response_id: 'FOREIGN_MODEL_FIELD_VALUE_NOT_FOUND', ); } @@ -340,70 +323,15 @@ class ForeignModelField extends Field { return $in_scope_models->query($this->model_query); } - /** - * Indexes all in scope Model objects by their $model_field and $model_field_internal values for quick lookup and - * returns an associative array of the indexed Model objects. This method will also cache the indices - * in the static $model_index property to prevent redundant indexing operations during the same request. - * - * Index is structured as: - * [ - * 'ModelClassShortName' => [ - * 'this_field_name' => [ - * 'model_field_value' => ModelObject, - * 'model_field_internal_value' => ModelObject, - * ], - * - * @return array An associative array mapping $model_field and $model_field_internal values to their corresponding - * Model objects. - */ - public function get_model_index(): array { - # Get the name of the Model this Field belongs to - $model_context_name = $this->context->get_class_shortname(); - - # Check if we have already indexed Model objects for this Field in this request - if (self::$model_index[$model_context_name][$this->name]) { - return self::$model_index[$model_context_name][$this->name]; - } - - # Loop through each in scope Model object and index them by their $model_field values - foreach ($this->get_in_scope_models()->model_objects as $model_object) { - $foreign_model_field_value = $model_object->{$this->model_field}->value; - $foreign_model_field_internal_value = $model_object->{$this->model_field_internal}->value; - self::$model_index[$model_context_name][$this->name][$this->model_field][ - $foreign_model_field_value - ] = $model_object; - self::$model_index[$model_context_name][$this->name][$this->model_field_internal][ - $foreign_model_field_internal_value - ] = $model_object; - } - - # Return the indexed Model objects for this Field - return self::$model_index[$model_context_name][$this->name] ?? []; - } - - /** - * Clears the cached model indices. - */ - public static function clear_model_index(): void { - self::$model_index = []; - } - /** * Obtains a ModelSet of the Model(s) that match this field's criteria. + * @param string $field_name The name of the field used to check for matching values. This is typically set to the + * same value as $this->field_name. * @param mixed $field_value The value of the $field_name that indicates there is a match. This is typically set * to the same value as $this->value. */ private function __get_matches(string $field_name, mixed $field_value): ModelSet { - # Create a ModelSet we can use to store matching objects - $modelset = new ModelSet(); - $match = $this->get_model_index()[$field_name][$field_value] ?? null; - - # Only add the Model object if it exists - if ($match) { - $modelset->model_objects[] = $match; - } - - return $modelset; + return $this->get_in_scope_models()->query(query_params: [$field_name => $field_value]); } /** @@ -412,11 +340,6 @@ class ForeignModelField extends Field { * @returns Model|null Returns the Model object associated with this Field's current value. */ public function get_related_model(): Model|null { - # Skip for non-many fields - if ($this->many) { - return null; - } - # Get the Model objects that match this field's criteria $query_modelset = $this->__get_matches($this->model_field, $this->value); @@ -451,4 +374,4 @@ class ForeignModelField extends Field { return $modelset; } -} +} \ No newline at end of file From bfe0f2e339fd4af80097e22203c9d053079641fd Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 22 Nov 2025 13:15:06 -0700 Subject: [PATCH 10/73] chore: use Model::set_config instead of config_set_path --- .../files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php | 2 +- .../files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc | 2 +- .../usr/local/pkg/RESTAPI/Tests/APIModelsAPIJWTTestCase.inc | 4 ++-- .../local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php index 33524acd..4b0c7162 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php @@ -426,7 +426,7 @@ function delete(): void { */ function rotate_server_key(): void { $pkg_index = RESTAPISettings::get_pkg_id(); - config_set_path("installedpackages/package/$pkg_index/conf/keys", []); + Model::set_config("installedpackages/package/$pkg_index/conf/keys", []); echo 'Rotating REST API server key... '; RESTAPIJWT::init_server_key(rotate: true); echo 'done.' . PHP_EOL; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc index 71d7f290..92132090 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc @@ -48,7 +48,7 @@ class RESTAPIJWT extends Model { if (empty($api_config['server_key']) or $rotate === true) { # Try to generate the server key, throw a ServerError if we cannot try { - config_set_path("installedpackages/package/$pkg_id/conf/server_key", bin2hex(random_bytes(32))); + Model::set_config("installedpackages/package/$pkg_id/conf/server_key", bin2hex(random_bytes(32))); write_config('API server key created'); } catch (Exception) { throw new ServerError( 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 73a40c28..53a71309 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 @@ -294,7 +294,7 @@ class RESTAPISettings extends Model { return API_SETTINGS_RESTORE_NO_CHANGE; } - config_set_path("installedpackages/package/$current_api_id/conf", $backup_api_conf); + Model::set_config("installedpackages/package/$current_api_id/conf", $backup_api_conf); write_config('Synchronized persistent API configuration'); return API_SETTINGS_RESTORE_SUCCESS; } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPIJWTTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPIJWTTestCase.inc index 676e21a3..7832b615 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPIJWTTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPIJWTTestCase.inc @@ -33,7 +33,7 @@ class APIModelsAPIJWTTestCase extends TestCase { $this->assert_not_equals($first_server_key, $second_server_key); # Remove the JWT server key created by generate_jwt() - config_set_path("installedpackages/package/{$api_pkg_id}/conf/server_key", ''); + Model::set_config("installedpackages/package/{$api_pkg_id}/conf/server_key", ''); write_config('Unit test removed JWT server key used for testing'); } @@ -62,7 +62,7 @@ class APIModelsAPIJWTTestCase extends TestCase { $this->assert_equals($decoded_jwt['data'], 'admin'); # Remove the JWT server key created by generate_jwt() - config_set_path("installedpackages/package/{$api_pkg_id}/conf/server_key", ''); + Model::set_config("installedpackages/package/{$api_pkg_id}/conf/server_key", ''); write_config('Unit test removed JWT server key used for testing'); } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc index 25becfab..cf9b69ff 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc @@ -87,7 +87,7 @@ class APIModelsAPISettingsTestCase extends TestCase { # Capture the current API config, delete the running API config, restore it from backup and ensure it matches $api_config_id = $api_settings->get_pkg_id(); $original_api_config = $api_settings->get_pkg_config(); - config_set_path("installedpackages/package/$api_config_id/conf", [ + Model::set_config("installedpackages/package/$api_config_id/conf", [ 'keep_backup' => 'enabled', 'bad-field' => true, ]); From 772cdf61ad66d90d29543ce5f8bf2f86b14b7de0 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 22 Nov 2025 13:20:26 -0700 Subject: [PATCH 11/73] fix: clear model object cache after test teardown --- .../usr/local/pkg/RESTAPI/Core/TestCase.inc | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc index 2f183d1f..71d75bba 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc @@ -85,8 +85,7 @@ class TestCase { break; } catch (Error | Exception $e) { # Tear down any resources created by this test before retrying or exiting - $config = $original_config; - write_config("Restored config after API test '$method'"); + $this->restore_config($original_config); $this->teardown(); # If we have retries left, wait the configured delay and try again @@ -101,8 +100,7 @@ class TestCase { } # Restore the config as it was when the test began. - $config = $original_config; - write_config("Restored config after API test '$method'"); + $this->restore_config($original_config); } } @@ -110,6 +108,18 @@ class TestCase { $this->teardown(); } + /** + * Restores the configuration to its original state and clears the Model object cache. + * + * @param array $original_config The original configuration to restore. + */ + protected function restore_config(array $original_config): void { + global $config; + $config = $original_config; + Model::clear_object_cache(); + write_config("Restored config after API test '{$this->method}'"); + } + /** * Installs the required packages for this TestCase. */ From 0d74bad77922612f3fce7f32001316a9862ef7ef Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 22 Nov 2025 17:02:19 -0700 Subject: [PATCH 12/73] refactor: clear model cache on api crud methods --- .../files/usr/local/pkg/RESTAPI/Core/Model.inc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 476df72a..710af182 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 @@ -2131,6 +2131,9 @@ class Model { $this->initial_object = $this->copy(); } + # Reset the object cache as the config has changed + Model::$object_cache = []; + # Return the current representation of this object return $this; } @@ -2221,6 +2224,9 @@ class Model { $this->initial_object = $this->copy(); } + # Reset the object cache as the config has changed + Model::$object_cache = []; + # Return the current representation of this object return $this; } @@ -2314,6 +2320,9 @@ class Model { clear_subsystem_dirty($this->subsystem); } + # Reset the object cache as the config has changed + Model::$object_cache = []; + return $new_objects; } @@ -2399,6 +2408,9 @@ class Model { $this->initial_object = $this->copy(); } + # Reset the object cache as the config has changed + Model::$object_cache = []; + # Return the current representation of this object return $this; } @@ -2446,6 +2458,9 @@ class Model { offset: $offset, ); + # Reset the object cache as the config has changed + Model::$object_cache = []; + # Delete the Model objects that matched the query return $model_objects->delete(); } @@ -2457,6 +2472,9 @@ class Model { # Obtain all Model objects for this Model $model_objects = self::read_all(); + # Reset the object cache as the config has changed + Model::$object_cache = []; + # Delete all Model objects for this Model return $model_objects->delete(); } From ab1ceebaffd3f37d8ce699636385b642ec887816 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 22 Nov 2025 17:03:12 -0700 Subject: [PATCH 13/73] style: run prettier on changed files --- .../usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 be6b4127..6020a752 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 @@ -177,7 +177,7 @@ class ForeignModelField extends Field { if (!class_exists($model_name)) { throw new ServerError( message: "ForeignModelField's `model_name` property must be an existing Model class FQN, " . - "received `$model_name`.", + "received `$model_name`.", response_id: 'FOREIGN_MODEL_FIELD_WITH_UNKNOWN_MODEL_NAME', ); } @@ -278,7 +278,7 @@ class ForeignModelField extends Field { if (!$query_modelset->exists()) { throw new NotFoundError( message: "Field `$this->name` could not locate `$model_names` object with " . - "`$this->model_field` set to `$value`", + "`$this->model_field` set to `$value`", response_id: 'FOREIGN_MODEL_FIELD_VALUE_NOT_FOUND', ); } @@ -374,4 +374,4 @@ class ForeignModelField extends Field { return $modelset; } -} \ No newline at end of file +} From c3a60d4a4e00b75815534268329540b75da5daaa Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 23 Nov 2025 11:38:28 -0700 Subject: [PATCH 14/73] test: clean up restapi settings tests --- .../local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc index cf9b69ff..a8176dc9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc @@ -3,6 +3,7 @@ namespace RESTAPI\Tests; use RESTAPI\Core\TestCase; +use RESTAPI\Core\Model; use RESTAPI\Models\FirewallRule; use RESTAPI\Models\RESTAPISettings; use RESTAPI\Models\User; @@ -13,8 +14,6 @@ use const RESTAPI\Models\API_SETTINGS_RESTORE_NO_CHANGE; use const RESTAPI\Models\API_SETTINGS_RESTORE_SUCCESS; class APIModelsAPISettingsTestCase extends TestCase { - # TODO: Needs Tests for API HA sync feature - /** * Checks that the static `get_pkg_config()` method returns the API package's internal configuration and * the static `get_pkg_id()` method returns the API package's ID From 391a4eee21bc0b3c50db690dc0edd346a1e568df Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 23 Nov 2025 11:46:01 -0700 Subject: [PATCH 15/73] fix: clear model object cache after package install --- .../files/usr/local/pkg/RESTAPI/Models/Package.inc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc index b0d097bb..0d9c92b7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc @@ -85,10 +85,13 @@ class Package extends Model { /** * Installs the package assigned to the `name` field. */ - public function _create() { + public function _create(): void { # Install the package pkg_install($this->name->value); + # Clear the object cache to ensure we get fresh data + $this->clear_object_cache(); + # Locate this package's current ID and load info about the package installed $this->id = $this->query(name: $this->name->value)->first()->id; $this->from_internal(); @@ -97,7 +100,7 @@ class Package extends Model { /** * Deletes the package assigned to the `name` field. */ - public function _delete() { + public function _delete(): void { pkg_delete($this->name->value); } } From 2e22e67ab8587c7b9df6838c450b0ed8e9b754b7 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Wed, 3 Dec 2025 21:33:43 -0700 Subject: [PATCH 16/73] fix: exclude model's with parents from caching --- pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 710af182..a9b032e1 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 @@ -1910,7 +1910,7 @@ class Model { $model_objects = []; $is_parent_model_many = $model->is_parent_model_many(); $requests_pagination = ($limit or $offset); - $cache_exempt = ($requests_pagination or $reverse); + $cache_exempt = ($requests_pagination or $reverse or isset($parent_id)); # Throw an error if this Model has a $many parent Model, but no parent Model ID was given if ($is_parent_model_many and !isset($parent_id)) { From 4ae5c926d7832648ecc455ffac9e408bb0dd1314 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Dec 2025 14:18:56 -0700 Subject: [PATCH 17/73] refactor: support queries on child models --- .../usr/local/pkg/RESTAPI/Core/Endpoint.inc | 8 --- .../usr/local/pkg/RESTAPI/Core/Field.inc | 2 +- .../usr/local/pkg/RESTAPI/Core/Model.inc | 68 ++++++++++++------- .../usr/local/pkg/RESTAPI/Core/ModelSet.inc | 3 +- ...DNSResolverHostOverrideAliasesEndpoint.inc | 24 +++++++ .../local/pkg/RESTAPI/Models/DHCPServer.inc | 6 +- .../RESTAPI/Models/DHCPServerAddressPool.inc | 2 +- .../Models/DHCPServerStaticMapping.inc | 2 +- ...elsRoutingGatewayGroupPriorityTestCase.inc | 4 +- 9 files changed, 77 insertions(+), 42 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc index 16aa82cf..cf02e440 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc @@ -412,14 +412,6 @@ class Endpoint { ); } } - - # Do not allow `many` Endpoints that are assigned a Model with a parent Model - if ($this->many and $this->model->parent_model_class) { - throw new ServerError( - message: 'Endpoints cannot enable `many` when the assigned Model has a parent Model', - response_id: 'ENDPOINT_MANY_WHEN_MODEL_HAS_PARENT', - ); - } } /** 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 7fe5a5de..2e052053 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 @@ -731,7 +731,7 @@ class Field { } # Use $modelset if provided. Otherwise, use all existing $context model objects as the $modelset - $modelset = $modelset ?: $this->context->read_all(parent_id: $this->context->parent_id); + $modelset = $modelset ?: $this->context->query(parent_id: $this->context->parent_id); # If this is a $many field, query for any other object has this value in the array if ($this->many) { 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 a9b032e1..f1d965e9 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 @@ -864,6 +864,33 @@ class Model { return $internal_object; } + /** + * Recursively obtains all internal objects for this Model from all parent objects in config. This method is only + * applicable to Models with a `parent_model_class` assigned. + * @return array An array of all internal objects for this Model from all parent objects in + */ + protected function get_internal_objects_from_all_parents(): array { + # Variables + $internal_objects = []; + $parent_model_class = "\\RESTAPI\\Models\\$this->parent_model_class"; + $parent_model = new $parent_model_class(skip_init: true); + $parent_configs = $this->get_config($parent_model->config_path, []); + + # Check for internal objects from all parents + foreach ($parent_configs as $parent_id => $parent_config) { + # Obtain the list of child objects from this parent config + $child_configs = $this->get_config("$parent_model->config_path/$parent_id/$this->config_path", []); + + foreach ($child_configs as $child_id => $child_config) { + $child_config["parent_id"] = $parent_id; + $child_config["id"] = $child_id; + $internal_objects[] = $child_config; + } + } + + return $internal_objects; + } + /** * Obtains all internal objects for this Model. When a `config_path` is specified, this method will obtain the * internal objects directly from config. When an `internal_callable` is assigned, this method will return @@ -886,6 +913,10 @@ class Model { elseif ($mock_internal_objects) { $internal_objects = $mock_internal_objects; } + # Obtain all internal objects from all parents if a parent Model class is assigned + elseif ($this->parent_model_class) { + $internal_objects = $this->get_internal_objects_from_all_parents(); + } # Obtain the internal objects from the config path if specified elseif ($this->config_path) { $internal_objects = $this->get_config($this->get_config_path(), []); @@ -913,7 +944,7 @@ class Model { * @throws ServerError When this Model does not have a `config_path` set. * @throws NotFoundError When an object with the specified $id does not exist. */ - public function from_internal() { + public function from_internal(): void { # Require a `parent_id` if a `many` parent Model is assigned if ($this->parent_model_class and $this->parent_model->many and !isset($this->parent_id)) { throw new ServerError( @@ -1319,7 +1350,7 @@ class Model { } # Use the $modelset if provided, otherwise obtain a ModelSet of all existing objects for this Model - $modelset = $modelset ?: $this->read_all(parent_id: $this->parent_id); + $modelset = $modelset ?: $this->query(parent_id: $this->parent_id); # Use this variable to keep track of query parameters to use when checking uniqueness $query_params = []; @@ -1415,7 +1446,7 @@ class Model { # For 'many' Models, capture all current Models in a ModelSet if one was not already given if ($this->many and !$modelset) { - $modelset = $this->read_all(parent_id: $this->parent_id); + $modelset = $this->query(parent_id: $this->parent_id); } # Loop through each of this object's assigned Fields and validate them. @@ -1887,8 +1918,6 @@ class Model { * Fetches Model objects for all objects stored in the internal pfSense values. If `config_path` is set, this will * load Model objects for each object stored at the config path. If `internal_callable` is set, this will create * Model objects for each object returned by the specified callable. - * @param mixed|null $parent_id Specifies the ID of the parent Model to read all objects from. This is required for - * $many Models with a $parent_model_class. This value has no affect otherwise. * @param int $offset The starting point in the dataset to be used with $limit. This is only applicable to $many * enabled Models. * @param int $limit The maximum number of Model objects to retrieve. This is only applicable to $many @@ -1899,26 +1928,16 @@ class Model { * not enabled. */ public static function read_all( - mixed $parent_id = null, int $limit = 0, int $offset = 0, bool $reverse = false, ): ModelSet|Model { # Variables $model_name = get_called_class(); - $model = new $model_name(parent_id: $parent_id); + $model = new $model_name(); $model_objects = []; - $is_parent_model_many = $model->is_parent_model_many(); $requests_pagination = ($limit or $offset); - $cache_exempt = ($requests_pagination or $reverse or isset($parent_id)); - - # Throw an error if this Model has a $many parent Model, but no parent Model ID was given - if ($is_parent_model_many and !isset($parent_id)) { - throw new ValidationError( - message: 'Field `parent_id` is required to read all.', - response_id: 'MODEL_PARENT_ID_REQUIRED', - ); - } + $cache_exempt = ($requests_pagination or $reverse); # Throw an error if pagination was requested on a Model without $many enabled if (!$model->many and $requests_pagination) { @@ -1946,8 +1965,11 @@ class Model { # Loop through each internal object and create a Model object for it foreach ($internal_objects as $internal_id => $internal_object) { - # Ensure numeric IDs are converted to integers + # Populate the ID and parent ID values where applicable + $internal_id = array_key_exists('id', $internal_object) ? $internal_object['id'] : $internal_id; $internal_id = is_numeric($internal_id) ? (int) $internal_id : $internal_id; + $parent_id = array_key_exists('parent_id', $internal_object) ? $internal_object['parent_id'] : null; + $parent_id = is_numeric($parent_id) ? (int) $parent_id : $parent_id; # Create a new Model object for this internal object and assign its ID $model_object = new $model(id: $internal_id, parent_id: $parent_id, skip_init: true); @@ -1978,8 +2000,6 @@ class Model { * @param array $query_params An array of query parameters. * @param array $excluded An array of field names to exclude from the query. This is helpful when * query data may have extra values that you do not want to include in the query. - * @param mixed|null $parent_id Specifies the ID of the parent Model to read all objects from. This is required for - * $many Models with a $parent_model_class. This value has no affect otherwise. * @param int $offset The starting point in the dataset to be used with $limit. This is only applicable to $many * enabled Models. * @param int $limit The maximum number of Model objects to retrieve. This is only applicable to $many @@ -1993,7 +2013,6 @@ class Model { public static function query( array $query_params = [], array $excluded = [], - mixed $parent_id = null, int $limit = 0, int $offset = 0, bool $reverse = false, @@ -2007,11 +2026,11 @@ class Model { # If no query or sort parameters were provided, just run read_all() with pagination for optimal performance if (!$query_params and $sort_by === null) { - return self::read_all(parent_id: $parent_id, limit: $limit, offset: $offset, reverse: $reverse); + return self::read_all(limit: $limit, offset: $offset, reverse: $reverse); } # Perform the query against all Model objects for this Model first - $modelset = self::read_all(parent_id: $parent_id)->query(query_params: $query_params, excluded: $excluded); + $modelset = self::read_all()->query(query_params: $query_params, excluded: $excluded); # Sort the set if a sort field was provided if ($sort_by) { @@ -2277,7 +2296,7 @@ class Model { # Keep track of all existing objects for this Model before anything is changed. This will be passed back # into `apply_replace_all()` so that method can gracefully bring down these objects before replacing them # if needed. - $initial_objects = $this->read_all(parent_id: $this->parent_id); + $initial_objects = $this->query(parent_id: $this->parent_id); # Obtain any Models that are deemed protected to ensure they do not removed in the next step. $new_objects = new ModelSet(); @@ -2453,7 +2472,6 @@ class Model { $model_objects = self::query( query_params: $query_params, excluded: $excluded, - parent_id: $parent_id, limit: $limit, offset: $offset, ); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc index 5a7e572c..57f65251 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc @@ -206,8 +206,9 @@ class ModelSet { continue; } - # Obtain the value for this field. If it is the ID, handle accordingly. + # Obtain the value for this field. If it is the ID or parent ID, handle accordingly. $field_value = $field === 'id' ? $model_object->id : $model_object->$field->value; + $field_value = $field === 'parent_id' ? $model_object->parent_id : $field_value; # Ensure the filter matches $filter = QueryFilter::get_by_name($filter_name); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc new file mode 100644 index 00000000..640cd807 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/dns_resolver/host_override/aliases'; + $this->model_name = 'DNSResolverHostOverrideAlias'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc index 073d03eb..98d42bba 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc @@ -461,7 +461,7 @@ class DHCPServer extends Model { */ public function validate_staticarp(bool $staticarp): bool { # Do not allow `staticarp` to be enabled if there are any configured static mappings without IPs - $static_mappings = DHCPServerStaticMapping::read_all(parent_id: $this->id); + $static_mappings = DHCPServerStaticMapping::query(parent_id: $this->id); foreach ($static_mappings->model_objects as $static_mapping) { if ($staticarp and !$static_mapping->ipaddr->value) { throw new ValidationError( @@ -484,7 +484,7 @@ class DHCPServer extends Model { */ private function get_range_overlap(string $range_from, string $range_to): ?Model { # Ensure range does not overlap with existing static mappings - $static_mappings = DHCPServerStaticMapping::read_all(parent_id: $this->id); + $static_mappings = DHCPServerStaticMapping::query(parent_id: $this->id); foreach ($static_mappings->model_objects as $static_mapping) { if (is_inrange_v4($static_mapping->ipaddr->value, $range_from, $range_to)) { return $static_mapping; @@ -492,7 +492,7 @@ class DHCPServer extends Model { } # Ensure range does not overlap with existing address pools `range_from` or `range_to` addresses - $pools = DHCPServerAddressPool::read_all(parent_id: $this->id); + $pools = DHCPServerAddressPool::query(parent_id: $this->id); foreach ($pools->model_objects as $pool) { if (is_inrange_v4($pool->range_from->value, $range_from, $range_to)) { return $pool; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerAddressPool.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerAddressPool.inc index b4fd9249..b630bde9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerAddressPool.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerAddressPool.inc @@ -314,7 +314,7 @@ class DHCPServerAddressPool extends Model { } # Ensure range does not overlap with existing static mappings - $static_mappings = DHCPServerStaticMapping::read_all(parent_id: $this->parent_id); + $static_mappings = DHCPServerStaticMapping::query(parent_id: $this->parent_id); foreach ($static_mappings->model_objects as $static_mapping) { if (is_inrange_v4($static_mapping->ipaddr->value, $range_from, $range_to)) { return $static_mapping; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc index 224eed99..14123064 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerStaticMapping.inc @@ -234,7 +234,7 @@ class DHCPServerStaticMapping extends Model { } # Ensure IP is not reserved by any existing DHCPServerAddressPools - $pools = DHCPServerAddressPool::read_all(parent_id: $this->parent_id); + $pools = DHCPServerAddressPool::query(parent_id: $this->parent_id); foreach ($pools->model_objects as $pool) { if (is_inrange_v4($ipaddr, $pool->range_from->value, $pool->range_to->value)) { return $pool; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRoutingGatewayGroupPriorityTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRoutingGatewayGroupPriorityTestCase.inc index 3842b82b..2fa41f28 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRoutingGatewayGroupPriorityTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRoutingGatewayGroupPriorityTestCase.inc @@ -52,7 +52,7 @@ class APIModelsRoutingGatewayGroupPriorityTestCase extends TestCase { $gateway_prio->create(); # Ensure the gateway group object was created - $this->assert_equals(RoutingGatewayGroupPriority::read_all(parent_id: $gateway_group->id)->count(), 2); + $this->assert_equals(RoutingGatewayGroupPriority::query(parent_id: $gateway_group->id)->count(), 2); # Update the gateway group priority object with new values $gateway_prio->tier->value = 3; @@ -67,7 +67,7 @@ class APIModelsRoutingGatewayGroupPriorityTestCase extends TestCase { # Delete the gateway group priority object and ensure it was deleted $gateway_prio->delete(); - $this->assert_equals(RoutingGatewayGroupPriority::read_all(parent_id: $gateway_group->id)->count(), 1); + $this->assert_equals(RoutingGatewayGroupPriority::query(parent_id: $gateway_group->id)->count(), 1); # Cleanup $gateway_group->delete(); From 17022aab49c1c437c9a94e700cab36d4e9b5502b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Dec 2025 14:19:38 -0700 Subject: [PATCH 18/73] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Core/Model.inc | 17 ++++------------- 1 file changed, 4 insertions(+), 13 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 f1d965e9..c2227cd2 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 @@ -882,8 +882,8 @@ class Model { $child_configs = $this->get_config("$parent_model->config_path/$parent_id/$this->config_path", []); foreach ($child_configs as $child_id => $child_config) { - $child_config["parent_id"] = $parent_id; - $child_config["id"] = $child_id; + $child_config['parent_id'] = $parent_id; + $child_config['id'] = $child_id; $internal_objects[] = $child_config; } } @@ -1927,11 +1927,7 @@ class Model { * @return ModelSet|Model Returns a ModelSet of Models if `many` is enabled or a single Model object if `many` is * not enabled. */ - public static function read_all( - int $limit = 0, - int $offset = 0, - bool $reverse = false, - ): ModelSet|Model { + public static function read_all(int $limit = 0, int $offset = 0, bool $reverse = false): ModelSet|Model { # Variables $model_name = get_called_class(); $model = new $model_name(); @@ -2469,12 +2465,7 @@ class Model { } # Obtain all Model objects that match the query of objects to be deleted - $model_objects = self::query( - query_params: $query_params, - excluded: $excluded, - limit: $limit, - offset: $offset, - ); + $model_objects = self::query(query_params: $query_params, excluded: $excluded, limit: $limit, offset: $offset); # Reset the object cache as the config has changed Model::$object_cache = []; From 35bac8a1e91a5ad0c64f3a16e45e4a56f19f2a88 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 7 Dec 2025 10:04:32 -0700 Subject: [PATCH 19/73] fix: only load each parent model object once --- .../files/usr/local/pkg/RESTAPI/Core/Model.inc | 12 +++++++----- 1 file changed, 7 insertions(+), 5 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 c2227cd2..b1f4c759 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 @@ -375,7 +375,7 @@ class Model { */ public function __sleep() { # Variables - $excluded_properties = ['initial_object', 'client', 'parent_model', 'related_objects']; + $excluded_properties = ['initial_object', 'client', 'parent_model', 'related_objects', 'object_cache']; $properties = array_keys(get_object_vars($this)); # Filter out excluded properties from the list of properties to serialize @@ -880,8 +880,11 @@ class Model { foreach ($parent_configs as $parent_id => $parent_config) { # Obtain the list of child objects from this parent config $child_configs = $this->get_config("$parent_model->config_path/$parent_id/$this->config_path", []); + $parent_model = new $parent_model_class(id: $parent_id, skip_init: true); + $parent_model->from_internal_object($parent_config); foreach ($child_configs as $child_id => $child_config) { + $child_config['parent_model'] = $parent_model; $child_config['parent_id'] = $parent_id; $child_config['id'] = $child_id; $internal_objects[] = $child_config; @@ -1800,10 +1803,10 @@ class Model { # Obtain all Model objects for this Model and sort them by the requested criteria $modelset = $this->query( - parent_id: $this->parent_id, sort_by: $this->sort_by, sort_order: $this->sort_order, sort_flags: $this->sort_flags, + parent_id: $this->parent_id, ); # Loop through the sorted object and assign it's internal value @@ -1969,12 +1972,11 @@ class Model { # Create a new Model object for this internal object and assign its ID $model_object = new $model(id: $internal_id, parent_id: $parent_id, skip_init: true); - - # Obtain the parent Model object if this Model has a parent Model class assigned - $model_object->parent_model = $model->parent_model; + $model_object->parent_model = $internal_object['parent_model'] ?? null; # Populate the Model object using its current internal values and add it to the array of all Model objects $model_object->from_internal_object($internal_object); + $model_objects[] = $model_object; } From 3cf3733a9c7d57a5b738721f7f1227960a66d2b6 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 7 Dec 2025 14:21:51 -0700 Subject: [PATCH 20/73] fix: allow endpoints for child models to be many enabled --- .../pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc index 33dde570..a1dae304 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc @@ -88,19 +88,6 @@ class APICoreEndpointTestCase extends TestCase { $endpoint->check_construct(); }, ); - - # Ensure endpoints cannot be many if th model has a parent model - $this->assert_throws_response( - response_id: 'ENDPOINT_MANY_WHEN_MODEL_HAS_PARENT', - code: 500, - callable: function () { - # Mock a 'many' endpoint with a non many model - $endpoint = new ServicesDNSResolverHostOverrideAliasEndpoint(); - $endpoint->many = true; - $endpoint->request_method_options = ['GET']; - $endpoint->check_construct(); - }, - ); } public function test_get_pagination_resource_links(): void { From c33f8886a8192f894d5f85cf08c6ba11aa3d2120 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 8 Dec 2025 23:20:27 -0700 Subject: [PATCH 21/73] refactor: prevent child model caching from clobbering from internal loads --- .../usr/local/pkg/RESTAPI/Core/BaseTraits.inc | 8 +- .../usr/local/pkg/RESTAPI/Core/Model.inc | 119 ++++++---- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 213 ++++++++++++++++++ .../usr/local/pkg/RESTAPI/Core/TestCase.inc | 2 +- .../usr/local/pkg/RESTAPI/Models/Package.inc | 2 +- 5 files changed, 288 insertions(+), 56 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc index ce99c8d2..b877b8de 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc @@ -14,16 +14,16 @@ trait BaseTraits { * Obtains the shortname of the called class. * @return string The shortname for this object's class. */ - public function get_class_shortname(): string { - return (new ReflectionClass($this))->getShortName(); + public static function get_class_shortname(): string { + return (new ReflectionClass(static::class))->getShortName(); } /** * Obtains the fully qualified name of the called class. * @return string The FQN for this object's class. */ - public function get_class_fqn(): string { - return (new ReflectionClass($this))->getName(); + public static function get_class_fqn(): string { + return (new ReflectionClass(static::class))->getName(); } /** 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 b1f4c759..da1c5da9 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 @@ -144,13 +144,6 @@ class Model { */ public Cache|null $cache = null; - /** - * @var array $object_cache - * An array used internally to cache already loaded ModelSets for any Model class. This prevents redundant loading - * of ModelSet objects during operations that may require multiple references to the same ModelSet. - */ - public static array $object_cache = []; - /** * @var ModelSet $related_objects * A ModelSet containing foreign Model objects related to this Model. These are primarily populated by @@ -375,7 +368,7 @@ class Model { */ public function __sleep() { # Variables - $excluded_properties = ['initial_object', 'client', 'parent_model', 'related_objects', 'object_cache']; + $excluded_properties = ['initial_object', 'client', 'parent_model', 'related_objects']; $properties = array_keys(get_object_vars($this)); # Filter out excluded properties from the list of properties to serialize @@ -409,7 +402,7 @@ class Model { */ private function set_construct_options(array $options): array { # Set the async flag if given. Ensure this defaults to true. - $this->async = isset($options['async']) ? $options['async'] : true; + $this->async = $options['async'] ?? true; unset($options['async']); # Set the placement index if given and is a positive integer @@ -529,12 +522,20 @@ class Model { return new $class(); } + /** + * Provides access to the ModelCache singleton instance. + * @return ModelCache The ModelCache singleton instance. + */ + public static function get_model_cache(): ModelCache { + return ModelCache::get_instance(); + } + /** * Clears the object cache for this Model class. */ - public static function clear_object_cache(): void { + public static function clear_model_cache(): void { # Clear the object cache - self::$object_cache = []; + self::get_model_cache()::clear(); } /** @@ -655,7 +656,7 @@ class Model { */ public static function set_config(string $path, mixed $value, mixed $default = null): mixed { # Clear the object cache for this Model class since config is being modified - self::clear_object_cache(); + self::clear_model_cache(); return config_set_path(self::normalize_config_path($path), $value, $default); } @@ -701,7 +702,7 @@ class Model { */ public static function del_config(string $path): mixed { # Clear the object cache for this Model class since config is being modified - self::clear_object_cache(); + self::clear_model_cache(); return config_del_path(self::normalize_config_path($path)); } @@ -739,7 +740,7 @@ class Model { session_destroy(); # Clear the object cache for this Model class since config has been modified - self::clear_object_cache(); + self::clear_model_cache(); # If a subsystem is specified for this Model, mark it as dirty if ($this->subsystem) { @@ -770,7 +771,7 @@ class Model { global $config; # Clear the object cache for all Model classes since config is being reloaded - self::clear_object_cache(); + self::clear_model_cache(); $config = parse_config(parse: $force_parse); } @@ -867,27 +868,34 @@ class Model { /** * Recursively obtains all internal objects for this Model from all parent objects in config. This method is only * applicable to Models with a `parent_model_class` assigned. - * @return array An array of all internal objects for this Model from all parent objects in + * @return array An array containing the internal object and its context information needed to reconstruct the + * Model object later. This array will always be structured as the following: + * [ + * "_parent_model" => Model, // The parent Model object + * "_id" => string|int, // The ID of the internal object + * "_internal_object" => array, // The internal object data + * ] */ protected function get_internal_objects_from_all_parents(): array { - # Variables - $internal_objects = []; + /**@var Model $parent_model_class The parent Model class assigned to this Model. */ $parent_model_class = "\\RESTAPI\\Models\\$this->parent_model_class"; - $parent_model = new $parent_model_class(skip_init: true); - $parent_configs = $this->get_config($parent_model->config_path, []); + $parent_modelset = $parent_model_class::read_all(); + $internal_objects = []; # Check for internal objects from all parents - foreach ($parent_configs as $parent_id => $parent_config) { - # Obtain the list of child objects from this parent config - $child_configs = $this->get_config("$parent_model->config_path/$parent_id/$this->config_path", []); - $parent_model = new $parent_model_class(id: $parent_id, skip_init: true); - $parent_model->from_internal_object($parent_config); + foreach ($parent_modelset->model_objects as $parent_model) { + # Obtain the internal config for + $child_configs = $this->get_config( + "$parent_model->config_path/$parent_model->id/$this->config_path", [] + ); foreach ($child_configs as $child_id => $child_config) { - $child_config['parent_model'] = $parent_model; - $child_config['parent_id'] = $parent_id; - $child_config['id'] = $child_id; - $internal_objects[] = $child_config; + # Retain internal object context information needed to reconstruct the Model object later + $internal_objects[] = [ + "_parent_model" => $parent_model, + "_id" => $child_id, + "_internal_object" => $child_config, + ]; } } @@ -898,11 +906,13 @@ class Model { * Obtains all internal objects for this Model. When a `config_path` is specified, this method will obtain the * internal objects directly from config. When an `internal_callable` is assigned, this method will return * the output of the assigned callable. + * @param bool $from_all_parents When set to `true`, this method will obtain internal objects from all parent + * objects in config. This is only applicable to Models with a `parent_model_class` assigned. * @throws ServerError When neither a `config_path` nor a `internal_callable` are assigned to this model, OR both a * `config_path` and a `internal_callable` are assigned to this model * @return array The array of internal objects without any additional processing performed. */ - public function get_internal_objects(): array { + public function get_internal_objects(bool $from_all_parents = false): array { global $mock_internal_objects; # Throw an error if both `config_path` and `internal_callable` are set. @@ -916,8 +926,8 @@ class Model { elseif ($mock_internal_objects) { $internal_objects = $mock_internal_objects; } - # Obtain all internal objects from all parents if a parent Model class is assigned - elseif ($this->parent_model_class) { + # Obtain all internal objects from all parents if a parent Model class is assigned and all parents is requested + elseif ($from_all_parents and $this->parent_model_class) { $internal_objects = $this->get_internal_objects_from_all_parents(); } # Obtain the internal objects from the config path if specified @@ -977,8 +987,10 @@ class Model { # When `many` is enabled, obtain the object with our ID. Otherwise, just assign the direct object $internal_object = $this->many ? $internal_objects[$this->id] : $internal_objects; - $this->from_internal_object($internal_object); + + # Re-index the ModelCache for this object since it has been refreshed from internal + self::get_model_cache()::cache_model($this); } /** @@ -1932,7 +1944,7 @@ class Model { */ public static function read_all(int $limit = 0, int $offset = 0, bool $reverse = false): ModelSet|Model { # Variables - $model_name = get_called_class(); + $model_name = self::get_class_fqn(); $model = new $model_name(); $model_objects = []; $requests_pagination = ($limit or $offset); @@ -1948,12 +1960,12 @@ class Model { } # Load from cache if it is not exempt and cached objects exist - if (!$cache_exempt and Model::$object_cache[$model_name]) { - return Model::$object_cache[$model_name]; + if (!$cache_exempt and self::get_model_cache()::has_modelset($model_name)) { + return Model::get_model_cache()::fetch_modelset($model_name); } - # Obtain all of this Model's internally stored objects - $internal_objects = $model->get_internal_objects(); + # Obtain all of this Model's internally stored objects, including those from parent Models if applicable + $internal_objects = $model->get_internal_objects(from_all_parents: true); # For non `many` Models, wrap the internal object in an array so we can loop $internal_objects = $model->many ? $internal_objects : [$internal_objects]; @@ -1964,15 +1976,19 @@ class Model { # Loop through each internal object and create a Model object for it foreach ($internal_objects as $internal_id => $internal_object) { - # Populate the ID and parent ID values where applicable - $internal_id = array_key_exists('id', $internal_object) ? $internal_object['id'] : $internal_id; + # For Models with parent Models, the internal object always contains extra context data about + # the parent Model class. See the get_internal_objects_from_all_parents() method for more information. + $internal_id = $model->parent_model_class ? $internal_object["_id"] : $internal_id; + $parent_model = $model->parent_model_class ? $internal_object["_parent_model"] : null; + $parent_id = $parent_model ? $parent_model->id : null; + $internal_object = $parent_model ? $internal_object["_internal_object"] : $internal_object; + + # Normalize IDs $internal_id = is_numeric($internal_id) ? (int) $internal_id : $internal_id; - $parent_id = array_key_exists('parent_id', $internal_object) ? $internal_object['parent_id'] : null; - $parent_id = is_numeric($parent_id) ? (int) $parent_id : $parent_id; # Create a new Model object for this internal object and assign its ID $model_object = new $model(id: $internal_id, parent_id: $parent_id, skip_init: true); - $model_object->parent_model = $internal_object['parent_model'] ?? null; + $model_object->parent_model = $parent_model; # Populate the Model object using its current internal values and add it to the array of all Model objects $model_object->from_internal_object($internal_object); @@ -1985,7 +2001,7 @@ class Model { # For many models, cache the ModelSet if not exempt if ($model->many and !$cache_exempt) { - Model::$object_cache[$model_name] = new ModelSet(model_objects: $model_objects); + self::get_model_cache()::cache_modelset($model_name, $modelset); } # For many enabled Models return a ModelSet, otherwise return a single Model object @@ -2088,6 +2104,9 @@ class Model { # Obtain the requested Model object from config by ID $this->from_internal(); + # Cache the newly read Model object + self::get_model_cache()::cache_model($this); + return $this; } @@ -2149,7 +2168,7 @@ class Model { } # Reset the object cache as the config has changed - Model::$object_cache = []; + self::clear_model_cache(); # Return the current representation of this object return $this; @@ -2242,7 +2261,7 @@ class Model { } # Reset the object cache as the config has changed - Model::$object_cache = []; + self::clear_model_cache(); # Return the current representation of this object return $this; @@ -2338,7 +2357,7 @@ class Model { } # Reset the object cache as the config has changed - Model::$object_cache = []; + self::clear_model_cache(); return $new_objects; } @@ -2426,7 +2445,7 @@ class Model { } # Reset the object cache as the config has changed - Model::$object_cache = []; + self::clear_model_cache(); # Return the current representation of this object return $this; @@ -2470,7 +2489,7 @@ class Model { $model_objects = self::query(query_params: $query_params, excluded: $excluded, limit: $limit, offset: $offset); # Reset the object cache as the config has changed - Model::$object_cache = []; + Model::clear_model_cache(); # Delete the Model objects that matched the query return $model_objects->delete(); @@ -2484,7 +2503,7 @@ class Model { $model_objects = self::read_all(); # Reset the object cache as the config has changed - Model::$object_cache = []; + Model::clear_model_cache(); # Delete all Model objects for this Model return $model_objects->delete(); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc new file mode 100644 index 00000000..f1a1570a --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -0,0 +1,213 @@ + [ + * [index field name] => [ + * [index field value] => Model object + * ] + * ] + */ + public static array $index = []; + + /** + * @var array $cache An associative array that contains cached Model objects that have been loaded into memory as + * a complete ModelSet of every object of that Model type. Structured as: [Model class name] => ModelSet object + */ + public static array $cache = []; + + /** + * Retrieves the singleton instance of the ModelCache. + * @return self The singleton instance of the ModelCache. + */ + public static function get_instance(): self { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Checks if a given Model class has a cached ModelSet. + * @param string $model_class The Model class name to check in the cache. + * @return bool True if a cached ModelSet exists for the specified Model class, false otherwise. + */ + public static function has_modelset(string $model_class): bool { + return isset(self::$cache[$model_class]); + } + + /** + * Caches a ModelSet in the ModelCache. + * @param string $model_class The Model class name of the ModelSet to cache. + * @param ModelSet $model_set The ModelSet object to cache. + */ + public static function cache_modelset(string $model_class, ModelSet $model_set): void { + self::$cache[$model_class] = $model_set; + } + + /** + * Fetches a cached ModelSet by its Model class name. + * @param string $model_class The Model class name to load from the cache. + * @return ModelSet The cached ModelSet + * @throws NotFoundError If no cached ModelSet exists for the specified Model class. + */ + public static function fetch_modelset(string $model_class): ModelSet { + if (!self::has_modelset($model_class)) { + throw new NotFoundError( + message: "No cached ModelSet found for Model class '$model_class'.", + response_id: 'MODEL_CACHE_MODELSET_NOT_FOUND', + ); + } + return self::$cache[$model_class]; + } + + /** + * Indexes the ModelSet cache for a given Model class by a specified field. This method will populate + * the $index array for the specified Model class and index field. + * @param string $model_class The Model class name whose ModelSet is to be indexed. If no cached ModelSet exists, + * one will be created by reading all Model objects from the data source. + * @param string $index_field The field name to index the Model objects by. + * @return array An associative array of indexed Model objects. + */ + public static function index_modelset_by_field(string $model_class, string $index_field): array + { + # First, check if this Model class has already been indexed by this field + if (isset(self::$index[$model_class][$index_field])) { + return self::$index[$model_class][$index_field]; + } + + # Ensure a cached ModelSet exists for the specified Model class + $model = new $model_class(); + $model_set = $model->read_all(); + + # Create an associative array to hold the indexed Model objects + $indexed_models = []; + + # Index each Model object in the ModelSet by the specified field + foreach ($model_set->model_objects as $model) { + $index_value = $index_field === 'id' ? $model->id : $model->$index_field->value; + $indexed_models[$index_value] = $model; + } + + # Store the indexed models in the ModelCache index and return them + self::$index[$model_class][$index_field] = $indexed_models; + return $indexed_models; + } + + /** + * Checks if a given Model class has a cached Model object by its index field/value. + * @param string $model_class The Model class name to check in the cache. + * @param string $index_field The index field name to look up the Model object by + * @param mixed $index_value The index field value to look up the Model object by + * @return bool True if a cached Model object exists for the specified Model class and index + */ + public static function has_model( + string $model_class, + string $index_field = 'id', + mixed $index_value = null, + ): bool { + # Cache is always a miss if the Model class is not indexed + if (!self::$index[$model_class]) { + return false; + } + + # Cache is a hit for non-many enabled models if a single Model object is indexed under the Model class + if (self::$index[$model_class] instanceof Model) { + return true; + } + + # Otherwise, cache is only a hit for many enabled models if the index field/value exists in the index + return isset(self::$index[$model_class][$index_field][$index_value]); + } + + /** + * Caches a Model object in the ModelCache by its index field/value. + * @param Model $model The Model object to cache. + * @param string $index_field The field to index the Model object by. Only used for many enabled Models. + */ + public static function cache_model(Model $model, string $index_field = 'id'): void { + # Determine the Model class of the Model object + $model_class = $model->get_class_fqn(); + + # Ensure an index collection exists for this model's class + if (!isset(self::$index[$model_class])) { + self::$index[$model_class] = []; + } + + # Ensure an index exists for this model's index field if many is enabled + if ($model->many and !isset(self::$index[$model_class][$index_field])) { + self::$index[$model_class][$index_field] = []; + } + + # For many enabled models, index the model under the specified index field and value + if ($model->many) { + $index_value = $index_field === 'id' ? $model->id : $model->$index_field->value; + self::$index[$model_class][$index_field][$index_value] = $model; + return; + } + + # Otherwise, index the model under the Model class only + self::$index[$model_class] = $model; + } + + /** + * Fetches a cached Model object by its Model class name and index field/value. + * @param string $model_class The Model class name to load from the cache. + * @param string $index_field The index field name to look up the Model object by. Only for many enabled Models. + * @param mixed $index_value The index field value to look up the Model object by. Only for many enabled Models. + * @return Model The cached Model object + * @throws NotFoundError If no cached Model object exists for the specified Model class and index. + */ + public static function fetch_model( + string $model_class, + string $index_field = 'id', + mixed $index_value = null, + ): Model + { + if (!self::has_model($model_class, $index_field, $index_value)) { + throw new NotFoundError( + message: "No cached Model found for Model class '$model_class' with " . + "index field '$index_field' and index value '$index_value'.", + response_id: 'MODEL_CACHE_MODEL_NOT_FOUND', + ); + } + + # For many enabled models, return the Model object indexed under the specified index field and value + if (isset(self::$index[$model_class][$index_field][$index_value])) { + return self::$index[$model_class][$index_field][$index_value]; + } + + # Otherwise, return the Model object indexed under the Model class only + return self::$index[$model_class]; + } + + /** + * Clears the ModelCache of all cached ModelSets and indexed Model objects. + */ + public static function clear(): void + { + self::$index = []; + self::$cache = []; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc index 71d75bba..e87a2ac7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc @@ -116,7 +116,7 @@ class TestCase { protected function restore_config(array $original_config): void { global $config; $config = $original_config; - Model::clear_object_cache(); + Model::clear_model_cache(); write_config("Restored config after API test '{$this->method}'"); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc index 0d9c92b7..6a69db42 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc @@ -90,7 +90,7 @@ class Package extends Model { pkg_install($this->name->value); # Clear the object cache to ensure we get fresh data - $this->clear_object_cache(); + $this->clear_model_cache(); # Locate this package's current ID and load info about the package installed $this->id = $this->query(name: $this->name->value)->first()->id; From af2d0e1f4470fddda3b77f88752d4c105849578c Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 8 Dec 2025 23:21:21 -0700 Subject: [PATCH 22/73] style: run prettier on changed files --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 16 +++++++--------- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 19 ++++++------------- 2 files changed, 13 insertions(+), 22 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 da1c5da9..bb90728a 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 @@ -885,16 +885,14 @@ class Model { # Check for internal objects from all parents foreach ($parent_modelset->model_objects as $parent_model) { # Obtain the internal config for - $child_configs = $this->get_config( - "$parent_model->config_path/$parent_model->id/$this->config_path", [] - ); + $child_configs = $this->get_config("$parent_model->config_path/$parent_model->id/$this->config_path", []); foreach ($child_configs as $child_id => $child_config) { # Retain internal object context information needed to reconstruct the Model object later $internal_objects[] = [ - "_parent_model" => $parent_model, - "_id" => $child_id, - "_internal_object" => $child_config, + '_parent_model' => $parent_model, + '_id' => $child_id, + '_internal_object' => $child_config, ]; } } @@ -1978,10 +1976,10 @@ class Model { foreach ($internal_objects as $internal_id => $internal_object) { # For Models with parent Models, the internal object always contains extra context data about # the parent Model class. See the get_internal_objects_from_all_parents() method for more information. - $internal_id = $model->parent_model_class ? $internal_object["_id"] : $internal_id; - $parent_model = $model->parent_model_class ? $internal_object["_parent_model"] : null; + $internal_id = $model->parent_model_class ? $internal_object['_id'] : $internal_id; + $parent_model = $model->parent_model_class ? $internal_object['_parent_model'] : null; $parent_id = $parent_model ? $parent_model->id : null; - $internal_object = $parent_model ? $internal_object["_internal_object"] : $internal_object; + $internal_object = $parent_model ? $internal_object['_internal_object'] : $internal_object; # Normalize IDs $internal_id = is_numeric($internal_id) ? (int) $internal_id : $internal_id; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index f1a1570a..46cbf390 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -90,8 +90,7 @@ class ModelCache { * @param string $index_field The field name to index the Model objects by. * @return array An associative array of indexed Model objects. */ - public static function index_modelset_by_field(string $model_class, string $index_field): array - { + public static function index_modelset_by_field(string $model_class, string $index_field): array { # First, check if this Model class has already been indexed by this field if (isset(self::$index[$model_class][$index_field])) { return self::$index[$model_class][$index_field]; @@ -122,11 +121,7 @@ class ModelCache { * @param mixed $index_value The index field value to look up the Model object by * @return bool True if a cached Model object exists for the specified Model class and index */ - public static function has_model( - string $model_class, - string $index_field = 'id', - mixed $index_value = null, - ): bool { + public static function has_model(string $model_class, string $index_field = 'id', mixed $index_value = null): bool { # Cache is always a miss if the Model class is not indexed if (!self::$index[$model_class]) { return false; @@ -145,7 +140,7 @@ class ModelCache { * Caches a Model object in the ModelCache by its index field/value. * @param Model $model The Model object to cache. * @param string $index_field The field to index the Model object by. Only used for many enabled Models. - */ + */ public static function cache_model(Model $model, string $index_field = 'id'): void { # Determine the Model class of the Model object $model_class = $model->get_class_fqn(); @@ -183,12 +178,11 @@ class ModelCache { string $model_class, string $index_field = 'id', mixed $index_value = null, - ): Model - { + ): Model { if (!self::has_model($model_class, $index_field, $index_value)) { throw new NotFoundError( message: "No cached Model found for Model class '$model_class' with " . - "index field '$index_field' and index value '$index_value'.", + "index field '$index_field' and index value '$index_value'.", response_id: 'MODEL_CACHE_MODEL_NOT_FOUND', ); } @@ -205,8 +199,7 @@ class ModelCache { /** * Clears the ModelCache of all cached ModelSets and indexed Model objects. */ - public static function clear(): void - { + public static function clear(): void { self::$index = []; self::$cache = []; } From 2a62c8a7de0b7bb287861fae3008caa143fe51c4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 14 Dec 2025 15:12:06 -0700 Subject: [PATCH 23/73] feat: implement model caching and indexing --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 13 +- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 81 ++++--- .../Tests/APICoreModelCacheTestCase.inc | 199 ++++++++++++++++++ .../RESTAPI/Tests/APICoreModelTestCase.inc | 41 ++++ 4 files changed, 293 insertions(+), 41 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc index bb90728a..e7326eb5 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 @@ -986,9 +986,6 @@ class Model { # When `many` is enabled, obtain the object with our ID. Otherwise, just assign the direct object $internal_object = $this->many ? $internal_objects[$this->id] : $internal_objects; $this->from_internal_object($internal_object); - - # Re-index the ModelCache for this object since it has been refreshed from internal - self::get_model_cache()::cache_model($this); } /** @@ -1946,7 +1943,7 @@ class Model { $model = new $model_name(); $model_objects = []; $requests_pagination = ($limit or $offset); - $cache_exempt = ($requests_pagination or $reverse); + $cache_exempt = ($requests_pagination or $reverse or $model->internal_callable); # Throw an error if pagination was requested on a Model without $many enabled if (!$model->many and $requests_pagination) { @@ -2102,9 +2099,6 @@ class Model { # Obtain the requested Model object from config by ID $this->from_internal(); - # Cache the newly read Model object - self::get_model_cache()::cache_model($this); - return $this; } @@ -2467,7 +2461,6 @@ class Model { public static function delete_many( array $query_params = [], array $excluded = [], - mixed $parent_id = null, int $limit = 0, int $offset = 0, ...$vl_query_params, @@ -2487,7 +2480,7 @@ class Model { $model_objects = self::query(query_params: $query_params, excluded: $excluded, limit: $limit, offset: $offset); # Reset the object cache as the config has changed - Model::clear_model_cache(); + self::clear_model_cache(); # Delete the Model objects that matched the query return $model_objects->delete(); @@ -2501,7 +2494,7 @@ class Model { $model_objects = self::read_all(); # Reset the object cache as the config has changed - Model::clear_model_cache(); + self::clear_model_cache(); # Delete all Model objects for this Model return $model_objects->delete(); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index 46cbf390..82b0f7f8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -3,6 +3,7 @@ namespace RESTAPI\Core; use RESTAPI\Responses\NotFoundError; +use RESTAPI\Responses\ServerError; require_once 'RESTAPI/autoloader.inc'; @@ -82,6 +83,51 @@ class ModelCache { return self::$cache[$model_class]; } + /** + * Raises an error if the given Model object does not support being indexed by the specified field. + * @param Model $model The Model object to check for uniqueness. + * @param string $index_field The index field name to check for uniqueness. + * @throws ServerError If the Model is attempting to be indexed by a non-unique field. + * @throws ServerError If the Model is not many-enabled. + */ + private static function ensure_model_supports_indexing(Model $model, string $index_field): void { + # Only many-enabled Models can be indexed + if (!$model->many) { + throw new ServerError( + message: "Cannot index Model class '" . $model->get_class_fqn() . 'because it is not many-enabled.', + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL', + ); + } + + # Models with parent model classes cannot be indexed + if ($model->parent_model_class) { + throw new ServerError( + message: "Cannot index Model class '" . + $model->get_class_fqn() . + "' because it has a parent model class '" . + $model->parent_model_class . + "'.", + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', + ); + } + + # If indexing by 'id', it's always unique + if ($index_field === 'id') { + return; + } + + # Check if the index field is unique on the Model object + if (!$model->$index_field->unique) { + throw new ServerError( + message: "Cannot index Model class '" . + $model->get_class_fqn() . + "' by non-unique field " . + "'$index_field'.", + response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', + ); + } + } + /** * Indexes the ModelSet cache for a given Model class by a specified field. This method will populate * the $index array for the specified Model class and index field. @@ -96,8 +142,11 @@ class ModelCache { return self::$index[$model_class][$index_field]; } - # Ensure a cached ModelSet exists for the specified Model class + # Ensure the Model can be indexed by the specified field $model = new $model_class(); + self::ensure_model_supports_indexing($model, $index_field); + + # Fetch or create the ModelSet for this Model class $model_set = $model->read_all(); # Create an associative array to hold the indexed Model objects @@ -136,36 +185,6 @@ class ModelCache { return isset(self::$index[$model_class][$index_field][$index_value]); } - /** - * Caches a Model object in the ModelCache by its index field/value. - * @param Model $model The Model object to cache. - * @param string $index_field The field to index the Model object by. Only used for many enabled Models. - */ - public static function cache_model(Model $model, string $index_field = 'id'): void { - # Determine the Model class of the Model object - $model_class = $model->get_class_fqn(); - - # Ensure an index collection exists for this model's class - if (!isset(self::$index[$model_class])) { - self::$index[$model_class] = []; - } - - # Ensure an index exists for this model's index field if many is enabled - if ($model->many and !isset(self::$index[$model_class][$index_field])) { - self::$index[$model_class][$index_field] = []; - } - - # For many enabled models, index the model under the specified index field and value - if ($model->many) { - $index_value = $index_field === 'id' ? $model->id : $model->$index_field->value; - self::$index[$model_class][$index_field][$index_value] = $model; - return; - } - - # Otherwise, index the model under the Model class only - self::$index[$model_class] = $model; - } - /** * Fetches a cached Model object by its Model class name and index field/value. * @param string $model_class The Model class name to load from the cache. diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc new file mode 100644 index 00000000..524e69b7 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc @@ -0,0 +1,199 @@ +assert_equals($instance_1, $instance_2); + } + + /** + * Ensures the has_modelset() method correctly identifies if a given Model class is cached in the ModelCache. + */ + public function test_model_cache_has_modelset(): void { + # Obtain and clear the ModelCache + $model_cache = ModelCache::get_instance(); + $model_cache::clear(); + + # Ensure the ModelCache does not have a cached ModelSet for the MockModelClass + $this->assert_is_false($model_cache::has_modelset('MockModelClass')); + + # Populate the ModelCache with a mock ModelSet for testing + ModelCache::$cache['MockModelClass'] = new ModelSet(); + $this->assert_is_true($model_cache::has_modelset('MockModelClass')); + + # Clear the ModelCache and ensure the ModelSet is removed + $model_cache::clear(); + $this->assert_is_false($model_cache::has_modelset('MockModelClass')); + } + + /** + * Ensures the cache_modelset() correctly caches a ModelSet in the ModelCache. + */ + public function test_model_cache_cache_modelset(): void { + # Obtain and clear the ModelCache + $model_cache = ModelCache::get_instance(); + $model_cache::clear(); + + # Create a mock ModelSet to cache + $mock_model_set = new ModelSet(); + + # Cache the mock ModelSet in the ModelCache + $model_cache::cache_modelset('MockModelClass', $mock_model_set); + + # Ensure the ModelSet was cached correctly + $this->assert_is_true($model_cache::has_modelset('MockModelClass')); + $this->assert_equals($model_cache::fetch_modelset('MockModelClass'), $mock_model_set); + + # Clear the ModelCache + $model_cache::clear(); + } + + /** + * Ensures the fetch_modelset() method correctly retrieves a cached ModelSet from the ModelCache. + */ + public function test_model_cache_fetch_nonexisting_modelset_throws_error(): void { + # Obtain and clear the ModelCache + $model_cache = ModelCache::get_instance(); + $model_cache::clear(); + + # Ensure fetching a non-cached ModelSet throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_MODELSET_NOT_FOUND', + code: 404, + callable: function () { + ModelCache::fetch_modelset('MockModelClass'); + }, + ); + } + + /** + * Ensures Models must be many-enabled to be indexed in the ModelCache. + */ + public function test_model_index_must_be_many_enabled(): void { + # Ensure attempting to index a non-many enabled Model throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL', + code: 500, + callable: function () { + ModelCache::index_modelset_by_field(model_class: RESTAPISettings::get_class_fqn(), index_field: 'id'); + }, + ); + } + + /** + * Ensures we cannot index a cached ModelSet by a non-unique field. + */ + public function test_model_index_field_must_be_unique(): void { + # Ensure indexing by id is always allowed + $this->assert_does_not_throw(function () { + ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'id'); + }); + + # Ensure attempting to index the Model by the non-unique field throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', + code: 500, + callable: function () { + ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'descr'); + }, + ); + } + + /** + * Ensures we cannot index a cached ModelSet for a Model that has a parent model class. + */ + public function test_model_index_field_cannot_have_parent_model_class(): void { + # Ensure attempting to index the Model by the non-unique field throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', + code: 500, + callable: function () { + ModelCache::index_modelset_by_field( + model_class: DNSResolverHostOverrideAlias::get_class_fqn(), + index_field: 'id', + ); + }, + ); + } + + /** + * Ensures the index_modelset_by_field() method correctly indexes a cached ModelSet by the specified field. This + * test also ensures the has_model() and fetch_model() methods work as expected for indexed Model objects. + */ + public function test_model_index_modelset_by_field(): void { + # Create FirewallAlias model objects to test with + $alias1 = new FirewallAlias(data: ['name' => 'host_alias', 'descr' => 'First alias', 'type' => 'host']); + $alias2 = new FirewallAlias(data: ['name' => 'network_alias', 'descr' => 'Second alias', 'type' => 'network']); + $alias3 = new FirewallAlias(data: ['name' => 'port_alias', 'descr' => 'Third alias', 'type' => 'port']); + $alias1->create(); + $alias2->create(); + $alias3->create(); + + # Index the FirewallAlias ModelSet by the 'name' field + ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'name'); + + # Ensure the ModelCache has the indexed Models + $this->assert_is_true( + ModelCache::has_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'host_alias', + ), + ); + $this->assert_is_true( + ModelCache::has_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'network_alias', + ), + ); + $this->assert_is_true( + ModelCache::has_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'port_alias', + ), + ); + + # Fetch the indexed Models and ensure they are correct + $fetched_alias1 = ModelCache::fetch_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'host_alias', + ); + $this->assert_equals($fetched_alias1->name->value, 'host_alias'); + $fetched_alias2 = ModelCache::fetch_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'network_alias', + ); + $this->assert_equals($fetched_alias2->name->value, 'network_alias'); + $fetched_alias3 = ModelCache::fetch_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'port_alias', + ); + $this->assert_equals($fetched_alias3->name->value, 'port_alias'); + + # Clean up the created aliases + FirewallAlias::delete_all(); + } +} 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 f05ceeb9..0ac8a5c8 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 @@ -1300,4 +1300,45 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { $this->assert_equals(Model::normalize_config_path('/test//path/'), 'test/path'); $this->assert_equals(Model::normalize_config_path('test'), 'test'); } + + /** + * Checks that the read_all() method caches previously read Model objects to improve performance. + */ + public function test_read_all_caching(): void { + # Ensure the model cache is empty + FirewallAlias::get_model_cache()::clear(); + + # Create FirewallAlias models to test this + $alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); + $alias_0->create(); + $alias_1 = new FirewallAlias(name: 'alias1', type: 'port'); + $alias_1->create(); + $aliias_2 = new FirewallAlias(name: 'alias2', type: 'network'); + $aliias_2->create(); + + # Read all aliases and ensure the cache is populated + FirewallAlias::read_all(); + $this->assert_is_true( + array_key_exists(FirewallAlias::get_class_fqn(), FirewallAlias::get_model_cache()::$cache), + ); + $this->assert_equals( + count(FirewallAlias::get_model_cache()::fetch_modelset(FirewallAlias::get_class_fqn())->model_objects), + 3, + ); + + # Add another alias and ensure the cache is updated + $alias_3 = new FirewallAlias(name: 'alias3', type: 'host'); + $alias_3->create(); + FirewallAlias::read_all(); + $this->assert_equals( + count(FirewallAlias::get_model_cache()::fetch_modelset(FirewallAlias::get_class_fqn())->model_objects), + 4, + ); + + # Delete all the aliases + FirewallAlias::delete_all(); + + # Ensure the cache was cleared after deletion + $this->assert_is_false(FirewallAlias::get_model_cache()::has_modelset(FirewallAlias::get_class_fqn())); + } } From d6805c70a8a6e26b6722b21875a3b3c8d3146602 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 14 Dec 2025 15:27:20 -0700 Subject: [PATCH 24/73] test: ensure read_all() for child models contains objects from all parents --- .../RESTAPI/Tests/APICoreModelTestCase.inc | 236 +++++++++++++----- 1 file changed, 174 insertions(+), 62 deletions(-) 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 0ac8a5c8..69173b62 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 @@ -15,12 +15,14 @@ use RESTAPI\Models\SystemStatus; use RESTAPI\Models\Test; use function RESTAPI\Models\DHCPServerStaticMapping; -class APICoreModelTestCase extends RESTAPI\Core\TestCase { +class APICoreModelTestCase extends RESTAPI\Core\TestCase +{ /** * Checks that a Model object cannot be created when both an ID and representation data are provided. This * ensures there is no confusion about which object ID to use when obtaining the model from its internal value. */ - public function test_no_model_with_id_and_representation_data() { + public function test_no_model_with_id_and_representation_data() + { $this->assert_throws_response( response_id: 'MODEL_WITH_ID_AND_REPRESENTATION', code: 500, @@ -35,7 +37,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * and IP address. This is mostly commonly used by RESTAPI\Core\Endpoint to provide Models information about the client * that authenticated a REST API call. */ - public function test_model_allows_provided_auth_obj() { + public function test_model_allows_provided_auth_obj() + { # Create an RESTAPI\Core\Auth object with custom values. $test_client = new RESTAPI\Core\Auth(); $test_client->username = 'test_user'; @@ -52,7 +55,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that a default RESTAPI\Core\Auth object is created when a Model object is created if none was provided. */ - public function test_model_default_auth_obj() { + public function test_model_default_auth_obj() + { # Create a Model object and but do not provide an Auth object $test_model = new Test(); @@ -64,7 +68,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks the Model's default `verbose_name` and `verbose_name_plural` are properly derived from its class name. */ - public function test_model_default_verbose_name() { + public function test_model_default_verbose_name() + { # Create a normal Model object and a FirewallAlias Model object for testing $test_model = new Test(); $test_fw_alias = new FirewallAlias(); @@ -79,7 +84,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that we are able to read items from the internal pfSense configuration by path. */ - public function test_model_get_config_valid_path() { + public function test_model_get_config_valid_path() + { # Try to read the WAN interface configuration since it is always present in the config $this->assert_type(Model::get_config('interfaces/wan'), 'array'); } @@ -87,7 +93,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that a specified default value is returned if the config path does not lead to an existing value. */ - public function test_model_get_config_bad_path() { + public function test_model_get_config_bad_path() + { # Try to read the WAN interface configuration since it is always present in the config $this->assert_equals(Model::get_config('does/not/exists', []), []); $this->assert_equals(Model::get_config('does/not/exists', false), false); @@ -97,7 +104,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that we cannot call the default get_next_id() method if the model object does not have a config path set. */ - public function test_model_cant_get_next_id_without_config_path() { + public function test_model_cant_get_next_id_without_config_path() + { $this->assert_throws_response( response_id: 'MODEL_NEXT_ID_WITHOUT_CONFIG_PATH', code: 500, @@ -112,7 +120,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that we cannot call the default get_next_id() method if the model object does not have `many` enabled. */ - public function test_model_cant_get_next_id_without_many() { + public function test_model_cant_get_next_id_without_many() + { $this->assert_throws_response( response_id: 'MODEL_NEXT_ID_WITHOUT_MANY', code: 500, @@ -129,7 +138,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * array value. This helps prevents dangerous write actions as it indicates the 'config_path' is wrong, or there * is a problem with the pfSense configuration. */ - public function test_model_cant_get_next_id_with_dangerous_config_path() { + public function test_model_cant_get_next_id_with_dangerous_config_path() + { # Try to read the system hostname (which is a primitive string in the pfSense configuration). To get an ID, we # expect the value be to an indexed array or an empty value. $this->assert_throws_response( @@ -148,7 +158,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks that we can successfully obtain the next ID for a non-existing config path. Empty paths should always * return the first available ID, `0`. */ - public function test_model_can_get_next_id_at_empty_path() { + public function test_model_can_get_next_id_at_empty_path() + { $test_model = new Test(); $test_model->config_path = 'this/path/does/not/exist/yet'; $test_model->many = true; @@ -160,7 +171,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * checks that the ID returned for filter/rule is greater than 0 because it's generally safe to assume there * will always be at least one item in filter/rule. */ - public function test_model_can_get_next_id_from_existing_array_path() { + public function test_model_can_get_next_id_from_existing_array_path() + { $test_model = new Test(); $test_model->config_path = 'filter/rule'; $test_model->many = true; @@ -170,7 +182,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that Models can correctly convert to their internal values. */ - public function test_model_to_internal() { + public function test_model_to_internal() + { # Since the default Model does not have any Fields, use a Test Model to test this. $test_model = new Test(); $test_model->test_bool->value = true; @@ -200,7 +213,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the Model's get_fields() method properly finds Field objects and their assigned names. */ - public function test_model_get_fields() { + public function test_model_get_fields() + { # Use the Test model since the base Model has no Fields assigned. $this->assert_equals((new Test())->get_fields(), [ 'test_bool', @@ -215,7 +229,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks to ensure Models cannot have Field objects with the same internal namespace and internal name. */ - public function test_model_field_internal_name_unique_to_namespace() { + public function test_model_field_internal_name_unique_to_namespace() + { $this->assert_throws_response( response_id: 'MODEL_FIELDS_WITH_CONFLICTING_INTERNAL_NAMES', code: 500, @@ -234,7 +249,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks to ensure the validate() method properly throws an error if a required pfSense package for the Model * is missing. */ - public function test_model_validate_packages_missing_package() { + public function test_model_validate_packages_missing_package() + { $this->assert_throws_response( response_id: 'MODEL_MISSING_REQUIRED_PACKAGE', code: 404, @@ -249,7 +265,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks to ensure the validate() method is successful when we have the correct packages installed. */ - public function test_model_validate_packages_installed_package() { + public function test_model_validate_packages_installed_package() + { $test_model = new Test(); $test_model->packages = ['pfSense-pkg-RESTAPI']; # Use pfSense-pkg-RESTAPI since we know we have it. $test_model->validate(); @@ -259,7 +276,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks to ensure the validate() method properly throws an error if we cannot include a required package include * file because it is not found in the PHP path. */ - public function test_model_validate_missing_package_includes() { + public function test_model_validate_missing_package_includes() + { $this->assert_throws_response( response_id: 'MODEL_WITH_FAILED_INCLUDE', code: 500, @@ -276,7 +294,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * objects cannot be created unless the Model supports many objects OR the base Model class's `_create()` method * is overridden. */ - public function test_model_create_without_many() { + public function test_model_create_without_many() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_CREATE_METHOD', code: 500, @@ -297,7 +316,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * objects cannot be created unless the Model has a valid config path OR the base Model class's `_create()` method * is overridden. */ - public function test_model_create_without_config_path() { + public function test_model_create_without_config_path() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_CREATE_METHOD', code: 500, @@ -320,7 +340,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks the default `create()` method correctly validates and writes the Model object to config. */ - public function test_model_create() { + public function test_model_create() + { # Create a new test model object in config $test_model = new Test(); $test_model->test_bool->value = true; @@ -344,7 +365,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * objects cannot be updated unless the Model has a valid config path OR the base Model class's `_update()` method * is overridden. */ - public function test_model_update_without_config_path() { + public function test_model_update_without_config_path() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_UPDATE_METHOD', code: 500, @@ -365,7 +387,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks the deafult Model object `update()` method to ensure it throws an error if `many` is set but no `id` * value has been assigned. We must have an ID to determine which object is being updated. */ - public function test_model_update_without_id() { + public function test_model_update_without_id() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRES_ID', code: 400, @@ -381,7 +404,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks the default `update()` method correctly validates, updates and writes the Model object to config. */ - public function test_model_update() { + public function test_model_update() + { # Create a new test model object in config $test_model = new Test(); $test_model->test_bool->value = true; @@ -415,7 +439,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * objects cannot be deleted unless the Model supports many objects OR the base Model class's `_delete()` method * is overridden. */ - public function test_model_delete_without_many() { + public function test_model_delete_without_many() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_DELETE_METHOD', code: 500, @@ -436,7 +461,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * objects cannot be deleted unless the Model has a valid config path OR the base Model class's `_delete()` method * is overridden. */ - public function test_model_delete_without_config_path() { + public function test_model_delete_without_config_path() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_DELETE_METHOD', code: 500, @@ -457,7 +483,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks the default Model object `delete()` method to ensure it throws an error if `many` is set but no `id` * value has been assigned. We must have an ID to determine which object is being deleted. */ - public function test_model_delete_without_id() { + public function test_model_delete_without_id() + { $this->assert_throws_response( response_id: 'MODEL_REQUIRES_ID', code: 400, @@ -473,7 +500,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the default `delete()` method actually deletes the object from configuration. */ - public function test_model_delete() { + public function test_model_delete() + { # Ensure we cannot find the object from config after it's deleted. $this->assert_throws_response( response_id: 'MODEL_OBJECT_NOT_FOUND', @@ -495,7 +523,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Test model's `apply()` method has been overridden to write a text file to /tmp/.api_model_test_apply.txt whenever * it is called. This test checks for that file after `create()`, `update()` or `delete()` is called. */ - public function test_model_calls_apply_when_requested() { + public function test_model_calls_apply_when_requested() + { # In case a test file was leftover, try to remove the test file first unlink('/tmp/.api_models_test_apply.txt'); @@ -521,7 +550,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that all Fields specified in `unique_together_fields` must be existing Fields assigned to the Model. */ - public function test_unique_together_fields() { + public function test_unique_together_fields() + { $this->assert_throws_response( response_id: 'MODEL_UNIQUE_TOGETHER_FIELDS_WITH_UNKNOWN_FIELD', code: 500, @@ -537,7 +567,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks the unique together validation correctly requires a group of Fields to be unique from all other Model * objects of its kind. */ - public function test_unique_together_validation() { + public function test_unique_together_validation() + { # Create a Model object that uses the unique Fields $original_model_obj = new Test( data: [ @@ -572,7 +603,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that we are able to construct a Model object with an array of representation data. */ - public function test_model_constructs_from_representation() { + public function test_model_constructs_from_representation() + { # Construct a new test model object using representation data $test_model = new Test( data: [ @@ -591,7 +623,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that `to_openapi_schema()` correctly converts the Model object to an OpenAPI schema. */ - public function test_model_to_openapi_schema() { + public function test_model_to_openapi_schema() + { # Create a model to test with and generate it's OpenAPI schema $test_model = new Test(); $test_model_schema = $test_model->to_openapi_schema(); @@ -608,7 +641,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the Models with a $cache_class assigned are construct with a specific Cache object. */ - public function test_cache_class_creates_cache_object_during_construction(): void { + public function test_cache_class_creates_cache_object_during_construction(): void + { # Define a model with a cache class. Use the RESTAPIVersionReleasesCache for testing. $model = new Model(); $model->cache_class = 'RESTAPIVersionReleasesCache'; @@ -627,7 +661,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks that the Model's $placement property correctly sets the placement of the Model object in the pfSense * configuration during creation and updates. */ - public function test_set_placement(): void { + public function test_set_placement(): void + { # Create three FirewallAlias models in order $alias_0 = new FirewallAlias(name: 'test0', type: 'host'); $alias_0->create(); @@ -662,7 +697,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the get_all_model_classes() method correctly returns all Model classes that extend the Model class. */ - public function test_get_all_model_classes(): void { + public function test_get_all_model_classes(): void + { # Variables $class_fqns = Model::get_all_model_classes(); $class_shortnames = Model::get_all_model_classes(shortnames: true); @@ -678,7 +714,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that each Model class in /usr/local/pkg/RESTAPI/Models/ has the correct constructor signature. */ - public function test_model_constructor_signature(): void { + public function test_model_constructor_signature(): void + { # Variables $class_fqns = Model::get_all_model_classes(); @@ -704,7 +741,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks that the skip_init property correctly skips loading a Model's values from internal in the Model's * __construct() method when set to true. */ - public function test_skip_init(): void { + public function test_skip_init(): void + { # Create a new Test model object with skip_init set to true $test_model = new SystemStatus(skip_init: true); @@ -722,7 +760,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks that the Model::from_internal_object() correctly loads the Model object using the internal configuration * values. */ - public function check_from_internal_object(): void { + public function check_from_internal_object(): void + { # Use a FirewallAlias model to test this $alias = new FirewallAlias(); @@ -752,7 +791,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures the authenticated username is properly retained whenever running Model::write_config() (#512) */ - public function test_write_config_retains_authenticated_username(): void { + public function test_write_config_retains_authenticated_username(): void + { # Authenticate a user $client = new Auth(); $client->username = 'test_user'; @@ -772,7 +812,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the 'delete_many()' method correctly deletes all Model objects that match the query. */ - public function test_delete_many(): void { + public function test_delete_many(): void + { # Create FirewallAlias models $host_alias_0 = new FirewallAlias(name: 'host0', type: 'host'); $host_alias_0->create(); @@ -803,7 +844,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the 'delete_many()' method throws an error if no query parameters were given. */ - public function test_delete_many_no_query(): void { + public function test_delete_many_no_query(): void + { $this->assert_throws_response( response_id: 'MODEL_DELETE_MANY_REQUIRES_QUERY_PARAMS', code: 400, @@ -816,7 +858,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the 'paginate()' method works correctly. */ - public function test_paginate(): void { + public function test_paginate(): void + { # Ensure the paginate method correctly returns the paginated subset of a given array $this->assert_equals(Model::paginate([1, 2, 3, 4, 5], limit: 2, offset: 1), [2, 3]); $this->assert_equals(Model::paginate([1, 2, 3, 4, 5], limit: 2, offset: 3), [4, 5]); @@ -831,7 +874,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures that queries are performed before pagination is applied. */ - public function test_paginate_with_query(): void { + public function test_paginate_with_query(): void + { # Create FirewallAlias models to test this $alias_alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); $alias_alias_0->create(); @@ -859,7 +903,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures the read_all() method can be reversed to read the objects in reverse order. */ - public function test_read_all_reverse(): void { + public function test_read_all_reverse(): void + { # Create FirewallAlias models to test this $alias_alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); $alias_alias_0->create(); @@ -884,7 +929,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures the query() method can be reversed to read the objects in reverse order. */ - public function test_query_reverse(): void { + public function test_query_reverse(): void + { # Create FirewallAlias models to test this $alias_alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); $alias_alias_0->create(); @@ -910,7 +956,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks that updates using the $append flag correctly append values to the existing values instead of replacing * them. */ - public function test_update_append(): void { + public function test_update_append(): void + { # Use a FirewallAlias model to test this $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['127.0.0.1']); $alias->create(); @@ -938,7 +985,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Checks that updates using the $remove flag correctly remove array values from the existing array values instead of * replacing them. */ - public function test_update_remove(): void { + public function test_update_remove(): void + { # Use a FirewallAlias model to test this $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['127.0.0.1', '127.0.0.2', '127.0.0.3']); $alias->create(); @@ -964,7 +1012,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the 'delete_all()' actually deletes all Model objects. */ - public function test_delete_all(): void { + public function test_delete_all(): void + { # Create FirewallAlias models $host_alias_0 = new FirewallAlias(name: 'host0', type: 'host'); $host_alias_0->create(); @@ -991,7 +1040,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { * Ensure the does_apply_immediately() method correctly determines if the Model object will apply changes * immediately or requires a manual apply. */ - public function test_does_apply_immediately(): void { + public function test_does_apply_immediately(): void + { # The FirewallAlias Model requires a manual apply via FirewallApply, ensure it returns false $alias = new FirewallAlias(); $this->assert_is_false($alias->does_apply_immediately()); @@ -1008,7 +1058,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures sorting via the Model's query() method works correctly. */ - public function test_query_sort(): void { + public function test_query_sort(): void + { # Create FirewallAlias models to test this $a_alias = new FirewallAlias(name: 'a_alias', type: 'network'); $a_alias->create(); @@ -1049,7 +1100,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures the sort() method works correctly when creating a new Model object. */ - public function test_sort_create(): void { + public function test_sort_create(): void + { # Create a few FirewallAlias models to test this $alias_0 = new FirewallAlias(name: 'zzz', type: 'host'); $alias_1 = new FirewallAlias(name: 'ccc', type: 'host'); @@ -1096,7 +1148,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensures the sort() method works correctly when updating an existing Model object. */ - public function test_sort_update(): void { + public function test_sort_update(): void + { # Create a few FirewallAlias models to test this $alias_0 = new FirewallAlias(name: 'zzz', type: 'host'); $alias_1 = new FirewallAlias(name: 'ccc', type: 'host'); @@ -1150,7 +1203,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensure the sort() method works correctly when replacing all Model objects. */ - public function test_sort_replace_all(): void { + public function test_sort_replace_all(): void + { $alias = new FirewallAlias(); $alias->sort_by = ['name']; $alias->sort_order = SORT_ASC; @@ -1180,7 +1234,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensure an error is thrown if the write lock is not released after all write_config() attempts are exhausted. */ - public function test_write_lock_not_released(): void { + public function test_write_lock_not_released(): void + { # Set the write lock touch(Model::WRITE_LOCK_FILE); @@ -1204,7 +1259,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensure the copy() method creates an unlinked copy of the Model object. */ - public function test_copy(): void { + public function test_copy(): void + { # Create a new FirewallAlias model to test with $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['1.2.3.4']); $alias->id = 5; @@ -1223,7 +1279,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Ensure the copy() method does not use excessive memory in larger datasets. This is a regression test for #617. */ - public function test_copy_memory_usage(): void { + public function test_copy_memory_usage(): void + { # Generate lots of DHCP Server Static Mappings $static_mappings = []; $used_macs = []; @@ -1252,7 +1309,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that all Model classes with an internal_callable assigned have an existing callable assigned. */ - public function test_model_internal_callables_exist(): void { + public function test_model_internal_callables_exist(): void + { # Loop through all Model classes foreach (Model::get_all_model_classes() as $model_class) { # Create a new instance of the Model class @@ -1271,7 +1329,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that all Model class fields with a 'choices_callable' assigned have an existing callable assigned. */ - public function test_model_field_choices_callables_exist(): void { + public function test_model_field_choices_callables_exist(): void + { # Loop through all Model classes foreach (Model::get_all_model_classes() as $model_class) { # Create a new instance of the Model class @@ -1293,7 +1352,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the normalize_config_path() method correctly removes extra slashes in the config path */ - public function test_normalize_config_path(): void { + public function test_normalize_config_path(): void + { $this->assert_equals(Model::normalize_config_path('/test/path'), 'test/path'); $this->assert_equals(Model::normalize_config_path('/test//path'), 'test/path'); $this->assert_equals(Model::normalize_config_path('test/path/'), 'test/path'); @@ -1304,7 +1364,8 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that the read_all() method caches previously read Model objects to improve performance. */ - public function test_read_all_caching(): void { + public function test_read_all_caching(): void + { # Ensure the model cache is empty FirewallAlias::get_model_cache()::clear(); @@ -1341,4 +1402,55 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { # Ensure the cache was cleared after deletion $this->assert_is_false(FirewallAlias::get_model_cache()::has_modelset(FirewallAlias::get_class_fqn())); } -} + + /** + * Checks that read_all() on a Model with a parent Model class returns all child Model objects from all parents. + */ + public function test_read_all_with_parent_model(): void + { + # Create DNSResolverHostOverrides with nested aliases to test this + $overrides = new RESTAPI\Models\DNSResolverHostOverride(); + $overrides->replace_all([ + [ + 'host' => 'host1', + 'domain' => 'example.com', + 'ip' => ['127.0.0.1'], + 'aliases' => [ + ['host' => 'alias1', 'domain' => 'example.com'], + ] + ], + [ + 'host' => 'host2', + 'domain' => 'example.com', + 'ip' => ['127.0.0.2'], + 'aliases' => [ + ['host' => 'alias2', 'domain' => 'example.com'], + ] + ], + [ + 'host' => 'host3', + 'domain' => 'example.com', + 'ip' => ['127.0.0.3'], + 'aliases' => [ + ['host' => 'alias3', 'domain' => 'example.com'] + ] + ] + ]); + + # Read all DNSResolverHostOverrideAlias models and ensure all aliases from all overrides are returned + $all_aliases = RESTAPI\Models\DNSResolverHostOverrideAlias::read_all(); + $this->assert_equals($all_aliases->count(), 3); + $this->assert_equals($all_aliases->model_objects[0]->host->value, 'alias1'); + $this->assert_equals($all_aliases->model_objects[0]->parent_id, 0); + $this->assert_equals($all_aliases->model_objects[0]->id, 0); + $this->assert_equals($all_aliases->model_objects[1]->host->value, 'alias2'); + $this->assert_equals($all_aliases->model_objects[1]->parent_id, 1); + $this->assert_equals($all_aliases->model_objects[1]->id, 0); + $this->assert_equals($all_aliases->model_objects[2]->host->value, 'alias3'); + $this->assert_equals($all_aliases->model_objects[2]->parent_id, 2); + $this->assert_equals($all_aliases->model_objects[2]->id, 0); + + # Cleanup DNSResolverHostOverrides + $overrides->delete_all(); + } +} \ No newline at end of file From e2effede33a0ae7e7b7ff709e11379cee5d35ace Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 14 Dec 2025 15:28:21 -0700 Subject: [PATCH 25/73] style: run prettier on changed files --- .../RESTAPI/Tests/APICoreModelTestCase.inc | 202 ++++++------------ 1 file changed, 67 insertions(+), 135 deletions(-) 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 69173b62..07a5dd60 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 @@ -15,14 +15,12 @@ use RESTAPI\Models\SystemStatus; use RESTAPI\Models\Test; use function RESTAPI\Models\DHCPServerStaticMapping; -class APICoreModelTestCase extends RESTAPI\Core\TestCase -{ +class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** * Checks that a Model object cannot be created when both an ID and representation data are provided. This * ensures there is no confusion about which object ID to use when obtaining the model from its internal value. */ - public function test_no_model_with_id_and_representation_data() - { + public function test_no_model_with_id_and_representation_data() { $this->assert_throws_response( response_id: 'MODEL_WITH_ID_AND_REPRESENTATION', code: 500, @@ -37,8 +35,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * and IP address. This is mostly commonly used by RESTAPI\Core\Endpoint to provide Models information about the client * that authenticated a REST API call. */ - public function test_model_allows_provided_auth_obj() - { + public function test_model_allows_provided_auth_obj() { # Create an RESTAPI\Core\Auth object with custom values. $test_client = new RESTAPI\Core\Auth(); $test_client->username = 'test_user'; @@ -55,8 +52,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that a default RESTAPI\Core\Auth object is created when a Model object is created if none was provided. */ - public function test_model_default_auth_obj() - { + public function test_model_default_auth_obj() { # Create a Model object and but do not provide an Auth object $test_model = new Test(); @@ -68,8 +64,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks the Model's default `verbose_name` and `verbose_name_plural` are properly derived from its class name. */ - public function test_model_default_verbose_name() - { + public function test_model_default_verbose_name() { # Create a normal Model object and a FirewallAlias Model object for testing $test_model = new Test(); $test_fw_alias = new FirewallAlias(); @@ -84,8 +79,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that we are able to read items from the internal pfSense configuration by path. */ - public function test_model_get_config_valid_path() - { + public function test_model_get_config_valid_path() { # Try to read the WAN interface configuration since it is always present in the config $this->assert_type(Model::get_config('interfaces/wan'), 'array'); } @@ -93,8 +87,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that a specified default value is returned if the config path does not lead to an existing value. */ - public function test_model_get_config_bad_path() - { + public function test_model_get_config_bad_path() { # Try to read the WAN interface configuration since it is always present in the config $this->assert_equals(Model::get_config('does/not/exists', []), []); $this->assert_equals(Model::get_config('does/not/exists', false), false); @@ -104,8 +97,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that we cannot call the default get_next_id() method if the model object does not have a config path set. */ - public function test_model_cant_get_next_id_without_config_path() - { + public function test_model_cant_get_next_id_without_config_path() { $this->assert_throws_response( response_id: 'MODEL_NEXT_ID_WITHOUT_CONFIG_PATH', code: 500, @@ -120,8 +112,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that we cannot call the default get_next_id() method if the model object does not have `many` enabled. */ - public function test_model_cant_get_next_id_without_many() - { + public function test_model_cant_get_next_id_without_many() { $this->assert_throws_response( response_id: 'MODEL_NEXT_ID_WITHOUT_MANY', code: 500, @@ -138,8 +129,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * array value. This helps prevents dangerous write actions as it indicates the 'config_path' is wrong, or there * is a problem with the pfSense configuration. */ - public function test_model_cant_get_next_id_with_dangerous_config_path() - { + public function test_model_cant_get_next_id_with_dangerous_config_path() { # Try to read the system hostname (which is a primitive string in the pfSense configuration). To get an ID, we # expect the value be to an indexed array or an empty value. $this->assert_throws_response( @@ -158,8 +148,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks that we can successfully obtain the next ID for a non-existing config path. Empty paths should always * return the first available ID, `0`. */ - public function test_model_can_get_next_id_at_empty_path() - { + public function test_model_can_get_next_id_at_empty_path() { $test_model = new Test(); $test_model->config_path = 'this/path/does/not/exist/yet'; $test_model->many = true; @@ -171,8 +160,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * checks that the ID returned for filter/rule is greater than 0 because it's generally safe to assume there * will always be at least one item in filter/rule. */ - public function test_model_can_get_next_id_from_existing_array_path() - { + public function test_model_can_get_next_id_from_existing_array_path() { $test_model = new Test(); $test_model->config_path = 'filter/rule'; $test_model->many = true; @@ -182,8 +170,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that Models can correctly convert to their internal values. */ - public function test_model_to_internal() - { + public function test_model_to_internal() { # Since the default Model does not have any Fields, use a Test Model to test this. $test_model = new Test(); $test_model->test_bool->value = true; @@ -213,8 +200,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the Model's get_fields() method properly finds Field objects and their assigned names. */ - public function test_model_get_fields() - { + public function test_model_get_fields() { # Use the Test model since the base Model has no Fields assigned. $this->assert_equals((new Test())->get_fields(), [ 'test_bool', @@ -229,8 +215,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks to ensure Models cannot have Field objects with the same internal namespace and internal name. */ - public function test_model_field_internal_name_unique_to_namespace() - { + public function test_model_field_internal_name_unique_to_namespace() { $this->assert_throws_response( response_id: 'MODEL_FIELDS_WITH_CONFLICTING_INTERNAL_NAMES', code: 500, @@ -249,8 +234,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks to ensure the validate() method properly throws an error if a required pfSense package for the Model * is missing. */ - public function test_model_validate_packages_missing_package() - { + public function test_model_validate_packages_missing_package() { $this->assert_throws_response( response_id: 'MODEL_MISSING_REQUIRED_PACKAGE', code: 404, @@ -265,8 +249,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks to ensure the validate() method is successful when we have the correct packages installed. */ - public function test_model_validate_packages_installed_package() - { + public function test_model_validate_packages_installed_package() { $test_model = new Test(); $test_model->packages = ['pfSense-pkg-RESTAPI']; # Use pfSense-pkg-RESTAPI since we know we have it. $test_model->validate(); @@ -276,8 +259,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks to ensure the validate() method properly throws an error if we cannot include a required package include * file because it is not found in the PHP path. */ - public function test_model_validate_missing_package_includes() - { + public function test_model_validate_missing_package_includes() { $this->assert_throws_response( response_id: 'MODEL_WITH_FAILED_INCLUDE', code: 500, @@ -294,8 +276,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * objects cannot be created unless the Model supports many objects OR the base Model class's `_create()` method * is overridden. */ - public function test_model_create_without_many() - { + public function test_model_create_without_many() { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_CREATE_METHOD', code: 500, @@ -316,8 +297,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * objects cannot be created unless the Model has a valid config path OR the base Model class's `_create()` method * is overridden. */ - public function test_model_create_without_config_path() - { + public function test_model_create_without_config_path() { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_CREATE_METHOD', code: 500, @@ -340,8 +320,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks the default `create()` method correctly validates and writes the Model object to config. */ - public function test_model_create() - { + public function test_model_create() { # Create a new test model object in config $test_model = new Test(); $test_model->test_bool->value = true; @@ -365,8 +344,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * objects cannot be updated unless the Model has a valid config path OR the base Model class's `_update()` method * is overridden. */ - public function test_model_update_without_config_path() - { + public function test_model_update_without_config_path() { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_UPDATE_METHOD', code: 500, @@ -387,8 +365,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks the deafult Model object `update()` method to ensure it throws an error if `many` is set but no `id` * value has been assigned. We must have an ID to determine which object is being updated. */ - public function test_model_update_without_id() - { + public function test_model_update_without_id() { $this->assert_throws_response( response_id: 'MODEL_REQUIRES_ID', code: 400, @@ -404,8 +381,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks the default `update()` method correctly validates, updates and writes the Model object to config. */ - public function test_model_update() - { + public function test_model_update() { # Create a new test model object in config $test_model = new Test(); $test_model->test_bool->value = true; @@ -439,8 +415,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * objects cannot be deleted unless the Model supports many objects OR the base Model class's `_delete()` method * is overridden. */ - public function test_model_delete_without_many() - { + public function test_model_delete_without_many() { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_DELETE_METHOD', code: 500, @@ -461,8 +436,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * objects cannot be deleted unless the Model has a valid config path OR the base Model class's `_delete()` method * is overridden. */ - public function test_model_delete_without_config_path() - { + public function test_model_delete_without_config_path() { $this->assert_throws_response( response_id: 'MODEL_REQUIRE_OVERRIDDEN_DELETE_METHOD', code: 500, @@ -483,8 +457,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks the default Model object `delete()` method to ensure it throws an error if `many` is set but no `id` * value has been assigned. We must have an ID to determine which object is being deleted. */ - public function test_model_delete_without_id() - { + public function test_model_delete_without_id() { $this->assert_throws_response( response_id: 'MODEL_REQUIRES_ID', code: 400, @@ -500,8 +473,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the default `delete()` method actually deletes the object from configuration. */ - public function test_model_delete() - { + public function test_model_delete() { # Ensure we cannot find the object from config after it's deleted. $this->assert_throws_response( response_id: 'MODEL_OBJECT_NOT_FOUND', @@ -523,8 +495,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Test model's `apply()` method has been overridden to write a text file to /tmp/.api_model_test_apply.txt whenever * it is called. This test checks for that file after `create()`, `update()` or `delete()` is called. */ - public function test_model_calls_apply_when_requested() - { + public function test_model_calls_apply_when_requested() { # In case a test file was leftover, try to remove the test file first unlink('/tmp/.api_models_test_apply.txt'); @@ -550,8 +521,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that all Fields specified in `unique_together_fields` must be existing Fields assigned to the Model. */ - public function test_unique_together_fields() - { + public function test_unique_together_fields() { $this->assert_throws_response( response_id: 'MODEL_UNIQUE_TOGETHER_FIELDS_WITH_UNKNOWN_FIELD', code: 500, @@ -567,8 +537,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks the unique together validation correctly requires a group of Fields to be unique from all other Model * objects of its kind. */ - public function test_unique_together_validation() - { + public function test_unique_together_validation() { # Create a Model object that uses the unique Fields $original_model_obj = new Test( data: [ @@ -603,8 +572,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that we are able to construct a Model object with an array of representation data. */ - public function test_model_constructs_from_representation() - { + public function test_model_constructs_from_representation() { # Construct a new test model object using representation data $test_model = new Test( data: [ @@ -623,8 +591,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that `to_openapi_schema()` correctly converts the Model object to an OpenAPI schema. */ - public function test_model_to_openapi_schema() - { + public function test_model_to_openapi_schema() { # Create a model to test with and generate it's OpenAPI schema $test_model = new Test(); $test_model_schema = $test_model->to_openapi_schema(); @@ -641,8 +608,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the Models with a $cache_class assigned are construct with a specific Cache object. */ - public function test_cache_class_creates_cache_object_during_construction(): void - { + public function test_cache_class_creates_cache_object_during_construction(): void { # Define a model with a cache class. Use the RESTAPIVersionReleasesCache for testing. $model = new Model(); $model->cache_class = 'RESTAPIVersionReleasesCache'; @@ -661,8 +627,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks that the Model's $placement property correctly sets the placement of the Model object in the pfSense * configuration during creation and updates. */ - public function test_set_placement(): void - { + public function test_set_placement(): void { # Create three FirewallAlias models in order $alias_0 = new FirewallAlias(name: 'test0', type: 'host'); $alias_0->create(); @@ -697,8 +662,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the get_all_model_classes() method correctly returns all Model classes that extend the Model class. */ - public function test_get_all_model_classes(): void - { + public function test_get_all_model_classes(): void { # Variables $class_fqns = Model::get_all_model_classes(); $class_shortnames = Model::get_all_model_classes(shortnames: true); @@ -714,8 +678,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that each Model class in /usr/local/pkg/RESTAPI/Models/ has the correct constructor signature. */ - public function test_model_constructor_signature(): void - { + public function test_model_constructor_signature(): void { # Variables $class_fqns = Model::get_all_model_classes(); @@ -741,8 +704,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks that the skip_init property correctly skips loading a Model's values from internal in the Model's * __construct() method when set to true. */ - public function test_skip_init(): void - { + public function test_skip_init(): void { # Create a new Test model object with skip_init set to true $test_model = new SystemStatus(skip_init: true); @@ -760,8 +722,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks that the Model::from_internal_object() correctly loads the Model object using the internal configuration * values. */ - public function check_from_internal_object(): void - { + public function check_from_internal_object(): void { # Use a FirewallAlias model to test this $alias = new FirewallAlias(); @@ -791,8 +752,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures the authenticated username is properly retained whenever running Model::write_config() (#512) */ - public function test_write_config_retains_authenticated_username(): void - { + public function test_write_config_retains_authenticated_username(): void { # Authenticate a user $client = new Auth(); $client->username = 'test_user'; @@ -812,8 +772,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the 'delete_many()' method correctly deletes all Model objects that match the query. */ - public function test_delete_many(): void - { + public function test_delete_many(): void { # Create FirewallAlias models $host_alias_0 = new FirewallAlias(name: 'host0', type: 'host'); $host_alias_0->create(); @@ -844,8 +803,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the 'delete_many()' method throws an error if no query parameters were given. */ - public function test_delete_many_no_query(): void - { + public function test_delete_many_no_query(): void { $this->assert_throws_response( response_id: 'MODEL_DELETE_MANY_REQUIRES_QUERY_PARAMS', code: 400, @@ -858,8 +816,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the 'paginate()' method works correctly. */ - public function test_paginate(): void - { + public function test_paginate(): void { # Ensure the paginate method correctly returns the paginated subset of a given array $this->assert_equals(Model::paginate([1, 2, 3, 4, 5], limit: 2, offset: 1), [2, 3]); $this->assert_equals(Model::paginate([1, 2, 3, 4, 5], limit: 2, offset: 3), [4, 5]); @@ -874,8 +831,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures that queries are performed before pagination is applied. */ - public function test_paginate_with_query(): void - { + public function test_paginate_with_query(): void { # Create FirewallAlias models to test this $alias_alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); $alias_alias_0->create(); @@ -903,8 +859,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures the read_all() method can be reversed to read the objects in reverse order. */ - public function test_read_all_reverse(): void - { + public function test_read_all_reverse(): void { # Create FirewallAlias models to test this $alias_alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); $alias_alias_0->create(); @@ -929,8 +884,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures the query() method can be reversed to read the objects in reverse order. */ - public function test_query_reverse(): void - { + public function test_query_reverse(): void { # Create FirewallAlias models to test this $alias_alias_0 = new FirewallAlias(name: 'alias0', type: 'host'); $alias_alias_0->create(); @@ -956,8 +910,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks that updates using the $append flag correctly append values to the existing values instead of replacing * them. */ - public function test_update_append(): void - { + public function test_update_append(): void { # Use a FirewallAlias model to test this $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['127.0.0.1']); $alias->create(); @@ -985,8 +938,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Checks that updates using the $remove flag correctly remove array values from the existing array values instead of * replacing them. */ - public function test_update_remove(): void - { + public function test_update_remove(): void { # Use a FirewallAlias model to test this $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['127.0.0.1', '127.0.0.2', '127.0.0.3']); $alias->create(); @@ -1012,8 +964,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the 'delete_all()' actually deletes all Model objects. */ - public function test_delete_all(): void - { + public function test_delete_all(): void { # Create FirewallAlias models $host_alias_0 = new FirewallAlias(name: 'host0', type: 'host'); $host_alias_0->create(); @@ -1040,8 +991,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase * Ensure the does_apply_immediately() method correctly determines if the Model object will apply changes * immediately or requires a manual apply. */ - public function test_does_apply_immediately(): void - { + public function test_does_apply_immediately(): void { # The FirewallAlias Model requires a manual apply via FirewallApply, ensure it returns false $alias = new FirewallAlias(); $this->assert_is_false($alias->does_apply_immediately()); @@ -1058,8 +1008,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures sorting via the Model's query() method works correctly. */ - public function test_query_sort(): void - { + public function test_query_sort(): void { # Create FirewallAlias models to test this $a_alias = new FirewallAlias(name: 'a_alias', type: 'network'); $a_alias->create(); @@ -1100,8 +1049,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures the sort() method works correctly when creating a new Model object. */ - public function test_sort_create(): void - { + public function test_sort_create(): void { # Create a few FirewallAlias models to test this $alias_0 = new FirewallAlias(name: 'zzz', type: 'host'); $alias_1 = new FirewallAlias(name: 'ccc', type: 'host'); @@ -1148,8 +1096,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensures the sort() method works correctly when updating an existing Model object. */ - public function test_sort_update(): void - { + public function test_sort_update(): void { # Create a few FirewallAlias models to test this $alias_0 = new FirewallAlias(name: 'zzz', type: 'host'); $alias_1 = new FirewallAlias(name: 'ccc', type: 'host'); @@ -1203,8 +1150,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensure the sort() method works correctly when replacing all Model objects. */ - public function test_sort_replace_all(): void - { + public function test_sort_replace_all(): void { $alias = new FirewallAlias(); $alias->sort_by = ['name']; $alias->sort_order = SORT_ASC; @@ -1234,8 +1180,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensure an error is thrown if the write lock is not released after all write_config() attempts are exhausted. */ - public function test_write_lock_not_released(): void - { + public function test_write_lock_not_released(): void { # Set the write lock touch(Model::WRITE_LOCK_FILE); @@ -1259,8 +1204,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensure the copy() method creates an unlinked copy of the Model object. */ - public function test_copy(): void - { + public function test_copy(): void { # Create a new FirewallAlias model to test with $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['1.2.3.4']); $alias->id = 5; @@ -1279,8 +1223,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Ensure the copy() method does not use excessive memory in larger datasets. This is a regression test for #617. */ - public function test_copy_memory_usage(): void - { + public function test_copy_memory_usage(): void { # Generate lots of DHCP Server Static Mappings $static_mappings = []; $used_macs = []; @@ -1309,8 +1252,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that all Model classes with an internal_callable assigned have an existing callable assigned. */ - public function test_model_internal_callables_exist(): void - { + public function test_model_internal_callables_exist(): void { # Loop through all Model classes foreach (Model::get_all_model_classes() as $model_class) { # Create a new instance of the Model class @@ -1329,8 +1271,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that all Model class fields with a 'choices_callable' assigned have an existing callable assigned. */ - public function test_model_field_choices_callables_exist(): void - { + public function test_model_field_choices_callables_exist(): void { # Loop through all Model classes foreach (Model::get_all_model_classes() as $model_class) { # Create a new instance of the Model class @@ -1352,8 +1293,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the normalize_config_path() method correctly removes extra slashes in the config path */ - public function test_normalize_config_path(): void - { + public function test_normalize_config_path(): void { $this->assert_equals(Model::normalize_config_path('/test/path'), 'test/path'); $this->assert_equals(Model::normalize_config_path('/test//path'), 'test/path'); $this->assert_equals(Model::normalize_config_path('test/path/'), 'test/path'); @@ -1364,8 +1304,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that the read_all() method caches previously read Model objects to improve performance. */ - public function test_read_all_caching(): void - { + public function test_read_all_caching(): void { # Ensure the model cache is empty FirewallAlias::get_model_cache()::clear(); @@ -1406,8 +1345,7 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase /** * Checks that read_all() on a Model with a parent Model class returns all child Model objects from all parents. */ - public function test_read_all_with_parent_model(): void - { + public function test_read_all_with_parent_model(): void { # Create DNSResolverHostOverrides with nested aliases to test this $overrides = new RESTAPI\Models\DNSResolverHostOverride(); $overrides->replace_all([ @@ -1415,26 +1353,20 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase 'host' => 'host1', 'domain' => 'example.com', 'ip' => ['127.0.0.1'], - 'aliases' => [ - ['host' => 'alias1', 'domain' => 'example.com'], - ] + 'aliases' => [['host' => 'alias1', 'domain' => 'example.com']], ], [ 'host' => 'host2', 'domain' => 'example.com', 'ip' => ['127.0.0.2'], - 'aliases' => [ - ['host' => 'alias2', 'domain' => 'example.com'], - ] + 'aliases' => [['host' => 'alias2', 'domain' => 'example.com']], ], [ 'host' => 'host3', 'domain' => 'example.com', 'ip' => ['127.0.0.3'], - 'aliases' => [ - ['host' => 'alias3', 'domain' => 'example.com'] - ] - ] + 'aliases' => [['host' => 'alias3', 'domain' => 'example.com']], + ], ]); # Read all DNSResolverHostOverrideAlias models and ensure all aliases from all overrides are returned @@ -1453,4 +1385,4 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase # Cleanup DNSResolverHostOverrides $overrides->delete_all(); } -} \ No newline at end of file +} From f46a205d0a199f2c9cfcb2bef50715158b00b1d6 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 15 Dec 2025 22:46:21 -0700 Subject: [PATCH 26/73] refactor: remove model indexing Model indexing isn't practical here because the cache isn't retained between requests. It makes more sense to cache logical queries instead of basic indexing. --- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 152 +----------------- .../usr/local/pkg/RESTAPI/Core/ModelSet.inc | 8 + .../Tests/APICoreModelCacheTestCase.inc | 113 ------------- 3 files changed, 13 insertions(+), 260 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index 82b0f7f8..66332c34 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -21,20 +21,11 @@ class ModelCache { public static ?self $instance = null; /** - * @var array $index An associative array that contains cache Model objects that have been loaded and - * indexed by specific fields for quick lookup. Structured as: - * - * [Model class name] => [ - * [index field name] => [ - * [index field value] => Model object - * ] - * ] - */ - public static array $index = []; - - /** - * @var array $cache An associative array that contains cached Model objects that have been loaded into memory as - * a complete ModelSet of every object of that Model type. Structured as: [Model class name] => ModelSet object + * @var array $cache An associative array that contains cached ModelSet objects that have been loaded into memory. + * This includes root ModelSets that contain all Model objects of a given Model class as well as indexed queries + * that have already been run against those ModelSets. Structured as: [ModelSet signature => ModelSet object]. + * Please note that root ModelSets are indexed by their qualified Model class name only, whereas queried ModelSets + * are indexed by the root signature followed by a series of query hashes separated by colons. */ public static array $cache = []; @@ -83,143 +74,10 @@ class ModelCache { return self::$cache[$model_class]; } - /** - * Raises an error if the given Model object does not support being indexed by the specified field. - * @param Model $model The Model object to check for uniqueness. - * @param string $index_field The index field name to check for uniqueness. - * @throws ServerError If the Model is attempting to be indexed by a non-unique field. - * @throws ServerError If the Model is not many-enabled. - */ - private static function ensure_model_supports_indexing(Model $model, string $index_field): void { - # Only many-enabled Models can be indexed - if (!$model->many) { - throw new ServerError( - message: "Cannot index Model class '" . $model->get_class_fqn() . 'because it is not many-enabled.', - response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL', - ); - } - - # Models with parent model classes cannot be indexed - if ($model->parent_model_class) { - throw new ServerError( - message: "Cannot index Model class '" . - $model->get_class_fqn() . - "' because it has a parent model class '" . - $model->parent_model_class . - "'.", - response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', - ); - } - - # If indexing by 'id', it's always unique - if ($index_field === 'id') { - return; - } - - # Check if the index field is unique on the Model object - if (!$model->$index_field->unique) { - throw new ServerError( - message: "Cannot index Model class '" . - $model->get_class_fqn() . - "' by non-unique field " . - "'$index_field'.", - response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', - ); - } - } - - /** - * Indexes the ModelSet cache for a given Model class by a specified field. This method will populate - * the $index array for the specified Model class and index field. - * @param string $model_class The Model class name whose ModelSet is to be indexed. If no cached ModelSet exists, - * one will be created by reading all Model objects from the data source. - * @param string $index_field The field name to index the Model objects by. - * @return array An associative array of indexed Model objects. - */ - public static function index_modelset_by_field(string $model_class, string $index_field): array { - # First, check if this Model class has already been indexed by this field - if (isset(self::$index[$model_class][$index_field])) { - return self::$index[$model_class][$index_field]; - } - - # Ensure the Model can be indexed by the specified field - $model = new $model_class(); - self::ensure_model_supports_indexing($model, $index_field); - - # Fetch or create the ModelSet for this Model class - $model_set = $model->read_all(); - - # Create an associative array to hold the indexed Model objects - $indexed_models = []; - - # Index each Model object in the ModelSet by the specified field - foreach ($model_set->model_objects as $model) { - $index_value = $index_field === 'id' ? $model->id : $model->$index_field->value; - $indexed_models[$index_value] = $model; - } - - # Store the indexed models in the ModelCache index and return them - self::$index[$model_class][$index_field] = $indexed_models; - return $indexed_models; - } - - /** - * Checks if a given Model class has a cached Model object by its index field/value. - * @param string $model_class The Model class name to check in the cache. - * @param string $index_field The index field name to look up the Model object by - * @param mixed $index_value The index field value to look up the Model object by - * @return bool True if a cached Model object exists for the specified Model class and index - */ - public static function has_model(string $model_class, string $index_field = 'id', mixed $index_value = null): bool { - # Cache is always a miss if the Model class is not indexed - if (!self::$index[$model_class]) { - return false; - } - - # Cache is a hit for non-many enabled models if a single Model object is indexed under the Model class - if (self::$index[$model_class] instanceof Model) { - return true; - } - - # Otherwise, cache is only a hit for many enabled models if the index field/value exists in the index - return isset(self::$index[$model_class][$index_field][$index_value]); - } - - /** - * Fetches a cached Model object by its Model class name and index field/value. - * @param string $model_class The Model class name to load from the cache. - * @param string $index_field The index field name to look up the Model object by. Only for many enabled Models. - * @param mixed $index_value The index field value to look up the Model object by. Only for many enabled Models. - * @return Model The cached Model object - * @throws NotFoundError If no cached Model object exists for the specified Model class and index. - */ - public static function fetch_model( - string $model_class, - string $index_field = 'id', - mixed $index_value = null, - ): Model { - if (!self::has_model($model_class, $index_field, $index_value)) { - throw new NotFoundError( - message: "No cached Model found for Model class '$model_class' with " . - "index field '$index_field' and index value '$index_value'.", - response_id: 'MODEL_CACHE_MODEL_NOT_FOUND', - ); - } - - # For many enabled models, return the Model object indexed under the specified index field and value - if (isset(self::$index[$model_class][$index_field][$index_value])) { - return self::$index[$model_class][$index_field][$index_value]; - } - - # Otherwise, return the Model object indexed under the Model class only - return self::$index[$model_class]; - } - /** * Clears the ModelCache of all cached ModelSets and indexed Model objects. */ public static function clear(): void { - self::$index = []; self::$cache = []; } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc index 57f65251..3573a4d8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc @@ -13,6 +13,14 @@ require_once 'RESTAPI/autoloader.inc'; class ModelSet { use BaseTraits; + /** + * @var string $signature A unique signature for this ModelSet used to index this ModelSet in the query cache + * while retaining all query chains. Signatures are compromised of a root signature (the Model class name) and + * a series of query signatures that represent each query that has been applied to this ModelSet to this point + * (separated by a colon). Example 'RESTAPI\Models\User:queryhash1:queryhash2' + */ + public string $signature = ''; + /** * @var array $model_objects An array of Model objects contained by this ModelSet. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc index 524e69b7..d23bf81b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc @@ -83,117 +83,4 @@ class APICoreModelCacheTestCase extends TestCase { }, ); } - - /** - * Ensures Models must be many-enabled to be indexed in the ModelCache. - */ - public function test_model_index_must_be_many_enabled(): void { - # Ensure attempting to index a non-many enabled Model throws an error - $this->assert_throws_response( - response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL', - code: 500, - callable: function () { - ModelCache::index_modelset_by_field(model_class: RESTAPISettings::get_class_fqn(), index_field: 'id'); - }, - ); - } - - /** - * Ensures we cannot index a cached ModelSet by a non-unique field. - */ - public function test_model_index_field_must_be_unique(): void { - # Ensure indexing by id is always allowed - $this->assert_does_not_throw(function () { - ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'id'); - }); - - # Ensure attempting to index the Model by the non-unique field throws an error - $this->assert_throws_response( - response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', - code: 500, - callable: function () { - ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'descr'); - }, - ); - } - - /** - * Ensures we cannot index a cached ModelSet for a Model that has a parent model class. - */ - public function test_model_index_field_cannot_have_parent_model_class(): void { - # Ensure attempting to index the Model by the non-unique field throws an error - $this->assert_throws_response( - response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', - code: 500, - callable: function () { - ModelCache::index_modelset_by_field( - model_class: DNSResolverHostOverrideAlias::get_class_fqn(), - index_field: 'id', - ); - }, - ); - } - - /** - * Ensures the index_modelset_by_field() method correctly indexes a cached ModelSet by the specified field. This - * test also ensures the has_model() and fetch_model() methods work as expected for indexed Model objects. - */ - public function test_model_index_modelset_by_field(): void { - # Create FirewallAlias model objects to test with - $alias1 = new FirewallAlias(data: ['name' => 'host_alias', 'descr' => 'First alias', 'type' => 'host']); - $alias2 = new FirewallAlias(data: ['name' => 'network_alias', 'descr' => 'Second alias', 'type' => 'network']); - $alias3 = new FirewallAlias(data: ['name' => 'port_alias', 'descr' => 'Third alias', 'type' => 'port']); - $alias1->create(); - $alias2->create(); - $alias3->create(); - - # Index the FirewallAlias ModelSet by the 'name' field - ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'name'); - - # Ensure the ModelCache has the indexed Models - $this->assert_is_true( - ModelCache::has_model( - model_class: FirewallAlias::get_class_fqn(), - index_field: 'name', - index_value: 'host_alias', - ), - ); - $this->assert_is_true( - ModelCache::has_model( - model_class: FirewallAlias::get_class_fqn(), - index_field: 'name', - index_value: 'network_alias', - ), - ); - $this->assert_is_true( - ModelCache::has_model( - model_class: FirewallAlias::get_class_fqn(), - index_field: 'name', - index_value: 'port_alias', - ), - ); - - # Fetch the indexed Models and ensure they are correct - $fetched_alias1 = ModelCache::fetch_model( - model_class: FirewallAlias::get_class_fqn(), - index_field: 'name', - index_value: 'host_alias', - ); - $this->assert_equals($fetched_alias1->name->value, 'host_alias'); - $fetched_alias2 = ModelCache::fetch_model( - model_class: FirewallAlias::get_class_fqn(), - index_field: 'name', - index_value: 'network_alias', - ); - $this->assert_equals($fetched_alias2->name->value, 'network_alias'); - $fetched_alias3 = ModelCache::fetch_model( - model_class: FirewallAlias::get_class_fqn(), - index_field: 'name', - index_value: 'port_alias', - ); - $this->assert_equals($fetched_alias3->name->value, 'port_alias'); - - # Clean up the created aliases - FirewallAlias::delete_all(); - } } From fe837256c3ff7c921e2e057346efbdcafac96f66 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 10:47:03 -0700 Subject: [PATCH 27/73] feat(QueryFilter): add query filter for finding objects whose field values are within an array or string --- docs/QUERIES_FILTERS_AND_SORTING.md | 9 ++++++ .../RESTAPI/QueryFilters/InQueryFilter.inc | 31 +++++++++++++++++++ .../APIQueryFiltersInQueryFilterTestCase.inc | 24 ++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/QueryFilters/InQueryFilter.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIQueryFiltersInQueryFilterTestCase.inc diff --git a/docs/QUERIES_FILTERS_AND_SORTING.md b/docs/QUERIES_FILTERS_AND_SORTING.md index a98a63dc..5e85533d 100644 --- a/docs/QUERIES_FILTERS_AND_SORTING.md +++ b/docs/QUERIES_FILTERS_AND_SORTING.md @@ -52,6 +52,15 @@ value array contains a given value for array fields. - Name: `contains` - Example: `https://pfsense.example.com/api/v2/examples?fieldname__contains=example` +### In (in) + +Search for objects whose field value is within a given array of values (when the filter value is an array), or search +for objects whose field value is a substring of a given string (when the filter value is a string). +- Name: `in` +- Examples: + - `https://pfsense.example.com/api/v2/examples?fieldname__in=example` + - `https://pfsense.example.com/api/v2/examples?fieldname__in[]=example1&fieldname__in[]=example2` + ### Less Than (lt) Search for objects whose field value is less than a given integer. diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/QueryFilters/InQueryFilter.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/QueryFilters/InQueryFilter.inc new file mode 100644 index 00000000..7f254fcb --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/QueryFilters/InQueryFilter.inc @@ -0,0 +1,31 @@ +assert_is_true($filter->evaluate('test', 'test')); + $this->assert_is_false($filter->evaluate('test', 'notfound')); + + # Check with array filter value and non-array field value + $this->assert_is_true($filter->evaluate('apple', ['apple', 'banana'])); + $this->assert_is_false($filter->evaluate('cherry', ['apple', 'banana'])); + } +} From 1d15eadc8b52d080ee295471e3b615ee03769c8a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 17:08:57 -0700 Subject: [PATCH 28/73] docs: add docs page on security hardening --- docs/SECURING_API_ACCESS.md | 136 ++++++++++++++++++ mkdocs.yml | 1 + .../RESTAPI/Models/RESTAPIAccessListEntry.inc | 3 +- 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 docs/SECURING_API_ACCESS.md diff --git a/docs/SECURING_API_ACCESS.md b/docs/SECURING_API_ACCESS.md new file mode 100644 index 00000000..f270bc8a --- /dev/null +++ b/docs/SECURING_API_ACCESS.md @@ -0,0 +1,136 @@ +# Securing API Access + +In the age of network automation, APIs on network appliances unlocks incredible efficiency and potential, but it also +can also create a high-stakes attack vector. Unlike standard web applications, a compromised firewall doesn't just leak +data; it can grant attackers administrative control over your network's perimeter, allowing them to rewrite traffic rules +and bypass your defenses entirely. The REST API package includes several built-in security features to help protect +API access and ensure that only authorized users and systems can interact with your pfSense instances. + +## Step 1: Follow Netgate's Best Practices for Remote Access + +If you need access to the pfSense REST API from outside your local network, it is critical that you follow Netgate's +[Allowing Remote Access to the GUI¶](https://docs.netgate.com/pfsense/en/latest/recipes/remote-firewall-administration.html) +guide to ensure that your pfSense instance is properly secured against unauthorized access. This includes: + +- Enabling HTTPS for the web GUI to encrypt traffic between clients and the pfSense instance. +- Using a VPN to connect to your pfSense instance remotely, rather than exposing the web GUI directly to the internet. +- Configuring strong firewall rules to restrict access to the webConfigurator. + +## Step 2: Choose an Appropriate Authentication Method + +The authentication method you choose for API access will depend on your specific use case and security requirements +for your environment. The pfSense REST API package supports several different authentication methods, and allows +multiple methods to be enabled simultaneously. The three main authentication methods supported are: + +### Basic Authentication (Local Database) + +[Basic authentication](AUTHENTICATION_AND_AUTHORIZATION.md#basic-authentication-local-database) allows you to authenticate +with the same username and password you use to log into the pfSense web GUI. This method is the default authentication +method for the REST API package as it allows for out-of-the-box functionality without any additional configuration. +However, basic authentication is less secure than other methods and should only be used in trusted environments and +over secure connections (e.g., HTTPS or VPN). + +**Pros**: + +- Easy to set up and use. +- No additional configuration required. + +**Cons**: + +- Less secure than other methods. +- Credentials are sent with each request, increasing the risk of interception, especially if not using HTTPS. +- Credentials may also allow web GUI and/or SSH access, which may not be desirable for API-only users. + +### JWT Authentication + +[JWT authentication](AUTHENTICATION_AND_AUTHORIZATION.md#json-web-token-jwt-authentication) allows you to authenticate +using JSON Web Tokens. These are short-lived, digitially signed tokens that can be used to authenticate API requests without +sending a username and password with each request. JWT authentication is more secure than basic authentication and is +recommended for production environments who need session-based or short-lived authentication. This is especially useful +for front-end applications or scripts that need to make multiple API requests over a short period of time. + +**Pros**: + +- More secure than basic authentication. +- Tokens can be short-lived, reducing the risk of compromise. +- Tokens do not expose pfSense user credentials with each request. + +**Cons**: + +- Requires additional configuration to set up. +- Tokens need to be refreshed before they expire. + +### Key Authentication + +[Key authentication](AUTHENTICATION_AND_AUTHORIZATION.md#api-key-authentication) allows you to authenticate using +dedicated API keys. These keys are created specifically for API access and never require a username or password to +be sent with requests. Key authentication is the most secure method and is recommended for production environments +where security is a top priority. This method is especially useful for automated systems or services that need to +make API requests without human intervention. + +**Pros**: + +- Most secure authentication method. +- API keys can be easily revoked or rotated without affecting user accounts. +- Does not expose pfSense user credentials with requests. +- Supports configurable key-lengths and hashing algorithms for purpose-specific security needs. + +**Cons**: + +- Requires additional configuration to set up. +- API keys need to be securely stored and managed. + +## Step 4: Use API-specific user accounts + +Regardless of the authentication method you choose, the REST API package uses pfSense's built-in privilege system to +control access to API endpoints. This means that all credentials used for API access must belong to a pfSense user account +who has been granted the appropriate API privileges. Each endpoint has its own privileges for each HTTP method supported +by that endpoint. It is highly recommended that you create dedicated user accounts specifically for API access, rather +than using existing user accounts that may have broader access. This helps to limit the potential impact of a +compromised account and allows for better tracking of API activity. It is also highly recommended to follow the principle +of least privilege when assigning API privileges to user accounts. Only grant the minimum privileges necessary for the +intended use case. + +!!! Warning + The `page-all` privilege grants unrestricted access to all API endpoints and methods. Avoid assigning this privilege + to user accounts unless absolutely necessary, as it significantly increases the risk of unauthorized access and + potential misuse of the API. + +## Step 4: Restrict API access to specific interfaces + +By default, the pfSense REST API package allows requests received on any interface IP to respond to API requests. However, +you can restrict the API to only respond to requests received on specific interfaces if desired. This can help limit the +exposure of the API to only trusted networks or systems beyond just setting firewall rules. To configure which +interfaces the API will respond to, navigate to `System` > `REST API` > `Settings` > `Allowed Interfaces` and select +the desired interfaces. + +!!! Warning + This setting is not a replacement for proper firewall rules. This setting should be used in addition to firewall rules + to provide a layered approach to security. Ensure that you have proper firewall rules in place to restrict access to the API + to only trusted networks or systems, then ensure the API is configured to only respond on those same interfaces. + +## Step 5: Configure API access lists + +The REST API package includes an API access list feature that allows you to restrict API access based on source IP, +network, time-of-day, and/or user. This provides an additional layer of security by allowing you to define specific rules for who +can access the API, when they can access it, and from where. To configure API access lists, navigate to `System` > `REST API` > +`Access Lists` and create the desired access list rules. When designing your access list rules, consider the following best practices: + +- Only allow IPs you trust and have a legitimate use case for accessing the API. +- Only allow the relevant users to access the API from their respective IPs or networks. +- If possible, configure and apply a schedule to apply to the access list rules to limit access to only when necessary. + +!!! Warning + This access control list is not a replacement for proper firewall rules. This setting should be used in addition to + firewall rules to provide a layered approach to security. Ensure that you have proper firewall rules in place to + restrict access to the API to only trusted networks or systems, then use the access list to further restrict access + based on your specific requirements. + +## Step 6: Update Regularly + +Ensure that you are running the latest version of the pfSense REST API package to benefit from the latest security +patches and features. Regularly check for updates and apply them as soon as possible to minimize the risk of vulnerabilities. + +!!! Tip + If you are using Prometheus for monitoring in your environment, consider using the [official pfREST Prometheus exporter](https://github.com/pfrest/pfsense_exporter) + to monitor for outdated pfSense REST API package versions across your fleet of pfSense instances! \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index fca1f06b..2df15e96 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 + - Securing API Access: SECURING_API_ACCESS.md - Limitations & FAQs: LIMITATIONS_AND_FAQS.md - API Reference: https://pfrest.org/api-docs/ - Advanced Topics: diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc index 35b4f39d..2d286b6b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc @@ -59,7 +59,8 @@ class RESTAPIAccessListEntry extends Model { allow_empty: true, many: true, many_minimum: 0, - help_text: 'The users that this entry applies to. Only users in this list will be affected by this entry.', + help_text: 'The users that this entry applies to. Only users in this list will be affected by this ' . + 'entry. Leave empty if this entry should apply to all users.', ); $this->sched = new ForeignModelField( model_name: 'FirewallSchedule', From 2d1eca4ba707d0f07e171b770d9d4f260598f28a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 17:10:40 -0700 Subject: [PATCH 29/73] perf: use caching and indexing to increase object relation performance This commit implements model and query caching/indexing that will help reduce the number of objects that need to be reloaded unnecessarily. This should provide a big performance improvement for instances with large pfSense configurations. --- .../usr/local/pkg/RESTAPI/Core/Model.inc | 4 +- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 184 ++++++++++++++++-- .../usr/local/pkg/RESTAPI/Core/ModelSet.inc | 59 +++++- .../pkg/RESTAPI/Fields/ForeignModelField.inc | 114 ++++++++--- .../Tests/APICoreModelCacheTestCase.inc | 144 +++++++++++++- 5 files changed, 448 insertions(+), 57 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 e7326eb5..1fae28df 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 @@ -1992,11 +1992,11 @@ class Model { } # Load the ModelSet with all obtained Model objects - $modelset = new ModelSet(model_objects: $model_objects); + $modelset = new ModelSet(model_objects: $model_objects, signature: $model_name); # For many models, cache the ModelSet if not exempt if ($model->many and !$cache_exempt) { - self::get_model_cache()::cache_modelset($model_name, $modelset); + self::get_model_cache()::cache_modelset($modelset); } # For many enabled Models return a ModelSet, otherwise return a single Model object diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index 66332c34..ab98feb7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -20,6 +20,18 @@ class ModelCache { */ public static ?self $instance = null; + /** + * @var array $index An associative array that contains cache Model objects that have been loaded and + * indexed by specific fields for quick lookup. Structured as: + * + * [Model class name] => [ + * [index field name] => [ + * [index field value] => Model object + * ] + * ] + */ + public static array $index = []; + /** * @var array $cache An associative array that contains cached ModelSet objects that have been loaded into memory. * This includes root ModelSets that contain all Model objects of a given Model class as well as indexed queries @@ -41,43 +53,185 @@ class ModelCache { } /** - * Checks if a given Model class has a cached ModelSet. - * @param string $model_class The Model class name to check in the cache. - * @return bool True if a cached ModelSet exists for the specified Model class, false otherwise. + * Checks if a cached ModelSet exists for a specified signature. + * @param string $signature The signature of the ModelSet to check for in the cache. + * @return bool True if a cached ModelSet exists with the specified signature, false otherwise. */ - public static function has_modelset(string $model_class): bool { - return isset(self::$cache[$model_class]); + public static function has_modelset(string $signature): bool { + return isset(self::$cache[$signature]); } /** - * Caches a ModelSet in the ModelCache. - * @param string $model_class The Model class name of the ModelSet to cache. + * Caches a ModelSet in the ModelCache by its signature. * @param ModelSet $model_set The ModelSet object to cache. */ - public static function cache_modelset(string $model_class, ModelSet $model_set): void { - self::$cache[$model_class] = $model_set; + public static function cache_modelset(ModelSet $model_set): void { + # Do not allow ModelSets to be cached without a valid signature + if (!$model_set->signature) { + throw new ServerError( + message: 'ModelSet cannot be cached without a valid signature.', + response_id: 'MODEL_CACHE_MODELSET_MISSING_SIGNATURE', + ); + } + + # Index the ModelSet in the cache by its signature + self::$cache[$model_set->signature] = $model_set; } /** * Fetches a cached ModelSet by its Model class name. - * @param string $model_class The Model class name to load from the cache. + * @param string $signature The signature of the ModelSet to fetch from the cache. * @return ModelSet The cached ModelSet - * @throws NotFoundError If no cached ModelSet exists for the specified Model class. + * @throws NotFoundError If no cached ModelSet exists for the specified signature. */ - public static function fetch_modelset(string $model_class): ModelSet { - if (!self::has_modelset($model_class)) { + public static function fetch_modelset(string $signature): ModelSet { + if (!self::has_modelset($signature)) { throw new NotFoundError( - message: "No cached ModelSet found for Model class '$model_class'.", + message: "No cached ModelSet found with signature '$signature'.", response_id: 'MODEL_CACHE_MODELSET_NOT_FOUND', ); } - return self::$cache[$model_class]; + return self::$cache[$signature]; + } + + /** + * Raises an error if the given Model object does not support being indexed by the specified field. + * @param Model $model The Model object to check for uniqueness. + * @param string $index_field The index field name to check for uniqueness. + * @throws ServerError If the Model is attempting to be indexed by a non-unique field. + * @throws ServerError If the Model is not many-enabled. + */ + private static function ensure_model_supports_indexing(Model $model, string $index_field): void { + # Only many-enabled Models can be indexed + if (!$model->many) { + throw new ServerError( + message: "Cannot index Model class '" . $model->get_class_fqn() . 'because it is not many-enabled.', + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL', + ); + } + + # Models with parent model classes cannot be indexed + if ($model->parent_model_class) { + throw new ServerError( + message: "Cannot index Model class '" . + $model->get_class_fqn() . + "' because it has a parent model class '" . + $model->parent_model_class . + "'.", + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', + ); + } + + # If indexing by 'id', it's always unique + if ($index_field === 'id') { + return; + } + + # Check if the index field is unique on the Model object + if (!$model->$index_field->unique) { + throw new ServerError( + message: "Cannot index Model class '" . + $model->get_class_fqn() . + "' by non-unique field " . + "'$index_field'.", + response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', + ); + } + } + + /** + * Indexes the ModelSet cache for a given Model class by a specified field. This method will populate + * the $index array for the specified Model class and index field. + * @param string $model_class The Model class name whose ModelSet is to be indexed. If no cached ModelSet exists, + * one will be created by reading all Model objects from the data source. + * @param string $index_field The field name to index the Model objects by. + * @return array An associative array of indexed Model objects. + */ + public static function index_modelset_by_field(string $model_class, string $index_field): array { + # First, check if this Model class has already been indexed by this field + if (isset(self::$index[$model_class][$index_field])) { + return self::$index[$model_class][$index_field]; + } + + # Ensure the Model can be indexed by the specified field + $model = new $model_class(); + self::ensure_model_supports_indexing($model, $index_field); + + # Fetch or create the ModelSet for this Model class + $model_set = $model->read_all(); + + # Create an associative array to hold the indexed Model objects + $indexed_models = []; + + # Index each Model object in the ModelSet by the specified field + foreach ($model_set->model_objects as $model) { + $index_value = $index_field === 'id' ? $model->id : $model->$index_field->value; + $indexed_models[$index_value] = $model; + } + + # Store the indexed models in the ModelCache index and return them + self::$index[$model_class][$index_field] = $indexed_models; + return $indexed_models; + } + + /** + * Checks if a given Model class has a cached Model object by its index field/value. + * @param string $model_class The Model class name to check in the cache. + * @param string $index_field The index field name to look up the Model object by + * @param mixed $index_value The index field value to look up the Model object by + * @return bool True if a cached Model object exists for the specified Model class and index + */ + public static function has_model(string $model_class, string $index_field = 'id', mixed $index_value = null): bool { + # Cache is always a miss if the Model class is not indexed + if (!self::$index[$model_class]) { + return false; + } + + # Cache is a hit for non-many enabled models if a single Model object is indexed under the Model class + if (self::$index[$model_class] instanceof Model) { + return true; + } + + # Otherwise, cache is only a hit for many enabled models if the index field/value exists in the index + return isset(self::$index[$model_class][$index_field][$index_value]); + } + + /** + * Fetches a cached Model object by its Model class name and index field/value. + * @param string $model_class The Model class name to load from the cache. + * @param string $index_field The index field name to look up the Model object by. Only for many enabled Models. + * @param mixed $index_value The index field value to look up the Model object by. Only for many enabled Models. + * @return Model The cached Model object + * @throws NotFoundError If no cached Model object exists for the specified Model class and index. + */ + public static function fetch_model( + string $model_class, + string $index_field = 'id', + mixed $index_value = null, + ): Model { + if (!self::has_model($model_class, $index_field, $index_value)) { + throw new NotFoundError( + message: "No cached Model found for Model class '$model_class' with " . + "index field '$index_field' and index value '$index_value'.", + response_id: 'MODEL_CACHE_MODEL_NOT_FOUND', + ); + } + + # For many enabled models, return the Model object indexed under the specified index field and value + if (isset(self::$index[$model_class][$index_field][$index_value])) { + return self::$index[$model_class][$index_field][$index_value]; + } + + # Otherwise, return the Model object indexed under the Model class only + return self::$index[$model_class]; } + /** * Clears the ModelCache of all cached ModelSets and indexed Model objects. */ public static function clear(): void { self::$cache = []; + self::$index = []; } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc index 3573a4d8..d594b1de 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc @@ -19,7 +19,7 @@ class ModelSet { * a series of query signatures that represent each query that has been applied to this ModelSet to this point * (separated by a colon). Example 'RESTAPI\Models\User:queryhash1:queryhash2' */ - public string $signature = ''; + public string $signature; /** * @var array $model_objects An array of Model objects contained by this ModelSet. @@ -30,7 +30,10 @@ class ModelSet { * Creates a ModelSet object that contains multiple Model objects. * @param array $model_objects An array of model objects to include in this model set */ - public function __construct(array $model_objects = []) { + public function __construct(array $model_objects = [], string $signature = '') { + # Assign the signature if provided + $this->signature = $signature; + # Throw an error if any Models are not a Model object foreach ($model_objects as $model_object) { if (!is_object($model_object) or !in_array('RESTAPI\Core\Model', class_parents($model_object))) { @@ -180,6 +183,38 @@ class ModelSet { return $results; } + /** + * Provides access to the ModelCache singleton instance. + * @return ModelCache The ModelCache singleton instance. + */ + public static function get_model_cache(): ModelCache { + return ModelCache::get_instance(); + } + + /** + * Determines the signature for a given query. This signature is used to index queried ModelSets in the + * ModelCache while retaining all query chains. + * @param array $query_params An associative array of query parameters to use to generate the signature. + * @return string The signature for the given query parameters. + */ + public function get_query_signature(array $query_params = []): string + { + # ModelSet is cache exempt if there is no existing signature + if (!$this->signature) { + return ''; + } + + # Sort the query parameters by key to ensure consistent signatures + ksort($query_params); + + # Encode the query parameters as JSON and generate an xxh hash + $query_json = json_encode($query_params); + $query_hash = hash(algo: 'xxh64', data: $query_json); + + # Append the query hash to the existing signature to retain query chains + return "$this->signature:$query_hash"; + } + /** * Filters the ModelSet to only include Model objects that match a specific query. * @param array $query_params An associative array of query targets and values. The array key will be the query @@ -193,9 +228,14 @@ class ModelSet { public function query(array $query_params = [], array $excluded = [], ...$vl_query_params): ModelSet { # Variables $queried_model_set = []; - - # Merge the $query_params and any provided variable-length arguments into a single variable $query_params = array_merge($query_params, $vl_query_params); + $query_signature = $this->get_query_signature($query_params); + $cache_exempt = !$query_signature; + + # Fetch the cached query if it exists and this ModelSet is not cache exempt + if (!$cache_exempt and self::get_model_cache()::has_modelset($query_signature)) { + return self::get_model_cache()::fetch_modelset($query_signature); + } # Loop through each model object in the provided model set and check it against the query parameters foreach ($this->model_objects as $model_object) { @@ -230,7 +270,16 @@ class ModelSet { $queried_model_set[] = $model_object; } } - return new ModelSet($queried_model_set); + + # Create a new ModelSet with the queried model objects + $modelset = new ModelSet($queried_model_set, signature: $query_signature); + + # Cache the queried ModelSet if this ModelSet is not cache exempt + if (!$cache_exempt) { + self::get_model_cache()::cache_modelset($modelset); + } + + return $modelset; } /** 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 6020a752..a5356783 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 @@ -7,6 +7,7 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI; use RESTAPI\Core\Field; use RESTAPI\Core\Model; +use RESTAPI\Core\ModelCache; use RESTAPI\Core\ModelSet; use RESTAPI\Responses\NotFoundError; use RESTAPI\Responses\ServerError; @@ -212,27 +213,69 @@ class ForeignModelField extends Field { } /** - * Converts the field value to its representation form from it's internal pfSense configuration value. - * @param mixed $internal_value The internal value from the pfSense configuration. - * @return mixed The field value in its representation form. + * Ensures that the foreign models this field relates to are indexed by their foreign model fields and + * internal fields for faster querying. */ - protected function _from_internal(mixed $internal_value): mixed { - # Ensure the internal field value is converted to its representation value before querying - if ($this->model_field_internal !== 'id') { - $internal_value = $this->models[0]->{$this->model_field_internal}->_from_internal($internal_value); + protected function _index_foreign_models(): void { + # Index all Models from the referenced Model classes + foreach ($this->models as $model) { + $model_name = $model->get_class_fqn(); + + # Index the Model objects by the `model_field` + ModelCache::get_instance()::index_modelset_by_field( + model_class: $model_name, + index_field: $this->model_field, + ); + + # Index the Model objects by the `model_field_internal` if different + if ($this->model_field_internal !== $this->model_field) { + ModelCache::get_instance()::index_modelset_by_field( + model_class: $model_name, + index_field: $this->model_field_internal, + ); + } } + } + + /** + * Obtains the referenced Model object from the model cache index by its internal value. + */ + public function get_referenced_model_by_internal_value(mixed $internal_value): Model|null { + # First, obtain the model cache and ensure foreign models are indexed + $model_cache = ModelCache::get_instance(); + $this->_index_foreign_models(); - # If the model_field_internal, and the model_field are the same, return the internal value as-is. - if ($this->model_field_internal === $this->model_field) { - return $internal_value; + # Then, attempt to obtain the Model object from the index + foreach ($this->models as $model) { + # Obtain the Model class FQN + $model_name = $model->get_class_fqn(); + + # Check if a Model object is indexed by this internal value + if ($model_cache::has_model($model_name, index_field: $this->model_field_internal, index_value: $internal_value)) { + return $model_cache::fetch_model( + model_class: $model_name, + index_field: $this->model_field_internal, + index_value: $internal_value, + ); + } } - # Query for the Model object this value relates to. - $query_modelset = $this->__get_matches($this->model_field_internal, $internal_value); + # Otherwise, return null + return null; + } - # If the model object exists, return the `model_field` value. - if ($query_modelset->exists()) { - return $query_modelset->first()->{$this->model_field}->value; + /** + * Converts the field value to its representation form from it's internal pfSense configuration value. + * @param mixed $internal_value The internal value from the pfSense configuration. + * @return mixed The field value in its representation form. + */ + protected function _from_internal(mixed $internal_value): mixed { + # Fetch the related Model object using the internal value + $related_model = $this->get_referenced_model_by_internal_value($internal_value); + + # If the related Model object exists, return the existing `model_field` value. + if ($related_model !== null) { + return $related_model->{$this->model_field}->value; } # As a failsafe, return the existing internal value if we could not find the related object @@ -287,40 +330,34 @@ class ForeignModelField extends Field { /** * Obtains the ModelSet of Model objects that are in-scope for this field using the $model_query and * $parent_model_query properties. - * @return ModelSet A ModelSet of Model objects that are in-scope for this field + * @return array An array of ModelSet objects containing all in-scope Models for this ForeignModelField. */ - public function get_in_scope_models(): ModelSet { + public function get_in_scope_models(): array { # Variables - $in_scope_models = new ModelSet(); + $in_scope_modelsets = []; # Get in scope Models from all assigned $model_name classes foreach ($this->models as $model) { - # Create a new ModelSet to store the in-scope Models for this model class - $models = new ModelSet(); - - # Obtain the parent Models if the assigned $model has a $parent_model_class assigned to it. + # For Models with parent Mdoels assigned, ensure only child Models from in-scope parent Models are included if ($model->parent_model_class) { $parent_model_class = $model->get_parent_model(); $parent_model = new $parent_model_class(); $parent_models = $parent_model->query($this->parent_model_query); - - # Loop through each identified parent and add its children into the ModelSet - foreach ($parent_models->model_objects as $parent) { - $parent_children = $model->query(parent_id: $parent->id); - $models->model_objects = array_merge($models->model_objects, $parent_children->model_objects); - } + $in_scope_parent_model_ids = array_map(fn($parent) => $parent->id, $parent_models->model_objects); + $models = $model->query(parent_id__in: $in_scope_parent_model_ids); } # Otherwise, just use all of this $model's current objects else { $models = $model->read_all(); } - # Add the in-scope Models to the ModelSet - $in_scope_models->model_objects = array_merge($in_scope_models->model_objects, $models->model_objects); + # Filter out Models that do not meet the assigned $model_query criteria + $models = $models->query($this->model_query); + $in_scope_modelsets[] = $models; } # Query for the Model object this value relates to. - return $in_scope_models->query($this->model_query); + return $in_scope_modelsets; } /** @@ -331,7 +368,20 @@ class ForeignModelField extends Field { * to the same value as $this->value. */ private function __get_matches(string $field_name, mixed $field_value): ModelSet { - return $this->get_in_scope_models()->query(query_params: [$field_name => $field_value]); + # Loop through all in cope modelsets to find matches + foreach ($this->get_in_scope_models() as $modelset) { + # Query for Model objects that match this field's criteria + $query_params = [$field_name => $field_value]; + $query_modelset = $modelset->query($query_params); + + # Return the matching ModelSet if it exists + if ($query_modelset->exists()) { + return $query_modelset; + } + } + + # Return an empty ModelSet if no matches were found + return new ModelSet(); } /** diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc index d23bf81b..e131623f 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc @@ -36,7 +36,7 @@ class APICoreModelCacheTestCase extends TestCase { $this->assert_is_false($model_cache::has_modelset('MockModelClass')); # Populate the ModelCache with a mock ModelSet for testing - ModelCache::$cache['MockModelClass'] = new ModelSet(); + ModelCache::$cache['MockModelClass'] = new ModelSet(signature: 'MockModelClass'); $this->assert_is_true($model_cache::has_modelset('MockModelClass')); # Clear the ModelCache and ensure the ModelSet is removed @@ -53,10 +53,10 @@ class APICoreModelCacheTestCase extends TestCase { $model_cache::clear(); # Create a mock ModelSet to cache - $mock_model_set = new ModelSet(); + $mock_model_set = new ModelSet(signature: 'MockModelClass'); # Cache the mock ModelSet in the ModelCache - $model_cache::cache_modelset('MockModelClass', $mock_model_set); + $model_cache::cache_modelset($mock_model_set); # Ensure the ModelSet was cached correctly $this->assert_is_true($model_cache::has_modelset('MockModelClass')); @@ -83,4 +83,142 @@ class APICoreModelCacheTestCase extends TestCase { }, ); } + + /** + * Ensures a ModelSet without a signature cannot be cached in the ModelCache. + */ + public function test_model_cache_cache_modelset_without_signature_throws_error(): void + { + # Obtain and clear the ModelCache + $model_cache = ModelCache::get_instance(); + $model_cache::clear(); + + # Create a mock ModelSet without a signature + $mock_model_set = new ModelSet(); + + # Ensure caching a ModelSet without a signature throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_MODELSET_MISSING_SIGNATURE', + code: 500, + callable: function () use ($mock_model_set) { + ModelCache::cache_modelset($mock_model_set); + }, + ); + + # Clear the ModelCache + $model_cache::clear(); + } + + /** + * Ensures Models must be many-enabled to be indexed in the ModelCache. + */ + public function test_model_index_must_be_many_enabled(): void { + # Ensure attempting to index a non-many enabled Model throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_NON_MANY_MODEL', + code: 500, + callable: function () { + ModelCache::index_modelset_by_field(model_class: RESTAPISettings::get_class_fqn(), index_field: 'id'); + }, + ); + } + + /** + * Ensures we cannot index a cached ModelSet by a non-unique field. + */ + public function test_model_index_field_must_be_unique(): void { + # Ensure indexing by id is always allowed + $this->assert_does_not_throw(function () { + ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'id'); + }); + + # Ensure attempting to index the Model by the non-unique field throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', + code: 500, + callable: function () { + ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'descr'); + }, + ); + } + + /** + * Ensures we cannot index a cached ModelSet for a Model that has a parent model class. + */ + public function test_model_index_field_cannot_have_parent_model_class(): void { + # Ensure attempting to index the Model by the non-unique field throws an error + $this->assert_throws_response( + response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', + code: 500, + callable: function () { + ModelCache::index_modelset_by_field( + model_class: DNSResolverHostOverrideAlias::get_class_fqn(), + index_field: 'id', + ); + }, + ); + } + + /** + * Ensures the index_modelset_by_field() method correctly indexes a cached ModelSet by the specified field. This + * test also ensures the has_model() and fetch_model() methods work as expected for indexed Model objects. + */ + public function test_model_index_modelset_by_field(): void { + # Create FirewallAlias model objects to test with + $alias1 = new FirewallAlias(data: ['name' => 'host_alias', 'descr' => 'First alias', 'type' => 'host']); + $alias2 = new FirewallAlias(data: ['name' => 'network_alias', 'descr' => 'Second alias', 'type' => 'network']); + $alias3 = new FirewallAlias(data: ['name' => 'port_alias', 'descr' => 'Third alias', 'type' => 'port']); + $alias1->create(); + $alias2->create(); + $alias3->create(); + + # Index the FirewallAlias ModelSet by the 'name' field + ModelCache::index_modelset_by_field(model_class: FirewallAlias::get_class_fqn(), index_field: 'name'); + + # Ensure the ModelCache has the indexed Models + $this->assert_is_true( + ModelCache::has_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'host_alias', + ), + ); + $this->assert_is_true( + ModelCache::has_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'network_alias', + ), + ); + $this->assert_is_true( + ModelCache::has_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'port_alias', + ), + ); + + # Fetch the indexed Models and ensure they are correct + $fetched_alias1 = ModelCache::fetch_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'host_alias', + ); + $this->assert_equals($fetched_alias1->name->value, 'host_alias'); + $fetched_alias2 = ModelCache::fetch_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'network_alias', + ); + $this->assert_equals($fetched_alias2->name->value, 'network_alias'); + $fetched_alias3 = ModelCache::fetch_model( + model_class: FirewallAlias::get_class_fqn(), + index_field: 'name', + index_value: 'port_alias', + ); + $this->assert_equals($fetched_alias3->name->value, 'port_alias'); + + # Clean up the created aliases + FirewallAlias::delete_all(); + } } From 9b27398b8a3787cce08acdac19b6c9e91debcce5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 17:11:17 -0700 Subject: [PATCH 30/73] style: run prettier on changed files --- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 17 ++++++++--------- .../usr/local/pkg/RESTAPI/Core/ModelSet.inc | 3 +-- .../pkg/RESTAPI/Fields/ForeignModelField.inc | 8 +++++++- .../RESTAPI/Models/RESTAPIAccessListEntry.inc | 2 +- .../RESTAPI/Tests/APICoreModelCacheTestCase.inc | 3 +-- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index ab98feb7..cbac7f63 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -114,10 +114,10 @@ class ModelCache { if ($model->parent_model_class) { throw new ServerError( message: "Cannot index Model class '" . - $model->get_class_fqn() . - "' because it has a parent model class '" . - $model->parent_model_class . - "'.", + $model->get_class_fqn() . + "' because it has a parent model class '" . + $model->parent_model_class . + "'.", response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', ); } @@ -131,9 +131,9 @@ class ModelCache { if (!$model->$index_field->unique) { throw new ServerError( message: "Cannot index Model class '" . - $model->get_class_fqn() . - "' by non-unique field " . - "'$index_field'.", + $model->get_class_fqn() . + "' by non-unique field " . + "'$index_field'.", response_id: 'MODEL_CACHE_INDEX_FIELD_NOT_UNIQUE', ); } @@ -212,7 +212,7 @@ class ModelCache { if (!self::has_model($model_class, $index_field, $index_value)) { throw new NotFoundError( message: "No cached Model found for Model class '$model_class' with " . - "index field '$index_field' and index value '$index_value'.", + "index field '$index_field' and index value '$index_value'.", response_id: 'MODEL_CACHE_MODEL_NOT_FOUND', ); } @@ -226,7 +226,6 @@ class ModelCache { return self::$index[$model_class]; } - /** * Clears the ModelCache of all cached ModelSets and indexed Model objects. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc index d594b1de..54cf4b45 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc @@ -197,8 +197,7 @@ class ModelSet { * @param array $query_params An associative array of query parameters to use to generate the signature. * @return string The signature for the given query parameters. */ - public function get_query_signature(array $query_params = []): string - { + public function get_query_signature(array $query_params = []): string { # ModelSet is cache exempt if there is no existing signature if (!$this->signature) { return ''; 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 a5356783..2274222a 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 @@ -251,7 +251,13 @@ class ForeignModelField extends Field { $model_name = $model->get_class_fqn(); # Check if a Model object is indexed by this internal value - if ($model_cache::has_model($model_name, index_field: $this->model_field_internal, index_value: $internal_value)) { + if ( + $model_cache::has_model( + $model_name, + index_field: $this->model_field_internal, + index_value: $internal_value, + ) + ) { return $model_cache::fetch_model( model_class: $model_name, index_field: $this->model_field_internal, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc index 2d286b6b..6d54c851 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc @@ -60,7 +60,7 @@ class RESTAPIAccessListEntry extends Model { many: true, many_minimum: 0, help_text: 'The users that this entry applies to. Only users in this list will be affected by this ' . - 'entry. Leave empty if this entry should apply to all users.', + 'entry. Leave empty if this entry should apply to all users.', ); $this->sched = new ForeignModelField( model_name: 'FirewallSchedule', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc index e131623f..87f1777b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc @@ -87,8 +87,7 @@ class APICoreModelCacheTestCase extends TestCase { /** * Ensures a ModelSet without a signature cannot be cached in the ModelCache. */ - public function test_model_cache_cache_modelset_without_signature_throws_error(): void - { + public function test_model_cache_cache_modelset_without_signature_throws_error(): void { # Obtain and clear the ModelCache $model_cache = ModelCache::get_instance(); $model_cache::clear(); From e707560f66bb928dc532d60b7b8136fe9145c431 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 21:30:04 -0700 Subject: [PATCH 31/73] fix: allow indexing models with parents --- .../usr/local/pkg/RESTAPI/Core/ModelCache.inc | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index cbac7f63..f99e8ff6 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -110,23 +110,6 @@ class ModelCache { ); } - # Models with parent model classes cannot be indexed - if ($model->parent_model_class) { - throw new ServerError( - message: "Cannot index Model class '" . - $model->get_class_fqn() . - "' because it has a parent model class '" . - $model->parent_model_class . - "'.", - response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', - ); - } - - # If indexing by 'id', it's always unique - if ($index_field === 'id') { - return; - } - # Check if the index field is unique on the Model object if (!$model->$index_field->unique) { throw new ServerError( From 33d3247806c70f705f324c72bb66c8007fd722e4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 22:33:43 -0700 Subject: [PATCH 32/73] fix: make FirewallRule associated_rule_id unique 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 3b63f24d..45f6f3a9 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 @@ -272,6 +272,7 @@ class FirewallRule extends Model { help_text: 'The internal tracking ID for this firewall rule.', ); $this->associated_rule_id = new StringField( + unique: true, default: null, allow_null: true, editable: false, From 8df0422ae24dc2d4052d59e321d804101c06c77b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 22:34:02 -0700 Subject: [PATCH 33/73] fix: make Service name unique field --- .../files/usr/local/pkg/RESTAPI/Models/Service.inc | 6 +++++- 1 file changed, 5 insertions(+), 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 a7b38aa5..669afd2a 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 @@ -30,7 +30,11 @@ class Service extends Model { write_only: true, help_text: 'The action to perform against this service.', ); - $this->name = new StringField(read_only: true, help_text: 'The internal name of the service.'); + $this->name = new StringField( + unique: true, + read_only: true, + help_text: 'The internal name of the service.' + ); $this->description = new StringField(read_only: true, help_text: 'The full descriptive name of the service.'); $this->enabled = new BooleanField( read_only: true, From e32be353b82dd31fe5664c998614d2f921ff55bd Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 22:34:47 -0700 Subject: [PATCH 34/73] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Models/Service.inc | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 669afd2a..99722732 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 @@ -30,11 +30,7 @@ class Service extends Model { write_only: true, help_text: 'The action to perform against this service.', ); - $this->name = new StringField( - unique: true, - read_only: true, - help_text: 'The internal name of the service.' - ); + $this->name = new StringField(unique: true, read_only: true, help_text: 'The internal name of the service.'); $this->description = new StringField(read_only: true, help_text: 'The full descriptive name of the service.'); $this->enabled = new BooleanField( read_only: true, From 013a71873e40709608118f3b8cf51d16d18d2187 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 16 Dec 2025 23:37:37 -0700 Subject: [PATCH 35/73] revert: restore ability to index by id --- .../files/usr/local/pkg/RESTAPI/Core/ModelCache.inc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc index f99e8ff6..0ede60d4 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc @@ -110,6 +110,11 @@ class ModelCache { ); } + # Always allow indexing by 'id' + if ($index_field === 'id') { + return; + } + # Check if the index field is unique on the Model object if (!$model->$index_field->unique) { throw new ServerError( From d6a6d50eddfc3d7fd495ecb96e018f3be67cfaf2 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 19 Dec 2025 19:59:55 -0700 Subject: [PATCH 36/73] test: remove test that ensure child models couldn't be indexed --- .../RESTAPI/Tests/APICoreModelCacheTestCase.inc | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc index 87f1777b..63bbfc07 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc @@ -141,23 +141,6 @@ class APICoreModelCacheTestCase extends TestCase { ); } - /** - * Ensures we cannot index a cached ModelSet for a Model that has a parent model class. - */ - public function test_model_index_field_cannot_have_parent_model_class(): void { - # Ensure attempting to index the Model by the non-unique field throws an error - $this->assert_throws_response( - response_id: 'MODEL_CACHE_INDEX_FIELD_ON_PARENTED_MODEL', - code: 500, - callable: function () { - ModelCache::index_modelset_by_field( - model_class: DNSResolverHostOverrideAlias::get_class_fqn(), - index_field: 'id', - ); - }, - ); - } - /** * Ensures the index_modelset_by_field() method correctly indexes a cached ModelSet by the specified field. This * test also ensures the has_model() and fetch_model() methods work as expected for indexed Model objects. From d14e54f843f9487a47a9cc6fec1e9f0bea85c052 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 19 Dec 2025 20:16:21 -0700 Subject: [PATCH 37/73] refactor!: require explicit creation of DHCPServer objects Previously, anytime a DHCPServer object was constructed it would automatically initialize empty configurations for eligible interfaces. This caused some issues with some backend DHCPServer functions built into pfSense 2.8.0+. Now DHCPServers that don't already exist in the config must be created via POST request first. This will result in more consistent behavior. --- .../Endpoints/ServicesDHCPServerEndpoint.inc | 2 +- .../local/pkg/RESTAPI/Models/DHCPServer.inc | 24 ------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerEndpoint.inc index a933a435..cfd21002 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerEndpoint.inc @@ -15,7 +15,7 @@ class ServicesDHCPServerEndpoint extends Endpoint { # Set Endpoint attributes $this->url = '/api/v2/services/dhcp_server'; $this->model_name = 'DHCPServer'; - $this->request_method_options = ['GET', 'PATCH']; + $this->request_method_options = ['GET', 'POST', 'PATCH', 'DELETE']; # Construct the parent Endpoint object parent::__construct(); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc index 98d42bba..0ab7ab2c 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc @@ -245,33 +245,9 @@ class DHCPServer extends Model { help_text: 'Static mappings applied to this DHCP server.', ); - # Ensure all interfaces have DHCP server objects initialized - $this->init_interfaces(); - parent::__construct($id, $parent_id, $data, ...$options); } - /** - * Initializes configuration objects for defined interface that have not yet configured the DHCP server - */ - private function init_interfaces(): void { - # Variables - $ifs_using_dhcp_server = array_keys($this->get_config(path: $this->config_path, default: [])); - - # Loop through each defined interface - foreach ($this->get_config('interfaces', []) as $if_id => $if) { - # Skip this interface if it is not a static interface or the subnet value is greater than or equal to 31 - if (empty($if['ipaddr']) or !is_ipaddrv4($if['ipaddr']) or $if->subnet->value >= 31) { - continue; - } - - # Otherwise, make this interface eligible for a DHCP server - if (!in_array($if_id, $ifs_using_dhcp_server)) { - $this->set_config(path: "$this->config_path/$if_id", value: ['range' => ['from' => '', 'to' => '']]); - } - } - } - /** * Obtains the internal `interface` field value since it is not stored in the config. * @return string The internal interface ID of the DHCPServer. From 6fd4f937433e7ad363ada904eeaf9eecf3761f43 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 20 Dec 2025 10:09:51 -0700 Subject: [PATCH 38/73] feat: add endpoints to allow querying child model objects #587 --- ...TrafficShaperLimiterBandwidthsEndpoint.inc | 24 +++++++++++++++++++ ...wallTrafficShaperLimiterQueuesEndpoint.inc | 24 +++++++++++++++++++ .../FirewallTrafficShaperQueuesEndpoint.inc | 24 +++++++++++++++++++ .../RoutingGatewayGroupPrioritiesEndpoint.inc | 24 +++++++++++++++++++ .../ServicesBINDAccessListEntriesEndpoint.inc | 24 +++++++++++++++++++ ...ServicesDHCPServerAddressPoolsEndpoint.inc | 24 +++++++++++++++++++ ...ervicesDHCPServerCustomOptionsEndpoint.inc | 24 +++++++++++++++++++ ...rvicesDHCPServerStaticMappingsEndpoint.inc | 24 +++++++++++++++++++ ...NSForwarderHostOverrideAliasesEndpoint.inc | 24 +++++++++++++++++++ ...sDNSResolverAccessListNetworksEndpoint.inc | 24 +++++++++++++++++++ ...DNSResolverHostOverrideAliasesEndpoint.inc | 4 ++-- .../ServicesHAProxyBackendACLsEndpoint.inc | 24 +++++++++++++++++++ .../ServicesHAProxyBackendActionsEndpoint.inc | 24 +++++++++++++++++++ ...rvicesHAProxyBackendErrorFilesEndpoint.inc | 24 +++++++++++++++++++ .../ServicesHAProxyBackendServersEndpoint.inc | 24 +++++++++++++++++++ .../ServicesHAProxyFrontendACLsEndpoint.inc | 24 +++++++++++++++++++ ...ServicesHAProxyFrontendActionsEndpoint.inc | 24 +++++++++++++++++++ ...rvicesHAProxyFrontendAddressesEndpoint.inc | 24 +++++++++++++++++++ ...cesHAProxyFrontendCertificatesEndpoint.inc | 24 +++++++++++++++++++ ...vicesHAProxyFrontendErrorFilesEndpoint.inc | 24 +++++++++++++++++++ ...cesHAProxySettingsDNSResolversEndpoint.inc | 24 +++++++++++++++++++ ...cesHAProxySettingsEmailMailersEndpoint.inc | 24 +++++++++++++++++++ .../Endpoints/StatusIPsecChildSAsEndpoint.inc | 24 +++++++++++++++++++ ...StatusOpenVPNServerConnectionsEndpoint.inc | 24 +++++++++++++++++++ .../StatusOpenVPNServerRoutesEndpoint.inc | 24 +++++++++++++++++++ .../VPNIPsecPhase1EncryptionsEndpoint.inc | 24 +++++++++++++++++++ .../VPNIPsecPhase2EncryptionsEndpoint.inc | 24 +++++++++++++++++++ .../VPNWireGuardPeerAllowedIPsEndpoint.inc | 24 +++++++++++++++++++ .../VPNWireGuardTunnelAddressesEndpoint.inc | 24 +++++++++++++++++++ 29 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterBandwidthsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterQueuesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperQueuesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/RoutingGatewayGroupPrioritiesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEntriesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerAddressPoolsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerCustomOptionsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerStaticMappingsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSForwarderHostOverrideAliasesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverAccessListNetworksEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendACLsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendActionsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendErrorFilesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendServersEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendACLsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendActionsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendAddressesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendCertificatesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendErrorFilesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsDNSResolversEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsEmailMailersEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerConnectionsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerRoutesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase1EncryptionsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase2EncryptionsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardPeerAllowedIPsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardTunnelAddressesEndpoint.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterBandwidthsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterBandwidthsEndpoint.inc new file mode 100644 index 00000000..333c5732 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterBandwidthsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/firewall/traffic_shaper/limiter/bandwidths'; + $this->model_name = 'TrafficShaperLimiterBandwidth'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterQueuesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterQueuesEndpoint.inc new file mode 100644 index 00000000..9f99b142 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperLimiterQueuesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/firewall/traffic_shaper/limiter/queues'; + $this->model_name = 'TrafficShaperLimiterQueue'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperQueuesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperQueuesEndpoint.inc new file mode 100644 index 00000000..8dec459e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallTrafficShaperQueuesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/firewall/traffic_shaper/queues'; + $this->model_name = 'TrafficShaperQueue'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/RoutingGatewayGroupPrioritiesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/RoutingGatewayGroupPrioritiesEndpoint.inc new file mode 100644 index 00000000..35436b77 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/RoutingGatewayGroupPrioritiesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/routing/gateway/group/priorities'; + $this->model_name = 'RoutingGatewayGroupPriority'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEntriesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEntriesEndpoint.inc new file mode 100644 index 00000000..8bfd5004 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEntriesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/bind/access_list/entries'; + $this->model_name = 'BINDAccessListEntry'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerAddressPoolsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerAddressPoolsEndpoint.inc new file mode 100644 index 00000000..9edffec1 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerAddressPoolsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/dhcp_server/address_pools'; + $this->model_name = 'DHCPServerAddressPool'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerCustomOptionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerCustomOptionsEndpoint.inc new file mode 100644 index 00000000..8c306f42 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerCustomOptionsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/dhcp_server/custom_options'; + $this->model_name = 'DHCPServerCustomOption'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerStaticMappingsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerStaticMappingsEndpoint.inc new file mode 100644 index 00000000..7f8a7486 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerStaticMappingsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/dhcp_server/static_mappings'; + $this->model_name = 'DHCPServerStaticMapping'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSForwarderHostOverrideAliasesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSForwarderHostOverrideAliasesEndpoint.inc new file mode 100644 index 00000000..3cab5741 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSForwarderHostOverrideAliasesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/dns_forwarder/host_override/aliases'; + $this->model_name = 'DNSForwarderHostOverrideAlias'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverAccessListNetworksEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverAccessListNetworksEndpoint.inc new file mode 100644 index 00000000..91fe4ade --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverAccessListNetworksEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/dns_resolver/access_list/networks'; + $this->model_name = 'DNSResolverAccessListNetwork'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc index 640cd807..1b1ff554 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDNSResolverHostOverrideAliasesEndpoint.inc @@ -7,8 +7,8 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Endpoint; /** - * Defines an Endpoint for interacting with a singular DNSResolverHostOverrideAlias Model object at - * /api/v2/services/dns_resolver/host_override/alias. + * Defines an Endpoint for interacting with many DNSResolverHostOverrideAlias Model objects at + * /api/v2/services/dns_resolver/host_override/aliases. */ class ServicesDNSResolverHostOverrideAliasesEndpoint extends Endpoint { public function __construct() { diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendACLsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendACLsEndpoint.inc new file mode 100644 index 00000000..6fa63547 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendACLsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/backend/acls'; + $this->model_name = 'HAProxyBackendACL'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendActionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendActionsEndpoint.inc new file mode 100644 index 00000000..6f52a42e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendActionsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/backend/actions'; + $this->model_name = 'HAProxyBackendAction'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendErrorFilesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendErrorFilesEndpoint.inc new file mode 100644 index 00000000..6ee41ce9 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendErrorFilesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/backend/errorfiles'; + $this->model_name = 'HAProxyBackendErrorFile'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendServersEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendServersEndpoint.inc new file mode 100644 index 00000000..cc1ccc1c --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendServersEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/backend/servers'; + $this->model_name = 'HAProxyBackendServer'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendACLsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendACLsEndpoint.inc new file mode 100644 index 00000000..eeda4b3b --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendACLsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/frontend/acls'; + $this->model_name = 'HAProxyFrontendACL'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendActionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendActionsEndpoint.inc new file mode 100644 index 00000000..0db6f81c --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendActionsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/frontend/actions'; + $this->model_name = 'HAProxyFrontendAction'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendAddressesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendAddressesEndpoint.inc new file mode 100644 index 00000000..fa5a8a33 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendAddressesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/frontend/addresses'; + $this->model_name = 'HAProxyFrontendAddress'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendCertificatesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendCertificatesEndpoint.inc new file mode 100644 index 00000000..23e641a2 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendCertificatesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/frontend/certificates'; + $this->model_name = 'HAProxyFrontendCertificate'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendErrorFilesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendErrorFilesEndpoint.inc new file mode 100644 index 00000000..b2802df3 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyFrontendErrorFilesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/frontend/error_files'; + $this->model_name = 'HAProxyFrontendErrorFile'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsDNSResolversEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsDNSResolversEndpoint.inc new file mode 100644 index 00000000..c5aa0767 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsDNSResolversEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/settings/dns_resolvers'; + $this->model_name = 'HAProxyDNSResolver'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsEmailMailersEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsEmailMailersEndpoint.inc new file mode 100644 index 00000000..87de72f5 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxySettingsEmailMailersEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/haproxy/settings/email_mailers'; + $this->model_name = 'HAProxyEmailMailer'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAsEndpoint.inc new file mode 100644 index 00000000..8678d069 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/status/ipsec/child_sas'; + $this->model_name = 'IPsecChildSAStatus'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerConnectionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerConnectionsEndpoint.inc new file mode 100644 index 00000000..d7519096 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerConnectionsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/status/openvpn/server/connections'; + $this->model_name = 'OpenVPNServerConnectionStatus'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerRoutesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerRoutesEndpoint.inc new file mode 100644 index 00000000..fd7ff3e0 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerRoutesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/status/openvpn/server/routes'; + $this->model_name = 'OpenVPNServerRouteStatus'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase1EncryptionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase1EncryptionsEndpoint.inc new file mode 100644 index 00000000..a0d2ccf4 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase1EncryptionsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/ipsec/phase1/encryptions'; + $this->model_name = 'IPsecPhase1Encryption'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase2EncryptionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase2EncryptionsEndpoint.inc new file mode 100644 index 00000000..3aba4903 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNIPsecPhase2EncryptionsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/ipsec/phase2/encryptions'; + $this->model_name = 'IPsecPhase2Encryption'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardPeerAllowedIPsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardPeerAllowedIPsEndpoint.inc new file mode 100644 index 00000000..b78ff70c --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardPeerAllowedIPsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/wireguard/peer/allowed_ips'; + $this->model_name = 'WireGuardPeerAllowedIP'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardTunnelAddressesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardTunnelAddressesEndpoint.inc new file mode 100644 index 00000000..c81af156 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNWireGuardTunnelAddressesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/wireguard/tunnel/addresses'; + $this->model_name = 'WireGuardTunnelAddress'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} From ed18245e644fc0057672cba68c9ab285739b057d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 21 Dec 2025 09:39:13 -0700 Subject: [PATCH 39/73] feat: add missed endpoint for querying firewall schedule time ranges --- .../FirewallScheduleTimeRangesEndpoint.inc | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallScheduleTimeRangesEndpoint.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallScheduleTimeRangesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallScheduleTimeRangesEndpoint.inc new file mode 100644 index 00000000..7b754054 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallScheduleTimeRangesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/firewall/schedule/time_ranges'; + $this->model_name = 'FirewallScheduleTimeRange'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} From 0026d1e3742ec3afec144c97286cdae358d33cf2 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 21 Dec 2025 09:44:14 -0700 Subject: [PATCH 40/73] docs: notate that basic auth is not inherently insecure --- docs/SECURING_API_ACCESS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/SECURING_API_ACCESS.md b/docs/SECURING_API_ACCESS.md index f270bc8a..06b0ffdc 100644 --- a/docs/SECURING_API_ACCESS.md +++ b/docs/SECURING_API_ACCESS.md @@ -41,6 +41,11 @@ over secure connections (e.g., HTTPS or VPN). - Credentials are sent with each request, increasing the risk of interception, especially if not using HTTPS. - Credentials may also allow web GUI and/or SSH access, which may not be desirable for API-only users. +!!! Note + Basic authentication is not inherently insecure. In fact, with the proper user account management, strong passwords, + and secure transport (HTTPS), basic authentication can be just as secure as key-based authentication methods. + + ### JWT Authentication [JWT authentication](AUTHENTICATION_AND_AUTHORIZATION.md#json-web-token-jwt-authentication) allows you to authenticate From dfb4c25f1e40fd5a90680227833fac08d41bd636 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 21 Dec 2025 09:52:13 -0700 Subject: [PATCH 41/73] chore: add return type hints to tests DHCPServer tests --- .../Tests/APIModelsDHCPServerTestCase.inc | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc index fb7196cd..c48126a1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc @@ -14,11 +14,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server cannot be enabled on an interface that does not have a static IPv4 address. */ - public function test_cannot_enable_dhcp_server_on_non_static_interface() { + public function test_cannot_enable_dhcp_server_on_non_static_interface(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_CANNOT_ENABLE_WITHOUT_STATIC_IPV4', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); @@ -38,7 +38,7 @@ class APIModelsDHCPServerTestCase extends TestCase { $this->assert_throws_response( response_id: 'DHCP_SERVER_CANNOT_BE_ENABLED_WITH_DHCP_RELAY', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); @@ -54,11 +54,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server's `range_from` field cannot be the interface's network address. */ - public function test_range_from_cannot_network_address() { + public function test_range_from_cannot_network_address(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_RANGE_FROM_CANNOT_BE_NETWORK_ADDRESS', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $interface = new NetworkInterface(id: 'lan'); @@ -72,11 +72,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server's `range_from` field cannot be greater than `range_to` */ - public function test_range_from_cannot_be_greater_than_range_to() { + public function test_range_from_cannot_be_greater_than_range_to(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_RANGE_FROM_CANNOT_BE_GREATER_THAN_RANGE_TO', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->range_to->value = '192.168.1.1'; @@ -88,11 +88,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server's `range_from` cannot be outside the interface's subnet */ - public function test_range_from_cannot_be_outside_interface_subnet() { + public function test_range_from_cannot_be_outside_interface_subnet(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_RANGE_FROM_OUTSIDE_OF_SUBNET', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->validate_range_from(range_from: '1.2.3.4'); @@ -103,11 +103,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server's `range_to` field cannot be the interface's broadcast address. */ - public function test_range_to_cannot_broadcast_address() { + public function test_range_to_cannot_broadcast_address(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_RANGE_FROM_CANNOT_BE_BROADCAST_ADDRESS', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $interface = new NetworkInterface(id: 'lan'); @@ -121,11 +121,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server's `range_to` cannot be outside the interface's subnet */ - public function test_range_to_cannot_be_outside_interface_subnet() { + public function test_range_to_cannot_be_outside_interface_subnet(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_RANGE_TO_OUTSIDE_OF_SUBNET', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->validate_range_to(range_to: '1.2.3.4'); @@ -136,7 +136,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that a DHCP server's primary DHCP pool cannot conflict with an existing virtual IP. */ - public function test_primary_dhcp_pool_cannot_conflict_with_vip() { + public function test_primary_dhcp_pool_cannot_conflict_with_vip(): void { # Create a virtual IP to use for testing $vip = new VirtualIP(interface: 'lan', mode: 'ipalias', subnet: '192.168.1.2', subnet_bits: 24); $vip->create(apply: true); @@ -145,7 +145,7 @@ class APIModelsDHCPServerTestCase extends TestCase { $this->assert_throws_response( response_id: 'DHCP_SERVER_PRIMARY_POOL_OVERLAPS_EXISTING_OBJECT', code: 409, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->range_from->value = '192.168.1.2'; @@ -161,7 +161,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that the primary DHCP pool cannot conflict with an existing DHCP static mapping. */ - public function test_dhcp_pool_cannot_conflict_with_static_mapping() { + public function test_dhcp_pool_cannot_conflict_with_static_mapping(): void { # Add a DHCP static mapping $static_map = new DHCPServerStaticMapping( parent_id: 'lan', @@ -175,7 +175,7 @@ class APIModelsDHCPServerTestCase extends TestCase { $this->assert_throws_response( response_id: 'DHCP_SERVER_PRIMARY_POOL_OVERLAPS_EXISTING_OBJECT', code: 409, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->range_from->value = '192.168.1.2'; @@ -191,11 +191,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures that the `maxleasetime` cannot be set to a value lower than the `defaultleasttime`. */ - public function test_maxleasetime_cannot_be_lower_than_defaultleasetime() { + public function test_maxleasetime_cannot_be_lower_than_defaultleasetime(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_MAX_LEASE_TIME_LESS_THAN_DEFAULT', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->defaultleasetime->value = 25000; @@ -207,11 +207,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the DHCP server `gateway` value is a value within the interface's subnet. */ - public function test_dhcp_server_gateway_in_interface_subnet() { + public function test_dhcp_server_gateway_in_interface_subnet(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_GATEWAY_NOT_WITHIN_SUBNET', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->validate_gateway(gateway: '1.2.3.4'); @@ -222,11 +222,11 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures `nonak` cannot be enabled when a `failover_peerip` is set */ - public function test_no_nonak_with_failover_peerip() { + public function test_no_nonak_with_failover_peerip(): void { $this->assert_throws_response( response_id: 'DHCP_SERVER_NONAK_WITH_FAILOVER_PEERIP_NOT_ALLOWED', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->failover_peerip->value = '192.168.1.2'; @@ -238,7 +238,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures `staticarp` cannot be enabled if there are any static mappings without IPs. */ - public function test_no_staticarp_with_no_ip_static_mappings() { + public function test_no_staticarp_with_no_ip_static_mappings(): void { # Add a DHCP static mapping $static_mapping = new DHCPServerStaticMapping(parent_id: 'lan', mac: '00:00:00:00:00:00', async: false); $static_mapping->create(apply: true); @@ -247,7 +247,7 @@ class APIModelsDHCPServerTestCase extends TestCase { $this->assert_throws_response( response_id: 'DHCP_SERVER_STATICARP_WITH_NO_IP_STATIC_MAPPINGS', code: 400, - callable: function () { + callable: function (): void { # Use the `lan` DHCP server for this test $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->validate_staticarp(staticarp: true); @@ -262,7 +262,7 @@ class APIModelsDHCPServerTestCase extends TestCase { * Ensures the DHCP server process is killed for the assigned interface when `enable` is set to `false` and * ensures the DHCP server process is respawned when it is re-enabled. */ - public function test_enable_controls_dhcpd_process() { + public function test_enable_controls_dhcpd_process(): void { # Disable the DHCP server and ensure the `dhcpd` process is no longer running $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->enable->value = false; @@ -284,7 +284,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `ignorebootp` field correctly sets the dhcpd `ignore bootp` setting. */ - public function test_ignorebootp_set_dhcpd_ignore_bootp() { + public function test_ignorebootp_set_dhcpd_ignore_bootp(): void { # Enable `ignorebootp` and ensure it is set in the dhcpd.conf $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->ignorebootp->value = true; @@ -300,7 +300,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `denyunknown` field correctly sets the `deny` setting in dhcpd.conf */ - public function test_denyunknown_sets_dhcpd_deny() { + public function test_denyunknown_sets_dhcpd_deny(): void { # Set `denyunknown` to `enabled` and ensure dhcpd.conf denies all unknown clients $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->denyunknown->value = 'enabled'; @@ -333,7 +333,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `ignoreclientuids` field correctly sets the dhcpd `ignore-client-uid` setting. */ - public function test_ignoreclientuids_sets_dhcpd_ignore_client_uids() { + public function test_ignoreclientuids_sets_dhcpd_ignore_client_uids(): void { # Enable `ignoreclientuids` and ensure it is set in the dhcpd.conf $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->ignoreclientuids->value = true; @@ -352,7 +352,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `disablepingcheck` field correctly sets the dhcpd `ping-check` setting. */ - public function test_disablepingcheck_sets_dhcpd_ping_check() { + public function test_disablepingcheck_sets_dhcpd_ping_check(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->disablepingcheck->value = true; $dhcp_server->update(apply: true); @@ -366,7 +366,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `range_from` and `range_to` field correctly configures the DHCP range in dhcpd.conf */ - public function test_range_from_and_range_to_sets_dhcpd_range() { + public function test_range_from_and_range_to_sets_dhcpd_range(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->range_from->value = '192.168.1.105'; $dhcp_server->range_to->value = '192.168.1.108'; @@ -380,7 +380,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `winsserver` field correctly sets the WINS servers configured in dhcpd.conf */ - public function test_winsserver_sets_dhcpd_netbios_nameservers() { + public function test_winsserver_sets_dhcpd_netbios_nameservers(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->winsserver->value = ['192.168.1.100', '192.168.1.101']; $dhcp_server->update(apply: true); @@ -393,7 +393,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `dnsserver` field correctly sets the DNS servers configured in dhcpd.conf */ - public function test_dnsserver_sets_dhcpd_dns_servers() { + public function test_dnsserver_sets_dhcpd_dns_servers(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->dnsserver->value = ['192.168.1.100', '192.168.1.101']; $dhcp_server->update(apply: true); @@ -406,7 +406,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `ntpserver` field correctly sets the NTP servers configured in dhcpd.conf */ - public function test_ntpserver_sets_dhcpd_ntp_servers() { + public function test_ntpserver_sets_dhcpd_ntp_servers(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->ntpserver->value = ['192.168.1.100', '192.168.1.101']; $dhcp_server->update(apply: true); @@ -419,7 +419,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `mac_allow` field sets the proper dhcpd settings */ - public function test_mac_allow_sets_dhcpd_allow_members() { + public function test_mac_allow_sets_dhcpd_allow_members(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->mac_allow->value = ['11:22:33:aa:bb:cc']; $dhcp_server->update(apply: true); @@ -436,7 +436,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `mac_deny` field sets the proper dhcpd settings */ - public function test_mac_deny_sets_dhcpd_deny_members() { + public function test_mac_deny_sets_dhcpd_deny_members(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->mac_deny->value = ['11:22:33:aa:bb:cc']; $dhcp_server->update(apply: true); @@ -453,7 +453,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `gateway` field correctly sets the router configured in dhcpd.conf */ - public function test_gateway_sets_dhcpd_router() { + public function test_gateway_sets_dhcpd_router(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->gateway->value = '192.168.1.2'; $dhcp_server->update(apply: true); @@ -466,7 +466,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `domain` field correctly sets the domain name configured in dhcpd.conf */ - public function test_domain_sets_dhcpd_domain_name() { + public function test_domain_sets_dhcpd_domain_name(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->domain->value = 'example.com'; $dhcp_server->update(apply: true); @@ -479,7 +479,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `domainsearchlist` field correctly sets the domain search configured in dhcpd.conf */ - public function test_domainsearchlist_sets_dhcpd_domain_search() { + public function test_domainsearchlist_sets_dhcpd_domain_search(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->domainsearchlist->value = ['example.com']; $dhcp_server->update(apply: true); @@ -492,7 +492,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `defaultleasetime` field correctly sets the default-lease-time configured in dhcpd.conf */ - public function test_defaultleasetime_sets_dhcpd_default_lease_time() { + public function test_defaultleasetime_sets_dhcpd_default_lease_time(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->defaultleasetime->value = 7201; $dhcp_server->update(apply: true); @@ -502,7 +502,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `maxleasetime` field correctly sets the max-lease-time configured in dhcpd.conf */ - public function test_maxleasetime_sets_dhcpd_max_lease_time() { + public function test_maxleasetime_sets_dhcpd_max_lease_time(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->maxleasetime->value = 86401; $dhcp_server->update(apply: true); @@ -512,7 +512,7 @@ class APIModelsDHCPServerTestCase extends TestCase { /** * Ensures the `failover_peerip` field correctly sets the failover peer address configured in dhcpd.conf */ - public function test_failover_peerip_sets_dhcpd_peer_address() { + public function test_failover_peerip_sets_dhcpd_peer_address(): void { $dhcp_server = new DHCPServer(id: 'lan', async: false); $dhcp_server->failover_peerip->value = '192.168.1.2'; $dhcp_server->update(apply: true); From 841e4904c7aa4d2adf74330675cc52e1141914a4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 21 Dec 2025 10:00:01 -0700 Subject: [PATCH 42/73] test(DHCPServer): ensure DHCPServers can be created and deleted --- .../Tests/APIModelsDHCPServerTestCase.inc | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc index c48126a1..5ee3c18f 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc @@ -535,4 +535,23 @@ class APIModelsDHCPServerTestCase extends TestCase { # Ensure a config entry was not initialized for the non-static interface $this->assert_is_false(array_key_exists('opt1', Model::get_config('dhcpd'))); } + + /** + * Ensures DHCP servers can be newly created and deleted for new interfaces. + */ + public function test_dhcp_server_create_and_delete_for_new_interface(): void { + # Temporarily add a test interface using static IPv4 + $interface = new NetworkInterface(descr: 'TESTIF', typev4: 'static', ipaddr: '192.168.2.1', subnet: 24); + $interface->create(); + + # Ensure we can create a DHCP server for the new interface + $dhcp_server = new DHCPServer(interface: $interface->id, range_from: '192.168.2.10', range_to: '192.168.2.15'); + $dhcp_server->create(); + + # Ensure we can delete the DHCP server for the new interface + $dhcp_server->delete(); + + # Delete the test interface + $interface->delete(); + } } From 53ad41fef9029cc2d73f95aea6571aa82c61e8a3 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 21 Dec 2025 12:01:59 -0700 Subject: [PATCH 43/73] docs: fix misordered list item --- docs/SECURING_API_ACCESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SECURING_API_ACCESS.md b/docs/SECURING_API_ACCESS.md index 06b0ffdc..c203c05a 100644 --- a/docs/SECURING_API_ACCESS.md +++ b/docs/SECURING_API_ACCESS.md @@ -85,7 +85,7 @@ make API requests without human intervention. - Requires additional configuration to set up. - API keys need to be securely stored and managed. -## Step 4: Use API-specific user accounts +## Step 3: Use API-specific user accounts Regardless of the authentication method you choose, the REST API package uses pfSense's built-in privilege system to control access to API endpoints. This means that all credentials used for API access must belong to a pfSense user account From 446fd657c113f0b6d1b51d2380e76bbca5fca95f Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 2 Jan 2026 18:24:37 -0700 Subject: [PATCH 44/73] test(DHCPServer): ensure interface is correctly created before crud tests --- .../pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc index 5ee3c18f..3143d5d7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerTestCase.inc @@ -541,7 +541,13 @@ class APIModelsDHCPServerTestCase extends TestCase { */ public function test_dhcp_server_create_and_delete_for_new_interface(): void { # Temporarily add a test interface using static IPv4 - $interface = new NetworkInterface(descr: 'TESTIF', typev4: 'static', ipaddr: '192.168.2.1', subnet: 24); + $interface = new NetworkInterface( + if: $this->env['PFREST_OPT1_IF'], + descr: 'TESTIF', + typev4: 'static', + ipaddr: '192.168.2.1', + subnet: 24, + ); $interface->create(); # Ensure we can create a DHCP server for the new interface From ff777efd29f28d0ae3e069ceda370ac9ce5928eb Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 3 Jan 2026 11:40:21 -0700 Subject: [PATCH 45/73] feat: finalize package logging facilities --- .../usr/local/pkg/RESTAPI/Core/BaseTraits.inc | 19 +++++++++++++++++++ .../local/share/pfSense-pkg-RESTAPI/info.xml | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc index b877b8de..d190ce73 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc @@ -34,6 +34,25 @@ trait BaseTraits { return get_classes_from_namespace((new ReflectionClass($this))->getNamespaceName(), shortnames: $shortnames); } + /** + * Writes a log entry to the applicable log file + * @param int $level The log level to write. This should be one of the LOG_* constants defined by syslog. + * @param string $message The message to write to the log file. + * @param string $logfile The log file to write to. This must be a valid logging facility defined in the package's + * info.xml file. + */ + public function log(int $level, string $message, string $logfile = 'restapi'): void { + # Only log debug messages when verbose logging is enabled + if ($level === LOG_DEBUG and !$this->config->debug_mode) { + return; + } + + # Otherwise, write to the applicable log file + openlog($logfile, flags: LOG_PID, facility: LOG_LOCAL0); + syslog(priority: $level, message: $message); + closelog(); + } + /** * Logs an error to the syslog. * @param string $message The error message to write to the syslog diff --git a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml index 8a3cd33f..8f0a7e70 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml +++ b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml @@ -9,6 +9,11 @@ %%PKGVERSION%% restapi.xml github@jaredhendrickson.com + + REST API + restapi + restapi.log + enabled disabled From f9123a8ef2f3d7b6e217c3f791e29076f9f3cb1a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 3 Jan 2026 11:56:48 -0700 Subject: [PATCH 46/73] chore: warn about installation on 32-bit systems --- pfSense-pkg-RESTAPI/files/pkg-install.in | 8 ++++++++ .../files/usr/local/pkg/RESTAPI/Fields/FloatField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/IntegerField.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Fields/UnixTimeField.inc | 2 +- .../local/pkg/RESTAPI/Models/FirewallAdvancedSettings.inc | 2 +- .../files/usr/local/pkg/RESTAPI/Models/GraphQL.inc | 2 +- .../pkg/RESTAPI/Validators/NumericRangeValidator.inc | 2 +- 7 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/pkg-install.in b/pfSense-pkg-RESTAPI/files/pkg-install.in index 3572bedf..eefc3a6a 100644 --- a/pfSense-pkg-RESTAPI/files/pkg-install.in +++ b/pfSense-pkg-RESTAPI/files/pkg-install.in @@ -1,8 +1,16 @@ #!/bin/sh + if [ "${2}" != "POST-INSTALL" ]; then exit 0 fi +# Warn users installing on 32-bit systems +ARCH_BITS=$(/usr/bin/getconf LONG_BIT) +if [ "${ARCH_BITS}" = "32" ]; then + echo "!!! WARNING: This package is not supported on 32-bit systems. !!!" +fi + + # Make this package known to pfSense /usr/local/bin/php -f /etc/rc.packages %%PORTNAME%% ${2} 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 8ffa77be..25665d8e 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 @@ -82,7 +82,7 @@ class FloatField extends RESTAPI\Core\Field { int $many_minimum = 0, int $many_maximum = 128, public int $minimum = 0, - public int $maximum = 99999999999999, + public int $maximum = PHP_INT_MAX, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', 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 be02ee03..d77c79a1 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 @@ -83,7 +83,7 @@ class IntegerField extends Field { int $many_minimum = 0, int $many_maximum = 128, public int $minimum = 0, - public int $maximum = 99999999999999, + public int $maximum = PHP_INT_MAX, string|null $delimiter = ',', string $verbose_name = '', string $verbose_name_plural = '', 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 f4467860..47aac9c5 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 @@ -87,7 +87,7 @@ class UnixTimeField extends IntegerField { int $many_minimum = 0, int $many_maximum = 128, int $minimum = 0, - int $maximum = 99999999999999, + int $maximum = PHP_INT_MAX, public bool $auto_add_now = true, public bool $auto_update_now = false, ?string $delimiter = ',', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAdvancedSettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAdvancedSettings.inc index cae2e796..e771dfde 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAdvancedSettings.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAdvancedSettings.inc @@ -21,7 +21,7 @@ class FirewallAdvancedSettings extends Model { # Set model fields $this->aliasesresolveinterval = new IntegerField( - default: 999999999, + default: PHP_INT_MAX, minimum: 0, help_text: 'The interval (in seconds) at which to resolve hostnames in aliases.', ); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc index 1c08f146..c7f2e8c9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc @@ -31,7 +31,7 @@ class GraphQL extends Model { $this->query = new StringField( default: '', allow_empty: true, - maximum_length: 9999999999, + maximum_length: PHP_INT_MAX, help_text: 'The GraphQL query/mutation to execute.', ); $this->variables = new ObjectField( diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/NumericRangeValidator.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/NumericRangeValidator.inc index a60a4faa..149d6afe 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/NumericRangeValidator.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/NumericRangeValidator.inc @@ -20,7 +20,7 @@ class NumericRangeValidator extends RESTAPI\Core\Validator { * @param int $minimum The minimum value this value can be. * @param int $maximum The maximum value this value can be.. */ - public function __construct(int $minimum = 0, int $maximum = 99999999999) { + public function __construct(int $minimum = 0, int $maximum = PHP_INT_MAX) { $this->minimum = $minimum; $this->maximum = $maximum; } From 51d5efc09f44f6d68d6be346bec5289cad02caff Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 3 Jan 2026 12:00:47 -0700 Subject: [PATCH 47/73] docs: adjust warning about 32-bit hardware in documentation --- docs/INSTALL_AND_CONFIG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md index 811f137e..be45b52d 100644 --- a/docs/INSTALL_AND_CONFIG.md +++ b/docs/INSTALL_AND_CONFIG.md @@ -8,7 +8,7 @@ Overall, the REST API package is designed to be as lightweight as possible and s run pfSense. It's recommended to follow Netgate's [minimum hardware requirements](https://docs.netgate.com/pfsense/en/latest/hardware/minimum-requirements.html). !!! Warning - - The package is currently not compatible with 32-bit builds of pfSense. It is recommended to use the [legacy v1 package](https://github.com/jaredhendrickson13/pfsense-api/tree/legacy) for 32-bit systems. + - The package is currently not supported on 32-bit architectures (i386) like the Netgate 3100 (SG-3100). - While the package should behave identically on 64-bit architectures other than amd64, automated testing only covers amd64 builds of pfSense CE. Support on other architectures is not guaranteed. From 6fbc476c4a518f71e497c5895ef4ba62fd97f8f9 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 4 Jan 2026 16:23:06 -0700 Subject: [PATCH 48/73] docs: remove arch specific mention from 32-bit warning --- docs/INSTALL_AND_CONFIG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md index be45b52d..6fee067b 100644 --- a/docs/INSTALL_AND_CONFIG.md +++ b/docs/INSTALL_AND_CONFIG.md @@ -8,7 +8,7 @@ Overall, the REST API package is designed to be as lightweight as possible and s run pfSense. It's recommended to follow Netgate's [minimum hardware requirements](https://docs.netgate.com/pfsense/en/latest/hardware/minimum-requirements.html). !!! Warning - - The package is currently not supported on 32-bit architectures (i386) like the Netgate 3100 (SG-3100). + - The package is currently not supported on 32-bit architectures like the Netgate 3100 (SG-3100). - While the package should behave identically on 64-bit architectures other than amd64, automated testing only covers amd64 builds of pfSense CE. Support on other architectures is not guaranteed. From 5e8000dd792dd623bd5f0ef247b4627f5a6e2e55 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 4 Jan 2026 16:31:51 -0700 Subject: [PATCH 49/73] chore: typo/whitespace corrections --- pfSense-pkg-RESTAPI/files/pkg-install.in | 1 - pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/nginx.inc | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/pkg-install.in b/pfSense-pkg-RESTAPI/files/pkg-install.in index eefc3a6a..d7b4f390 100644 --- a/pfSense-pkg-RESTAPI/files/pkg-install.in +++ b/pfSense-pkg-RESTAPI/files/pkg-install.in @@ -10,7 +10,6 @@ if [ "${ARCH_BITS}" = "32" ]; then echo "!!! WARNING: This package is not supported on 32-bit systems. !!!" fi - # Make this package known to pfSense /usr/local/bin/php -f /etc/rc.packages %%PORTNAME%% ${2} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/nginx.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/nginx.inc index 05210b0c..84d09d6f 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/nginx.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/nginx.inc @@ -2,8 +2,8 @@ /** * The pfSense-pkg-RESTAPI's 'plugin_nginx' package hook. This function is automatically called by the pfSense - * package system to add custom NGINX configurations. For this packacge, this function ensures an nginx server block - * is defined for API endpoints to allow the utiliziation of additional HTTP methods like PUT, PATCH and DELETE and + * package system to add custom NGINX configurations. For this package, this function ensures an nginx server block + * is defined for API endpoints to allow the utilization of additional HTTP methods like PUT, PATCH and DELETE and * URLs without a trailing slash. * @param mixed $pluginparams Plugin parameters passed in by the pfSense package system. * @return string The custom nginx block to be added to the webConfigurator's nginx.conf file From a45a75a8c936bf816d4fe453c1044121bb07d061 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 4 Jan 2026 16:34:48 -0700 Subject: [PATCH 50/73] fix(PortForward): set disabled value during associated rule creation/update --- .../files/usr/local/pkg/RESTAPI/Models/PortForward.inc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc index eb87f2f2..504b2c1e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc @@ -209,6 +209,7 @@ class PortForward extends Model { if ($this->associated_rule_id->value and $this->associated_rule_id->value !== 'pass') { $firewall_rule = new FirewallRule( type: 'pass', + disabled: $this->disabled->value, interface: [$this->interface->value], ipprotocol: $this->ipprotocol->value, protocol: $this->protocol->value, @@ -250,6 +251,7 @@ class PortForward extends Model { $firewall_rule = $rule_q->first(); $firewall_rule->from_representation( type: 'pass', + disabled: $this->disabled->value, interface: [$this->interface->value], ipprotocol: $this->ipprotocol->value, protocol: $this->protocol->value, From 3bfb6b1b1cf977fd4589b8e01b4c7b7126d77126 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 4 Jan 2026 16:39:59 -0700 Subject: [PATCH 51/73] perf: allow Model classes to be marked as ModelCache exempt --- .../files/usr/local/pkg/RESTAPI/Core/Model.inc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 1fae28df..f468f4a4 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 @@ -144,6 +144,13 @@ class Model { */ public Cache|null $cache = null; + /** + * @var bool $model_cache_exempt + * If set to `true`, this Model will be exempt from any global Model caching mechanisms. This is primarily + * used for Models that frequently change or are not suitable for caching. + */ + public bool $model_cache_exempt = false; + /** * @var ModelSet $related_objects * A ModelSet containing foreign Model objects related to this Model. These are primarily populated by @@ -1943,7 +1950,7 @@ class Model { $model = new $model_name(); $model_objects = []; $requests_pagination = ($limit or $offset); - $cache_exempt = ($requests_pagination or $reverse or $model->internal_callable); + $cache_exempt = ($requests_pagination or $reverse or $model->model_cache_exempt); # Throw an error if pagination was requested on a Model without $many enabled if (!$model->many and $requests_pagination) { From 5ab8e5652ad2bd10ed91a605cf53b0d2b2ff86f5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 4 Jan 2026 16:54:33 -0700 Subject: [PATCH 52/73] feat: allow configurable log levels --- .../usr/local/pkg/RESTAPI/Core/BaseTraits.inc | 8 ++++++-- .../pkg/RESTAPI/Models/RESTAPISettings.inc | 17 +++++++++++++++++ .../local/share/pfSense-pkg-RESTAPI/info.xml | 1 + 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc index d190ce73..062b6a8b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc @@ -3,6 +3,7 @@ namespace RESTAPI\Core; use ReflectionClass; +use RESTAPI\Models\RESTAPISettings; use function RESTAPI\Core\Tools\get_classes_from_namespace; /** @@ -42,8 +43,11 @@ trait BaseTraits { * info.xml file. */ public function log(int $level, string $message, string $logfile = 'restapi'): void { - # Only log debug messages when verbose logging is enabled - if ($level === LOG_DEBUG and !$this->config->debug_mode) { + # Get the log level limit from the RESTAPI settings + $log_limit = (int) RESTAPISettings::get_pkg_config()['log_level'] ?? 3; + + # Do not log if the incoming level is higher than the configured log level + if ($level >= $log_limit) { return; } 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 53a71309..8db75fac 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 @@ -31,6 +31,7 @@ class RESTAPISettings extends Model { public BooleanField $keep_backup; public BooleanField $login_protection; public BooleanField $log_successful_auth; + public IntegerField $log_level; public BooleanField $allow_pre_releases; public BooleanField $hateoas; public BooleanField $expose_sensitive_fields; @@ -93,6 +94,22 @@ class RESTAPISettings extends Model { failed API authentication attempts are logged to prevent flooding the authentication logs. This field is only applicable when the API `login_protection` setting is enabled.", ); + $this->log_level = new IntegerField( + default: 3, + choices: [ + 0 => '0 - Emergency', + 1 => '1 - Alert', + 2 => '2 - Critical', + 3 => '3 - Error', + 4 => '4 - Warning', + 5 => '5 - Notice', + 6 => '6 - Info', + 7 => '7 - Debug', + ], + verbose_name: 'log level', + help_text: 'Sets the log level for API logging. The log level determines the severity of messages that are + logged. Setting a higher log level will include all messages of that level and lower severity.', + ); $this->allow_pre_releases = new BooleanField( default: false, indicates_true: 'enabled', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml index 8f0a7e70..d7fb9972 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml +++ b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml @@ -20,6 +20,7 @@ enabled enabled disabled + 3 disabled disabled From 80e4950c579e9b21056265d312776499e12e70fa Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 6 Jan 2026 22:18:44 -0700 Subject: [PATCH 53/73] refactor: use string representation for log levels --- .../usr/local/pkg/RESTAPI/Core/BaseTraits.inc | 2 +- .../pkg/RESTAPI/Models/RESTAPISettings.inc | 26 +++++++++---------- .../local/share/pfSense-pkg-RESTAPI/info.xml | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc index 062b6a8b..39264406 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc @@ -44,7 +44,7 @@ trait BaseTraits { */ public function log(int $level, string $message, string $logfile = 'restapi'): void { # Get the log level limit from the RESTAPI settings - $log_limit = (int) RESTAPISettings::get_pkg_config()['log_level'] ?? 3; + $log_limit = constant(RESTAPISettings::get_pkg_config()['log_level']) ?? 4; # Do not log if the incoming level is higher than the configured log level if ($level >= $log_limit) { 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 8db75fac..c6035578 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 @@ -31,7 +31,7 @@ class RESTAPISettings extends Model { public BooleanField $keep_backup; public BooleanField $login_protection; public BooleanField $log_successful_auth; - public IntegerField $log_level; + public StringField $log_level; public BooleanField $allow_pre_releases; public BooleanField $hateoas; public BooleanField $expose_sensitive_fields; @@ -94,21 +94,21 @@ class RESTAPISettings extends Model { failed API authentication attempts are logged to prevent flooding the authentication logs. This field is only applicable when the API `login_protection` setting is enabled.", ); - $this->log_level = new IntegerField( - default: 3, + $this->log_level = new StringField( + default: "LOG_WARNING", choices: [ - 0 => '0 - Emergency', - 1 => '1 - Alert', - 2 => '2 - Critical', - 3 => '3 - Error', - 4 => '4 - Warning', - 5 => '5 - Notice', - 6 => '6 - Info', - 7 => '7 - Debug', + 'LOG_DEBUG' => 'Debug', + 'LOG_INFO' => 'Info', + 'LOG_NOTICE' => 'Notice', + 'LOG_WARNING' => 'Warning', + 'LOG_ERR' => 'Error', + 'LOG_CRIT' => 'Critical', + 'LOG_ALERT' => 'Alert', + 'LOG_EMERG' => 'Emergency', ], verbose_name: 'log level', - help_text: 'Sets the log level for API logging. The log level determines the severity of messages that are - logged. Setting a higher log level will include all messages of that level and lower severity.', + help_text: 'Sets the log level for API logging. The log level determines the minimum severity of messages + that should be logged.', ); $this->allow_pre_releases = new BooleanField( default: false, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml index d7fb9972..e0bfba87 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml +++ b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml @@ -20,7 +20,7 @@ enabled enabled disabled - 3 + LOG_WARNING disabled disabled From 62ca4fed1819e85328307b25fc5f00712121ebe8 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 6 Jan 2026 22:19:17 -0700 Subject: [PATCH 54/73] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc index c6035578..7d137560 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 @@ -95,7 +95,7 @@ class RESTAPISettings extends Model { only applicable when the API `login_protection` setting is enabled.", ); $this->log_level = new StringField( - default: "LOG_WARNING", + default: 'LOG_WARNING', choices: [ 'LOG_DEBUG' => 'Debug', 'LOG_INFO' => 'Info', From 0fe15966b49abe53aed174c2b3438734c94ac3e8 Mon Sep 17 00:00:00 2001 From: novastate <52055485+novastate@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:41:25 +0100 Subject: [PATCH 55/73] feat: add POST /api/v2/diagnostics/ping endpoint (#820) Adds a new diagnostic endpoint to run ping from pfSense. Features: - Required `host` parameter (IP or hostname) - Optional `count` parameter (1-10, default 3) - Optional `source_address` parameter - Returns ping output and result code Co-authored-by: novastate --- .../Endpoints/DiagnosticsPingEndpoint.inc | 23 ++++++ .../usr/local/pkg/RESTAPI/Models/Ping.inc | 79 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsPingEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Ping.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsPingEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsPingEndpoint.inc new file mode 100644 index 00000000..af520553 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsPingEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/diagnostics/ping'; + $this->model_name = 'Ping'; + $this->request_method_options = ['POST']; + $this->post_help_text = 'Run a ping command from pfSense to a specified host.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Ping.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Ping.inc new file mode 100644 index 00000000..827ef3dd --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Ping.inc @@ -0,0 +1,79 @@ +verbose_name = 'Ping'; + + # Define Model Fields + $this->host = new StringField( + required: true, + write_only: true, + help_text: 'The IP address or hostname to ping.', + ); + $this->count = new IntegerField( + default: 3, + minimum: 1, + maximum: 10, + write_only: true, + help_text: 'The number of ping requests to send.', + ); + $this->source_address = new StringField( + default: '', + allow_empty: true, + write_only: true, + help_text: 'The source IP address to use for ping requests.', + ); + $this->output = new StringField( + allow_null: true, + read_only: true, + help_text: 'The output from the ping command.', + ); + $this->result_code = new IntegerField( + allow_null: true, + read_only: true, + help_text: 'The result code from the ping command. 0 indicates success.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Execute the ping command and populate the output. + */ + public function _create(): void { + # Build the ping command + $host = escapeshellarg($this->host->value); + $count = intval($this->count->value); + $cmd_str = "ping -c {$count}"; + + # Add source address if specified + if (!empty($this->source_address->value)) { + $source = escapeshellarg($this->source_address->value); + $cmd_str .= " -S {$source}"; + } + + $cmd_str .= " {$host}"; + + # Execute the command + $cmd = new Command($cmd_str); + $this->output->value = $cmd->output; + $this->result_code->value = $cmd->result_code; + } +} From 2beb842c65fc86b2b62dc6a7cdc57bd85fcbc049 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 9 Jan 2026 19:15:39 -0700 Subject: [PATCH 56/73] fix: do not cache models with internal callables by default This commit ensures that Model's with internal_callables are not cached by default. This is primarily because these Models often fetch real-time data, not stored config data. This change also allows for these models to override this default. --- .../files/usr/local/pkg/RESTAPI/Core/Model.inc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 f468f4a4..11bce85f 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 @@ -149,7 +149,7 @@ class Model { * If set to `true`, this Model will be exempt from any global Model caching mechanisms. This is primarily * used for Models that frequently change or are not suitable for caching. */ - public bool $model_cache_exempt = false; + public bool $model_cache_exempt; /** * @var ModelSet $related_objects @@ -341,6 +341,11 @@ class Model { ); } + # Set the model cache exemption if it was not explicitly defined + if (!isset($this->model_cache_exempt)) { + $this->model_cache_exempt = (bool) $this->internal_callable; + } + # Check for known $options and map them to their Model properties $options = $this->set_construct_options($options); From 6aff549b549d1b93824506cc7b5711d4ec092ce0 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 9 Jan 2026 19:15:58 -0700 Subject: [PATCH 57/73] refactor: remove redundant log_error method from Dispatcher class --- .../files/usr/local/pkg/RESTAPI/Core/Dispatcher.inc | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Dispatcher.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Dispatcher.inc index e3afd073..5411d4a1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Dispatcher.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Dispatcher.inc @@ -275,15 +275,6 @@ class Dispatcher { sleep(30); } - /** - * Logs an error to the syslog. - * @param string $message The error message to write to the syslog - */ - public static function log_error(string $message): void { - # Call the pfSense `log_error` function - log_error($message); - } - /** * Configures this Dispatcher to run on a schedule if the `schedule` property is set. * @return CronJob|null Returns the CronJob created for this Dispatcher if a `schedule` is defined. Returns `null` From e1cfc5033bb76a07d4a72d61d545611644f02886 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 9 Jan 2026 22:48:39 -0700 Subject: [PATCH 58/73] chore: only check log level once --- .../usr/local/pkg/RESTAPI/Core/BaseTraits.inc | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc index 39264406..f9e0b9ba 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc @@ -11,6 +11,8 @@ use function RESTAPI\Core\Tools\get_classes_from_namespace; * automatically inherit all resources included. */ trait BaseTraits { + private static int $__log_level; + /** * Obtains the shortname of the called class. * @return string The shortname for this object's class. @@ -42,12 +44,20 @@ trait BaseTraits { * @param string $logfile The log file to write to. This must be a valid logging facility defined in the package's * info.xml file. */ - public function log(int $level, string $message, string $logfile = 'restapi'): void { - # Get the log level limit from the RESTAPI settings - $log_limit = constant(RESTAPISettings::get_pkg_config()['log_level']) ?? 4; + public static function log(int $level, string $message, string $logfile = 'restapi'): void { + # If this is a system log, use the pfSense 'log_error' function instead + if ($logfile === 'system') { + log_error($message); + return; + } + + # If the log level has not been set yet, obtain it + if (!isset(self::$__log_level)) { + self::$__log_level = constant(RESTAPISettings::get_pkg_config()['log_level']) ?? 4; + } # Do not log if the incoming level is higher than the configured log level - if ($level >= $log_limit) { + if ($level > self::$__log_level) { return; } From 747d61b18f00fbebfc89812ec3f40df670d79d18 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 9 Jan 2026 22:48:57 -0700 Subject: [PATCH 59/73] feat: add log_level field to RESTAPI Settings form page --- .../usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc index 418d1b72..f856760d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc @@ -33,6 +33,7 @@ class SystemRESTAPISettingsForm extends Form { 'jwt_exp', 'login_protection', 'log_successful_auth', + 'log_level', 'expose_sensitive_fields', 'override_sensitive_fields', ], From c7f0b52b063b7a2026d167071a319437cc95c094 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 9 Jan 2026 22:49:44 -0700 Subject: [PATCH 60/73] refactor: deprecate old log_error method --- .../files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc | 9 --------- .../pkg/RESTAPI/Models/CertificateAuthorityRenew.inc | 2 +- .../usr/local/pkg/RESTAPI/Models/CertificateRenew.inc | 2 +- .../local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc | 3 ++- .../usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc | 6 +++--- .../usr/local/pkg/RESTAPI/Models/RESTAPIVersion.inc | 4 ++-- .../files/usr/local/pkg/RESTAPI/Models/SSH.inc | 4 ++-- 7 files changed, 11 insertions(+), 19 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc index f9e0b9ba..a8cfd1c3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc @@ -66,13 +66,4 @@ trait BaseTraits { syslog(priority: $level, message: $message); closelog(); } - - /** - * Logs an error to the syslog. - * @param string $message The error message to write to the syslog - */ - public static function log_error(string $message): void { - # Call the pfSense `log_error` function - log_error($message); - } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc index bbd6bc99..f2505743 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc @@ -120,7 +120,7 @@ class CertificateAuthorityRenew extends Model { # Otherwise, continue with the renewal $this->newserial->value = cert_get_serial($ca_config['item']['crt']); $msg = "Renewed CA {$ca_config['item']['descr']} ({$ca_config['item']['refid']}) - Serial {$this->oldserial->value} -> {$this->newserial->value}"; - $this->log_error($msg); + $this->log(level: LOG_ERR, message: $msg, logfile: "system"); $this->write_config($msg); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc index d2d3b673..95691e66 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc @@ -120,7 +120,7 @@ class CertificateRenew extends Model { # Otherwise, continue with the renewal $this->newserial->value = cert_get_serial($cert_config['item']['crt']); $msg = "Renewed certificate {$cert_config['item']['descr']} ({$cert_config['item']['refid']}) - Serial {$this->oldserial->value} -> {$this->newserial->value}"; - $this->log_error($msg); + $this->log(level: LOG_ERR, message: $msg, logfile: "system"); $this->write_config($msg); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc index 6d54c851..0172fb6e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc @@ -137,7 +137,8 @@ class RESTAPIAccessListEntry extends Model { # If the assigned schedule no longer exists, log the event and deny access if (!$this->sched->get_related_model()) { - $this->log_error( + $this->log( + level: LOG_ERR, message: "The schedule '{$this->sched->value}' assigned to REST API access list entry " . "'$this->id' no longer exists.", ); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index e74ea3d8..3a6559c9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -122,13 +122,13 @@ class RESTAPISettingsSync extends Model { # Log an error if the HTTP failed if (!$response) { cprint(message: 'failed.' . PHP_EOL, condition: $print_status); - self::log_error("Failed to sync REST API settings to $ha_sync_host: no response received."); + self::log(level: LOG_ERR, message: "Failed to sync REST API settings to $ha_sync_host: no response received."); } elseif (!$response_json) { cprint(message: 'failed.' . PHP_EOL, condition: $print_status); - self::log_error("Failed to sync REST API settings to $ha_sync_host: received unexpected response."); + self::log(level: LOG_ERR, message: "Failed to sync REST API settings to $ha_sync_host: received unexpected response."); } elseif ($response_json['code'] !== 200) { cprint(message: 'failed.' . PHP_EOL, condition: $print_status); - self::log_error("Failed to sync REST API settings to $ha_sync_host: {$response_json['message']}"); + self::log(level: LOG_ERR, message: "Failed to sync REST API settings to $ha_sync_host: {$response_json['message']}"); } else { cprint(message: 'done.' . PHP_EOL, condition: $print_status); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIVersion.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIVersion.inc index 16f5baf6..42cbb691 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIVersion.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIVersion.inc @@ -236,8 +236,8 @@ class RESTAPIVersion extends Model { # If an error occurred either deleting or adding the package, return false if ($delete->result_code !== 0 or $add->result_code !== 0) { - $this->log_error($delete->output); - $this->log_error($add->output); + $this->log(level: LOG_ERR, message: $delete->output); + $this->log(level: LOG_ERR, message: $add->output); return false; } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc index 1c47da66..0d3c28ce 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc @@ -56,11 +56,11 @@ class SSH extends Model { public function apply() { # Stop the sshd service killbyname('sshd'); - $this->log_error('secure shell configuration has changed. Stopping sshd.'); + $this->log(level: LOG_ERR, message: 'secure shell configuration has changed. Stopping sshd.', logfile: "system"); # If sshd is still enabled, restart the sshd service using the new config if (config_path_enabled('system/ssh', 'enable')) { - $this->log_error('secure shell configuration has changed. Restarting sshd.'); + $this->log(level: LOG_ERR, message: 'secure shell configuration has changed. Restarting sshd.', logfile: "system"); send_event('service restart sshd'); } } From d10b325e814e235114877ce376297984756b27d7 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 9 Jan 2026 22:50:10 -0700 Subject: [PATCH 61/73] style: run prettier on changed files --- .../RESTAPI/Models/CertificateAuthorityRenew.inc | 2 +- .../local/pkg/RESTAPI/Models/CertificateRenew.inc | 2 +- .../pkg/RESTAPI/Models/RESTAPISettingsSync.inc | 15 ++++++++++++--- .../files/usr/local/pkg/RESTAPI/Models/SSH.inc | 12 ++++++++++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc index f2505743..c818beb9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc @@ -120,7 +120,7 @@ class CertificateAuthorityRenew extends Model { # Otherwise, continue with the renewal $this->newserial->value = cert_get_serial($ca_config['item']['crt']); $msg = "Renewed CA {$ca_config['item']['descr']} ({$ca_config['item']['refid']}) - Serial {$this->oldserial->value} -> {$this->newserial->value}"; - $this->log(level: LOG_ERR, message: $msg, logfile: "system"); + $this->log(level: LOG_ERR, message: $msg, logfile: 'system'); $this->write_config($msg); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc index 95691e66..c61f7dd7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc @@ -120,7 +120,7 @@ class CertificateRenew extends Model { # Otherwise, continue with the renewal $this->newserial->value = cert_get_serial($cert_config['item']['crt']); $msg = "Renewed certificate {$cert_config['item']['descr']} ({$cert_config['item']['refid']}) - Serial {$this->oldserial->value} -> {$this->newserial->value}"; - $this->log(level: LOG_ERR, message: $msg, logfile: "system"); + $this->log(level: LOG_ERR, message: $msg, logfile: 'system'); $this->write_config($msg); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index 3a6559c9..179f88e0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -122,13 +122,22 @@ class RESTAPISettingsSync extends Model { # Log an error if the HTTP failed if (!$response) { cprint(message: 'failed.' . PHP_EOL, condition: $print_status); - self::log(level: LOG_ERR, message: "Failed to sync REST API settings to $ha_sync_host: no response received."); + self::log( + level: LOG_ERR, + message: "Failed to sync REST API settings to $ha_sync_host: no response received.", + ); } elseif (!$response_json) { cprint(message: 'failed.' . PHP_EOL, condition: $print_status); - self::log(level: LOG_ERR, message: "Failed to sync REST API settings to $ha_sync_host: received unexpected response."); + self::log( + level: LOG_ERR, + message: "Failed to sync REST API settings to $ha_sync_host: received unexpected response.", + ); } elseif ($response_json['code'] !== 200) { cprint(message: 'failed.' . PHP_EOL, condition: $print_status); - self::log(level: LOG_ERR, message: "Failed to sync REST API settings to $ha_sync_host: {$response_json['message']}"); + self::log( + level: LOG_ERR, + message: "Failed to sync REST API settings to $ha_sync_host: {$response_json['message']}", + ); } else { cprint(message: 'done.' . PHP_EOL, condition: $print_status); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc index 0d3c28ce..34838a14 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SSH.inc @@ -56,11 +56,19 @@ class SSH extends Model { public function apply() { # Stop the sshd service killbyname('sshd'); - $this->log(level: LOG_ERR, message: 'secure shell configuration has changed. Stopping sshd.', logfile: "system"); + $this->log( + level: LOG_ERR, + message: 'secure shell configuration has changed. Stopping sshd.', + logfile: 'system', + ); # If sshd is still enabled, restart the sshd service using the new config if (config_path_enabled('system/ssh', 'enable')) { - $this->log(level: LOG_ERR, message: 'secure shell configuration has changed. Restarting sshd.', logfile: "system"); + $this->log( + level: LOG_ERR, + message: 'secure shell configuration has changed. Restarting sshd.', + logfile: 'system', + ); send_event('service restart sshd'); } } From 70029c8d72878a3e310b987cffa637a82ef5ea14 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 10 Jan 2026 10:41:35 -0700 Subject: [PATCH 62/73] feat: log security related events --- .../files/usr/local/pkg/RESTAPI/Core/Auth.inc | 4 +++ .../usr/local/pkg/RESTAPI/Core/Endpoint.inc | 26 ++++++++++++++++--- .../local/pkg/RESTAPI/Models/RESTAPIJWT.inc | 6 +++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc index 0c2c0876..0b535f5e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc @@ -294,6 +294,10 @@ class Auth { # If this Auth class is being requested by the remote client, set the matched auth and break the loop if ($auth->is_requested()) { $matched_auth = $auth; + self::log( + level: LOG_DEBUG, + message: "Client from $auth->ip_address is attempting to authenticate using $auth->verbose_name." + ); break; } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc index cf02e440..41ceff95 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc @@ -625,6 +625,7 @@ class Endpoint { private function check_acl(): void { # Allow the API call if the ignore_acl flag is set if ($this->ignore_acl) { + $this->log(level: LOG_DEBUG, message: "Ignoring REST API ACL for $this->url as ignore_acl is set."); return; } @@ -632,6 +633,11 @@ class Endpoint { if ( !RESTAPIAccessListEntry::is_allowed_by_acl(ip: $this->client->ip_address, username: $this->client->username) ) { + $this->log( + level: LOG_WARNING, + message: "Denied {$this->client->username}@{$this->client->ip_address} access to $this->url for REST ". + "API access list violation." + ); throw new ForbiddenError( message: 'The requested action is not allowed by admin policy', response_id: 'ENDPOINT_CLIENT_NOT_ALLOWED_BY_ACL', @@ -643,7 +649,13 @@ class Endpoint { * Checks if the API is enabled before allowing the call. */ private function check_enabled(): void { + $client_ip = $_SERVER['REMOTE_ADDR']; + if (!$this->restapi_settings->enabled->value and !$this->ignore_enabled) { + $this->log( + level: LOG_WARNING, + message: "Denied $client_ip access to $this->url as REST API is disabled." + ); throw new ServiceUnavailableError( message: 'The REST API is currently not enabled.', response_id: 'ENDPOINT_REST_API_IS_NOT_ENABLED', @@ -657,6 +669,7 @@ class Endpoint { private function check_interface_allowed(): void { # Variables $server_ip = $_SERVER['SERVER_ADDR']; + $client_ip = $_SERVER['REMOTE_ADDR']; $allowed_interfaces = $this->restapi_settings->allowed_interfaces->value; # Allow any interface if the allowed interfaces is empty or the ignore_interfaces flag is set @@ -672,23 +685,28 @@ class Endpoint { # Loop through each allowed interface and check if the server IP is allowed to answer API calls foreach ( $this->restapi_settings->allowed_interfaces->get_related_models()->model_objects - as $allowed_interface + as $allowed_if ) { # Allow the server IP if it matches the current interface's IPv4 or IPv6 address - if ($server_ip === $allowed_interface->get_current_ipv4()) { + if ($server_ip === $allowed_if->get_current_ipv4()) { return; } - if ($server_ip === $allowed_interface->get_current_ipv6()) { + if ($server_ip === $allowed_if->get_current_ipv6()) { return; } # Check if this interface has a virtual IP that matches the server IP that accepted the API call - $vip_q = VirtualIP::query(interface: $allowed_interface->represented_as(), subnet: $server_ip); + $vip_q = VirtualIP::query(interface: $allowed_if->represented_as(), subnet: $server_ip); if ($vip_q->exists()) { return; } } + # Throw a forbidden error if this API call was made to a non-API enabled interface + $this->log( + level: LOG_WARNING, + message:"Denied $client_ip access to $this->url as interface IP $server_ip is not allowed to respond." + ); throw new ForbiddenError( message: 'The requested action is not allowed by admin policy', response_id: 'ENDPOINT_INTERFACE_NOT_ALLOWED', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc index 92132090..ea6b2a96 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc @@ -105,5 +105,11 @@ class RESTAPIJWT extends Model { # Assign the JWT string to `token` $this->token->value = JWT::encode($payload, $server_key, 'HS256'); + + # Log the action + $this->log( + level: LOG_INFO, + message: "Client {$this->client->username}@{$this->client->ip_address} was issued a new JWT." + ); } } From 5918c742ad70dac883bf835e6c0a232852dbe3e4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 10 Jan 2026 10:41:56 -0700 Subject: [PATCH 63/73] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Core/Auth.inc | 2 +- .../usr/local/pkg/RESTAPI/Core/Endpoint.inc | 16 +++++----------- .../usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc | 2 +- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc index 0b535f5e..1afa99e3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc @@ -296,7 +296,7 @@ class Auth { $matched_auth = $auth; self::log( level: LOG_DEBUG, - message: "Client from $auth->ip_address is attempting to authenticate using $auth->verbose_name." + message: "Client from $auth->ip_address is attempting to authenticate using $auth->verbose_name.", ); break; } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc index 41ceff95..05628011 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc @@ -635,8 +635,8 @@ class Endpoint { ) { $this->log( level: LOG_WARNING, - message: "Denied {$this->client->username}@{$this->client->ip_address} access to $this->url for REST ". - "API access list violation." + message: "Denied {$this->client->username}@{$this->client->ip_address} access to $this->url for REST " . + 'API access list violation.', ); throw new ForbiddenError( message: 'The requested action is not allowed by admin policy', @@ -652,10 +652,7 @@ class Endpoint { $client_ip = $_SERVER['REMOTE_ADDR']; if (!$this->restapi_settings->enabled->value and !$this->ignore_enabled) { - $this->log( - level: LOG_WARNING, - message: "Denied $client_ip access to $this->url as REST API is disabled." - ); + $this->log(level: LOG_WARNING, message: "Denied $client_ip access to $this->url as REST API is disabled."); throw new ServiceUnavailableError( message: 'The REST API is currently not enabled.', response_id: 'ENDPOINT_REST_API_IS_NOT_ENABLED', @@ -683,10 +680,7 @@ class Endpoint { } # Loop through each allowed interface and check if the server IP is allowed to answer API calls - foreach ( - $this->restapi_settings->allowed_interfaces->get_related_models()->model_objects - as $allowed_if - ) { + foreach ($this->restapi_settings->allowed_interfaces->get_related_models()->model_objects as $allowed_if) { # Allow the server IP if it matches the current interface's IPv4 or IPv6 address if ($server_ip === $allowed_if->get_current_ipv4()) { return; @@ -705,7 +699,7 @@ class Endpoint { # Throw a forbidden error if this API call was made to a non-API enabled interface $this->log( level: LOG_WARNING, - message:"Denied $client_ip access to $this->url as interface IP $server_ip is not allowed to respond." + message: "Denied $client_ip access to $this->url as interface IP $server_ip is not allowed to respond.", ); throw new ForbiddenError( message: 'The requested action is not allowed by admin policy', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc index ea6b2a96..47a58a8b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc @@ -109,7 +109,7 @@ class RESTAPIJWT extends Model { # Log the action $this->log( level: LOG_INFO, - message: "Client {$this->client->username}@{$this->client->ip_address} was issued a new JWT." + message: "Client {$this->client->username}@{$this->client->ip_address} was issued a new JWT.", ); } } From 79340d8c37cf3bebe48c945bd03c60e554dd6125 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 16 Jan 2026 16:53:08 -0700 Subject: [PATCH 64/73] feat(UserGroup): allow updating groups with `system` scope #822 --- .../local/pkg/RESTAPI/Models/UserGroup.inc | 26 ++++++++++- .../Responses/UnprocessableContentError.inc | 2 +- .../Tests/APIModelsUserGroupTestCase.inc | 46 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc index a2e867b1..faedf3a5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc @@ -9,6 +9,7 @@ use RESTAPI\Fields\IntegerField; use RESTAPI\Fields\StringField; use RESTAPI\Responses\ForbiddenError; use RESTAPI\Responses\NotFoundError; +use RESTAPI\Responses\UnprocessableContentError; use RESTAPI\Responses\ValidationError; use RESTAPI\Validators\RegexValidator; @@ -48,9 +49,10 @@ class UserGroup extends Model { ); $this->scope = new StringField( default: 'local', - choices: ['local', 'remote'], + choices: ['local', 'remote', 'system'], help_text: 'The scope of this user group. Use `local` for user groups that only apply to this system. use ' . - '`remote` for groups that also apply to remote authentication servers.', + '`remote` for groups that also apply to remote authentication servers. Please note the `system` scope ' . + 'is reserved for built-in, system-defined user groups and cannot be assigned manually.', ); $this->member = new ForeignModelField( model_name: 'User', @@ -95,6 +97,26 @@ class UserGroup extends Model { return $name; } + /** + * Adds additional validation to the `scope` field. + * @param string $scope The incoming value to be validated. + * @return string The validated value to be assigned. + * @throws ValidationError When `scope` is set to `system` and this is a newly created object or the existing + * `scope` is not already `system`. + */ + public function validate_scope(string $scope): string { + $existing_scope = $this->initial_object->scope->value ?? null; + + if ($scope === 'system' and $scope !== $existing_scope) { + throw new UnprocessableContentError( + message: 'Field `scope` cannot be set to `system` as it is reserved for system-defined user groups only.', + response_id: 'USER_GROUP_SCOPE_CANNOT_BE_SET_TO_SYSTEM', + ); + } + + return $scope; + } + /** * Adds additional validation to the `priv` field. * @param string $priv The incoming value to be validated diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/UnprocessableContentError.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/UnprocessableContentError.inc index 8a547612..9f6b8659 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/UnprocessableContentError.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/UnprocessableContentError.inc @@ -15,7 +15,7 @@ use RESTAPI\Core\Response; */ class UnprocessableContentError extends Response { public $code = 422; - public string $help_text = 'The client has requested a resource that requires a dependency which is not installed.'; + public string $help_text = 'The client has requested a resource that requires a dependency or other requirement that was not met.'; public function __construct( $message, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc index c737f584..d06fac7e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc @@ -123,4 +123,50 @@ class APIModelsUserGroupTestCase extends TestCase { }, ); } + + /** + * Ensures that UserGroups cannot be created or updated with the 'system' scope unless the existing object also has + * the 'system' scope. + */ + public function test_cannot_create_or_update_user_groups_with_system_scope(): void { + # Create a UserGroup to test with + $user_group = new UserGroup(); + + # Ensure attempts to create UserGroups with the system scope are rejected + $this->assert_throws_response( + response_id: 'USER_GROUP_SCOPE_CANNOT_BE_SET_TO_SYSTEM', + code: 422, + callable: function () use ($user_group) { + $user_group->scope->value = 'system'; + $user_group->create(); + }, + ); + + # Ensure attempts to update existing UserGroups to have the system scope are rejected + $this->assert_throws_response( + response_id: 'USER_GROUP_SCOPE_CANNOT_BE_SET_TO_SYSTEM', + code: 422, + callable: function () use ($user_group) { + # Create a local UserGroup to test with + $user_group = new UserGroup(data: ['name' => 'testgroup', 'scope' => 'local']); + $user_group->create(); + + # Attempt to update the scope to `system` + $user_group->scope->value = 'system'; + $user_group->update(); + }, + ); + + # Delete the test UserGroup + $user_group->delete(); + + # Ensure we CAN update an existing UserGroup with the system scope to still have the system scope + $this->assert_does_not_throw( + callable: function () { + # Create a UserGroup with the system scope to test with + $admin_group = UserGroup::query(name: 'Admins')->first(); + $admin_group->validate_scope('system'); + }, + ); + } } From a7751d6a14ad1fa19db22166ce50dd2d4dc7b397 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 16 Jan 2026 17:01:31 -0700 Subject: [PATCH 65/73] feat: add model, endpoint and tests for REST API logs --- .../StatusLogsPackagesRESTAPIEndpoint.inc | 23 +++++++++++ .../local/pkg/RESTAPI/Models/RESTAPILog.inc | 39 +++++++++++++++++++ .../Tests/APIModelsRESTAPILogTestCase.inc | 18 +++++++++ 3 files changed, 80 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsPackagesRESTAPIEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPILog.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPILogTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsPackagesRESTAPIEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsPackagesRESTAPIEndpoint.inc new file mode 100644 index 00000000..8a08bc45 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsPackagesRESTAPIEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/status/logs/packages/restapi'; + $this->model_name = 'RESTAPILog'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPILog.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPILog.inc new file mode 100644 index 00000000..bbd3844e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPILog.inc @@ -0,0 +1,39 @@ +internal_callable = 'get_restapi_log'; + $this->many = true; + + $this->text = new StringField(default: '', help_text: 'The raw text of the REST API log entry.'); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Obtains the REST API log as an array. This method is the internal callable for this Model. + * @return array The REST API log as an array of objects. + */ + protected function get_restapi_log(): array { + return $this->read_log($this->log_file); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPILogTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPILogTestCase.inc new file mode 100644 index 00000000..199ab310 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPILogTestCase.inc @@ -0,0 +1,18 @@ +model_objects as $dhcp_log) { + $this->assert_is_not_empty($dhcp_log->text->value); + } + } +} From 2aae2bd203acf32995f5739ee44fbfe4233fcfab Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 16 Jan 2026 18:09:01 -0700 Subject: [PATCH 66/73] test: adjust UserGroup model creation during tests --- .../pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc index d06fac7e..368c20a8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc @@ -129,15 +129,12 @@ class APIModelsUserGroupTestCase extends TestCase { * the 'system' scope. */ public function test_cannot_create_or_update_user_groups_with_system_scope(): void { - # Create a UserGroup to test with - $user_group = new UserGroup(); - # Ensure attempts to create UserGroups with the system scope are rejected $this->assert_throws_response( response_id: 'USER_GROUP_SCOPE_CANNOT_BE_SET_TO_SYSTEM', code: 422, - callable: function () use ($user_group) { - $user_group->scope->value = 'system'; + callable: function () { + $user_group = new UserGroup(name: 'testgroup', scope: 'system'); $user_group->create(); }, ); @@ -146,7 +143,7 @@ class APIModelsUserGroupTestCase extends TestCase { $this->assert_throws_response( response_id: 'USER_GROUP_SCOPE_CANNOT_BE_SET_TO_SYSTEM', code: 422, - callable: function () use ($user_group) { + callable: function () { # Create a local UserGroup to test with $user_group = new UserGroup(data: ['name' => 'testgroup', 'scope' => 'local']); $user_group->create(); @@ -157,9 +154,6 @@ class APIModelsUserGroupTestCase extends TestCase { }, ); - # Delete the test UserGroup - $user_group->delete(); - # Ensure we CAN update an existing UserGroup with the system scope to still have the system scope $this->assert_does_not_throw( callable: function () { From d061af4bde0520895f8967c9460809f1f033668e Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 16 Jan 2026 20:57:18 -0700 Subject: [PATCH 67/73] test: fix case sensitivity in UserGroup query --- .../usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc index 368c20a8..18bc2e43 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc @@ -158,7 +158,7 @@ class APIModelsUserGroupTestCase extends TestCase { $this->assert_does_not_throw( callable: function () { # Create a UserGroup with the system scope to test with - $admin_group = UserGroup::query(name: 'Admins')->first(); + $admin_group = UserGroup::query(name: 'admins')->first(); $admin_group->validate_scope('system'); }, ); From 6ed5ecfe9b771b0d1e5e733b0b0854fe3ec3e582 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 16 Jan 2026 23:32:40 -0700 Subject: [PATCH 68/73] test: load system exact UserGroup by id --- .../usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc index 18bc2e43..756ddd66 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc @@ -158,7 +158,7 @@ class APIModelsUserGroupTestCase extends TestCase { $this->assert_does_not_throw( callable: function () { # Create a UserGroup with the system scope to test with - $admin_group = UserGroup::query(name: 'admins')->first(); + $admin_group = new UserGroup(id: 0); $admin_group->validate_scope('system'); }, ); From 869934eb4648d83f657ff1b12554456de7c7e6fb Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 17 Jan 2026 11:37:35 -0700 Subject: [PATCH 69/73] fix(ForeignModelField): use index to find matches not queries --- .../pkg/RESTAPI/Fields/ForeignModelField.inc | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) 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 2274222a..a740b189 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 @@ -359,7 +359,7 @@ class ForeignModelField extends Field { # Filter out Models that do not meet the assigned $model_query criteria $models = $models->query($this->model_query); - $in_scope_modelsets[] = $models; + $in_scope_modelsets[$model->get_class_fqn()] = $models; } # Query for the Model object this value relates to. @@ -374,16 +374,25 @@ class ForeignModelField extends Field { * to the same value as $this->value. */ private function __get_matches(string $field_name, mixed $field_value): ModelSet { + self::_index_foreign_models(); + # Loop through all in cope modelsets to find matches - foreach ($this->get_in_scope_models() as $modelset) { - # Query for Model objects that match this field's criteria - $query_params = [$field_name => $field_value]; - $query_modelset = $modelset->query($query_params); + foreach ($this->get_in_scope_models() as $model_class => $in_scope_modelset) { + # Move to the next model class if we found no matches in the index + if (!ModelCache::get_instance()::has_model($model_class, $field_name, $field_value)) { + continue; + } - # Return the matching ModelSet if it exists - if ($query_modelset->exists()) { - return $query_modelset; + # Obtain the matched model + $matched_model = ModelCache::get_instance()::fetch_model($model_class, $field_name, $field_value); + + # Move to the next model class if the matched model we found was not in scope + if ($this->model_query and !$in_scope_modelset->query(id: $matched_model->id)->exists()) { + continue; } + + # Otherwise, the matched model is valid. Return it within a ModelSet + return new ModelSet(model_objects: [$matched_model]); } # Return an empty ModelSet if no matches were found From 4021b0f1d3c91bc9b270aa0302139b2757861cbc Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 17 Jan 2026 11:38:10 -0700 Subject: [PATCH 70/73] chore(UserGroup): bump 'member' items maximum to 2048 --- .../files/usr/local/pkg/RESTAPI/Models/UserGroup.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc index faedf3a5..ccc9aa06 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc @@ -61,6 +61,7 @@ class UserGroup extends Model { default: [], allow_empty: true, many: true, + many_maximum: 2048, delimiter: null, help_text: 'The local user names to assign to this user group.', ); From 7e06fd1622dc95ed1145c9e6736e3c457be0a08d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 17 Jan 2026 11:38:35 -0700 Subject: [PATCH 71/73] test(UserGroup): ensure we can update and read large user groups within 10 seconds --- .../Tests/APIModelsUserGroupTestCase.inc | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc index 756ddd66..136965c9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc @@ -163,4 +163,48 @@ class APIModelsUserGroupTestCase extends TestCase { }, ); } + + /** + * Ensures UserGroups remain readable even when many users are assigned to a group. This tests for performance + * issues identified in GitHub issue #779. + */ + public function test_read_performance_with_many_members(): void + { + # Mock creating many users by direct config assignment + foreach (range(1, 1000) as $idx) { + global $config; + $config['system']['user'][] = [ + 'name' => "testuser$idx", + 'uid' => 3000 + $idx, + ]; + } + + # Create a UserGroup with many members + $user_group = new UserGroup( + name: 'biggroup', + scope: 'local', + member: [], + ); + $user_group->create(); + + # Perform an update that maps all created users to our group and track its time + $start_time = time(); + $user_group->member->value = array_map(fn($i) => "testuser$i", range(1, 1000)); + $user_group->update(); + $end_time = time(); + $duration = $end_time - $start_time; + + # Ensure the update took less than 10 seconds + $this->assert_is_less_than($duration, 5); + + # Ensure we can read all user groups within 10 seconds + $start_time = time(); + UserGroup::read_all(); + $end_time = time(); + $duration = $end_time - $start_time; + $this->assert_is_less_than($duration, 5); + + # Clean up the user group + $user_group->delete(); + } } From fa7b489c11798cabe5d09ef608614c86d7d79712 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 17 Jan 2026 11:38:55 -0700 Subject: [PATCH 72/73] style: run prettier on changed files --- .../pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc index 136965c9..45a7363c 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsUserGroupTestCase.inc @@ -168,8 +168,7 @@ class APIModelsUserGroupTestCase extends TestCase { * Ensures UserGroups remain readable even when many users are assigned to a group. This tests for performance * issues identified in GitHub issue #779. */ - public function test_read_performance_with_many_members(): void - { + public function test_read_performance_with_many_members(): void { # Mock creating many users by direct config assignment foreach (range(1, 1000) as $idx) { global $config; @@ -180,11 +179,7 @@ class APIModelsUserGroupTestCase extends TestCase { } # Create a UserGroup with many members - $user_group = new UserGroup( - name: 'biggroup', - scope: 'local', - member: [], - ); + $user_group = new UserGroup(name: 'biggroup', scope: 'local', member: []); $user_group->create(); # Perform an update that maps all created users to our group and track its time From 4194563747bf8bb5d91028289e3664471a13177b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 17 Jan 2026 13:41:50 -0700 Subject: [PATCH 73/73] fix: re-add unique constraint to RoutingGatewayStatus model --- .../files/usr/local/pkg/RESTAPI/Models/RoutingGatewayStatus.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayStatus.inc index abc7718e..ba77bb1d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayStatus.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayStatus.inc @@ -29,6 +29,7 @@ class RoutingGatewayStatus extends Model { $this->name = new ForeignModelField( model_name: 'RoutingGateway', model_field: 'name', + unique: true, allow_null: true, read_only: true, help_text: 'The name of the gateway this status corresponds to.',