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