diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md
index 811f137e7..6fee067ba 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 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.
diff --git a/docs/QUERIES_FILTERS_AND_SORTING.md b/docs/QUERIES_FILTERS_AND_SORTING.md
index a98a63dc2..5e85533dd 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/docs/SECURING_API_ACCESS.md b/docs/SECURING_API_ACCESS.md
new file mode 100644
index 000000000..c203c05ab
--- /dev/null
+++ b/docs/SECURING_API_ACCESS.md
@@ -0,0 +1,141 @@
+# 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.
+
+!!! 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
+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 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
+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 fca1f06b3..2df15e961 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/pkg-install.in b/pfSense-pkg-RESTAPI/files/pkg-install.in
index 3572bedf4..d7b4f390f 100644
--- a/pfSense-pkg-RESTAPI/files/pkg-install.in
+++ b/pfSense-pkg-RESTAPI/files/pkg-install.in
@@ -1,8 +1,15 @@
#!/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/.resources/scripts/manage.php b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php
index 33524acd9..4b0c7162e 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/Core/Auth.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc
index 0c2c0876c..1afa99e30 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/BaseTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/BaseTraits.inc
index ce99c8d23..a8cfd1c3a 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;
/**
@@ -10,20 +11,22 @@ 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.
*/
- 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();
}
/**
@@ -35,11 +38,32 @@ trait BaseTraits {
}
/**
- * Logs an error to the syslog.
- * @param string $message The error message to write to the syslog
+ * 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 static function log_error(string $message): void {
- # Call the pfSense `log_error` function
- log_error($message);
+ 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 > self::$__log_level) {
+ return;
+ }
+
+ # Otherwise, write to the applicable log file
+ openlog($logfile, flags: LOG_PID, facility: LOG_LOCAL0);
+ syslog(priority: $level, message: $message);
+ closelog();
}
}
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 e3afd0730..5411d4a11 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`
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 16aa82cf9..056280111 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',
- );
- }
}
/**
@@ -633,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;
}
@@ -640,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',
@@ -651,7 +649,10 @@ 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',
@@ -665,6 +666,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
@@ -678,25 +680,27 @@ 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
- ) {
+ 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_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/Core/Field.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Field.inc
index 7fe5a5de3..2e0520535 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 4c42b30c7..11bce85f2 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';
@@ -142,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;
+
/**
* @var ModelSet $related_objects
* A ModelSet containing foreign Model objects related to this Model. These are primarily populated by
@@ -332,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);
@@ -400,7 +414,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
@@ -520,6 +534,22 @@ 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_model_cache(): void {
+ # Clear the object cache
+ self::get_model_cache()::clear();
+ }
+
/**
* 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.
@@ -637,6 +667,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_model_cache();
return config_set_path(self::normalize_config_path($path), $value, $default);
}
@@ -681,6 +713,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_model_cache();
return config_del_path(self::normalize_config_path($path));
}
@@ -717,6 +751,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_model_cache();
+
# If a subsystem is specified for this Model, mark it as dirty
if ($this->subsystem) {
mark_subsystem_dirty($this->subsystem);
@@ -744,6 +781,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_model_cache();
+
$config = parse_config(parse: $force_parse);
}
@@ -836,15 +877,52 @@ 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 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 {
+ /**@var Model $parent_model_class The parent Model class assigned to this Model. */
+ $parent_model_class = "\\RESTAPI\\Models\\$this->parent_model_class";
+ $parent_modelset = $parent_model_class::read_all();
+ $internal_objects = [];
+
+ # 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", []);
+
+ 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,
+ ];
+ }
+ }
+
+ 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
* 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.
@@ -858,6 +936,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 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
elseif ($this->config_path) {
$internal_objects = $this->get_config($this->get_config_path(), []);
@@ -885,7 +967,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(
@@ -915,7 +997,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);
}
@@ -1291,7 +1372,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 = [];
@@ -1387,7 +1468,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.
@@ -1741,10 +1822,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
@@ -1859,8 +1940,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
@@ -1870,26 +1949,13 @@ 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(
- mixed $parent_id = null,
- 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(parent_id: $parent_id);
+ $model_name = self::get_class_fqn();
+ $model = new $model_name();
$model_objects = [];
- $is_parent_model_many = $model->is_parent_model_many();
$requests_pagination = ($limit or $offset);
-
- # 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 or $model->model_cache_exempt);
# Throw an error if pagination was requested on a Model without $many enabled
if (!$model->many and $requests_pagination) {
@@ -1900,8 +1966,13 @@ class Model {
);
}
- # Obtain all of this Model's internally stored objects
- $internal_objects = $model->get_internal_objects();
+ # Load from cache if it is not exempt and cached objects exist
+ 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, 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];
@@ -1912,22 +1983,36 @@ 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
+ # 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;
# 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 = $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);
+
$model_objects[] = $model_object;
}
+ # Load the ModelSet with all obtained 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($modelset);
+ }
+
# 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();
}
/**
@@ -1936,8 +2021,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
@@ -1951,7 +2034,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,
@@ -1965,11 +2047,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) {
@@ -2089,6 +2171,9 @@ class Model {
$this->initial_object = $this->copy();
}
+ # Reset the object cache as the config has changed
+ self::clear_model_cache();
+
# Return the current representation of this object
return $this;
}
@@ -2179,6 +2264,9 @@ class Model {
$this->initial_object = $this->copy();
}
+ # Reset the object cache as the config has changed
+ self::clear_model_cache();
+
# Return the current representation of this object
return $this;
}
@@ -2229,7 +2317,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();
@@ -2272,6 +2360,9 @@ class Model {
clear_subsystem_dirty($this->subsystem);
}
+ # Reset the object cache as the config has changed
+ self::clear_model_cache();
+
return $new_objects;
}
@@ -2357,6 +2448,9 @@ class Model {
$this->initial_object = $this->copy();
}
+ # Reset the object cache as the config has changed
+ self::clear_model_cache();
+
# Return the current representation of this object
return $this;
}
@@ -2379,7 +2473,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,
@@ -2396,13 +2489,10 @@ 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,
- parent_id: $parent_id,
- 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
+ self::clear_model_cache();
# Delete the Model objects that matched the query
return $model_objects->delete();
@@ -2415,6 +2505,9 @@ class Model {
# Obtain all Model objects for this Model
$model_objects = self::read_all();
+ # Reset the object cache as the config has changed
+ 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
new file mode 100644
index 000000000..0ede60d44
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelCache.inc
@@ -0,0 +1,224 @@
+ [
+ * [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
+ * 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 = [];
+
+ /**
+ * 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 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 $signature): bool {
+ return isset(self::$cache[$signature]);
+ }
+
+ /**
+ * Caches a ModelSet in the ModelCache by its signature.
+ * @param ModelSet $model_set The ModelSet object to cache.
+ */
+ 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 $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 signature.
+ */
+ public static function fetch_modelset(string $signature): ModelSet {
+ if (!self::has_modelset($signature)) {
+ throw new NotFoundError(
+ message: "No cached ModelSet found with signature '$signature'.",
+ response_id: 'MODEL_CACHE_MODELSET_NOT_FOUND',
+ );
+ }
+ 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',
+ );
+ }
+
+ # 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(
+ 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 5a7e572c8..54cf4b456 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.
*/
@@ -22,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))) {
@@ -172,6 +183,37 @@ 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
@@ -185,9 +227,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) {
@@ -206,8 +253,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);
@@ -221,7 +269,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/Core/TestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc
index 2f183d1f9..e87a2ac76 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_model_cache();
+ write_config("Restored config after API test '{$this->method}'");
+ }
+
/**
* Installs the required packages for this TestCase.
*/
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 000000000..af5205533
--- /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/Endpoints/FirewallScheduleTimeRangesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/FirewallScheduleTimeRangesEndpoint.inc
new file mode 100644
index 000000000..7b754054b
--- /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();
+ }
+}
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 000000000..333c57329
--- /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 000000000..9f99b142e
--- /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 000000000..8dec459e6
--- /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 000000000..35436b772
--- /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 000000000..8bfd50042
--- /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 000000000..9edffec1b
--- /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 000000000..8c306f421
--- /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/ServicesDHCPServerEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerEndpoint.inc
index a933a435b..cfd210023 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/Endpoints/ServicesDHCPServerStaticMappingsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesDHCPServerStaticMappingsEndpoint.inc
new file mode 100644
index 000000000..7f8a74860
--- /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 000000000..3cab57412
--- /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 000000000..91fe4ade4
--- /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
new file mode 100644
index 000000000..1b1ff5545
--- /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/Endpoints/ServicesHAProxyBackendACLsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesHAProxyBackendACLsEndpoint.inc
new file mode 100644
index 000000000..6fa635470
--- /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 000000000..6f52a42ec
--- /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 000000000..6ee41ce90
--- /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 000000000..cc1ccc1cf
--- /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 000000000..eeda4b3bc
--- /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 000000000..0db6f81cf
--- /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 000000000..fa5a8a335
--- /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 000000000..23e641a29
--- /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 000000000..b2802df31
--- /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 000000000..c5aa07673
--- /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 000000000..87de72f56
--- /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 000000000..8678d0695
--- /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/StatusLogsPackagesRESTAPIEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsPackagesRESTAPIEndpoint.inc
new file mode 100644
index 000000000..8a08bc45e
--- /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/Endpoints/StatusOpenVPNServerConnectionsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusOpenVPNServerConnectionsEndpoint.inc
new file mode 100644
index 000000000..d75190960
--- /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 000000000..fd7ff3e0e
--- /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 000000000..a0d2ccf4d
--- /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 000000000..3aba4903d
--- /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 000000000..b78ff70c6
--- /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 000000000..c81af1561
--- /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();
+ }
+}
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 8ffa77be8..25665d8e5 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/ForeignModelField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ForeignModelField.inc
index 6020a7521..a740b1896 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,75 @@ 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,
+ );
+ }
}
+ }
- # 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;
+ /**
+ * 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();
+
+ # 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 +336,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[$model->get_class_fqn()] = $models;
}
# Query for the Model object this value relates to.
- return $in_scope_models->query($this->model_query);
+ return $in_scope_modelsets;
}
/**
@@ -331,7 +374,29 @@ 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]);
+ self::_index_foreign_models();
+
+ # Loop through all in cope modelsets to find matches
+ 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;
+ }
+
+ # 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
+ return new ModelSet();
}
/**
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 be02ee039..d77c79a1f 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 f4467860b..47aac9c52 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/Forms/SystemRESTAPISettingsForm.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc
index 418d1b721..f856760d2 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',
],
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 bbd6bc995..c818beb92 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 d2d3b673e..c61f7dd71 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/DHCPServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
index 073d03eb2..0ab7ab2c0 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.
@@ -461,7 +437,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 +460,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 +468,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 b4fd9249b..b630bde98 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 224eed996..141230642 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/Models/FirewallAdvancedSettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAdvancedSettings.inc
index cae2e7966..e771dfdee 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/FirewallRule.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallRule.inc
index 3b63f24dd..45f6f3a91 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,
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 d5c2355e3..e028e6b8d 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);
}
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 1c08f1466..c7f2e8c9b 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/Models/Package.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Package.inc
index b0d097bbb..6a69db421 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_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;
$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);
}
}
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 000000000..827ef3dd2
--- /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;
+ }
+}
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 eb87f2f2b..504b2c1e9 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,
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 35b4f39d5..0172fb6ee 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',
@@ -136,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/RESTAPIJWT.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIJWT.inc
index 71d7f2904..47a58a8b7 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(
@@ -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.",
+ );
}
}
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 000000000..bbd3844ee
--- /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/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc
index 73a40c282..7d137560f 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 StringField $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 StringField(
+ default: 'LOG_WARNING',
+ choices: [
+ '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 minimum severity of messages
+ that should be logged.',
+ );
$this->allow_pre_releases = new BooleanField(
default: false,
indicates_true: 'enabled',
@@ -294,7 +311,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/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc
index e74ea3d8a..179f88e0c 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_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 16f5baf6b..42cbb6915 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/RoutingGatewayStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayStatus.inc
index abc7718ea..ba77bb1d1 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.',
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 1c47da666..34838a148 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_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');
}
}
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 a7b38aa51..99722732a 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,7 @@ 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,
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 a2e867b1b..ccc9aa06a 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',
@@ -59,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.',
);
@@ -95,6 +98,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/QueryFilters/InQueryFilter.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/QueryFilters/InQueryFilter.inc
new file mode 100644
index 000000000..7f254fcb0
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/QueryFilters/InQueryFilter.inc
@@ -0,0 +1,31 @@
+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 {
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 000000000..63bbfc078
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelCacheTestCase.inc
@@ -0,0 +1,206 @@
+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(signature: 'MockModelClass');
+ $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(signature: 'MockModelClass');
+
+ # Cache the mock ModelSet in the ModelCache
+ $model_cache::cache_modelset($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 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 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 f05ceeb92..07a5dd60b 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,89 @@ 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()));
+ }
+
+ /**
+ * 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();
+ }
}
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 676e21a3f..7832b615f 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 25becfab6..a8176dc94 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
@@ -87,7 +86,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,
]);
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 fb7196cd6..3143d5d78 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);
@@ -535,4 +535,29 @@ 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(
+ 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
+ $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();
+ }
}
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 000000000..199ab3101
--- /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);
+ }
+ }
+}
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 3842b82b1..2fa41f28b 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();
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 c737f584e..45a7363c5 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,83 @@ 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 {
+ # 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 () {
+ $user_group = new UserGroup(name: 'testgroup', scope: '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 () {
+ # 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();
+ },
+ );
+
+ # 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 = new UserGroup(id: 0);
+ $admin_group->validate_scope('system');
+ },
+ );
+ }
+
+ /**
+ * 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();
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIQueryFiltersInQueryFilterTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIQueryFiltersInQueryFilterTestCase.inc
new file mode 100644
index 000000000..10603da87
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIQueryFiltersInQueryFilterTestCase.inc
@@ -0,0 +1,24 @@
+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']));
+ }
+}
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 a60a4faad..149d6afe3 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;
}
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 05210b0c6..84d09d6f1 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
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 8a3cd33fb..e0bfba872 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,12 +9,18 @@
%%PKGVERSION%%
restapi.xml
github@jaredhendrickson.com
+
+ REST API
+ restapi
+ restapi.log
+
enabled
disabled
enabled
enabled
disabled
+ LOG_WARNING
disabled
disabled