diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3db366a79..ea95f957f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,8 @@ jobs: PFSENSE_VERSION: "2.7.2" - FREEBSD_VERSION: FreeBSD-15.0-CURRENT PFSENSE_VERSION: "24.03" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "24.11" steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 1b251a6a0..bb759714c 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ Install on pfSense Plus: pkg-static -C /dev/null add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-24.03-pkg-RESTAPI.pkg ``` -## Support for pfSense Plus 24.11 +> [!WARNING] +> Before installing the package, always ensure your pfSense version is supported! Supported versions are listed [here](https://pfrest.org/INSTALL_AND_CONFIG/#supported-pfsense-versions). +> Installation of the package on unsupported versions of pfSense may result in unexpected behavior and/or system instability. -Working on supporting in https://github.com/jaredhendrickson13/pfsense-api/discussions/610 - -> [!IMPORTANT] +> [!TIP] > You may need to customize the installation command to reference the package built for your pfSense version. Check > the [releases page](https://github.com/jaredhendrickson13/pfsense-api/releases) to find the package built for > your version of pfSense. diff --git a/composer.lock b/composer.lock index 3125900bd..87ac4f886 100644 --- a/composer.lock +++ b/composer.lock @@ -1,156 +1,146 @@ { - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "df390555a5bc256768abe12103f30b54", - "packages": [ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "df390555a5bc256768abe12103f30b54", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "a49db6f0a5033aef5143295342f1c95521b075ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/a49db6f0a5033aef5143295342f1c95521b075ff", + "reference": "a49db6f0a5033aef5143295342f1c95521b075ff", + "shasum": "" + }, + "require": { + "php": "^7.4||^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^1.0||^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["BSD-3-Clause"], + "authors": [ { - "name": "firebase/php-jwt", - "version": "v6.10.0", - "source": { - "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "a49db6f0a5033aef5143295342f1c95521b075ff" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/a49db6f0a5033aef5143295342f1c95521b075ff", - "reference": "a49db6f0a5033aef5143295342f1c95521b075ff", - "shasum": "" - }, - "require": { - "php": "^7.4||^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^6.5||^7.4", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^1.0||^2.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firebase\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", - "keywords": [ - "jwt", - "php" - ], - "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.10.0" - }, - "time": "2023-12-01T16:26:39+00:00" + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" }, { - "name": "webonyx/graphql-php", - "version": "v15.18.1", - "source": { - "type": "git", - "url": "https://github.com/webonyx/graphql-php.git", - "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/a167afab66d8aa74b7f552759c0bbd906afb4134", - "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^7.4 || ^8" - }, - "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", - "dms/phpunit-arraysubset-asserts": "dev-master", - "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.64.0", - "mll-lab/php-cs-fixer-config": "^5.9.2", - "nyholm/psr7": "^1.5", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "1.12.10", - "phpstan/phpstan-phpunit": "1.4.1", - "phpstan/phpstan-strict-rules": "1.6.1", - "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", - "psr/http-message": "^1 || ^2", - "react/http": "^1.6", - "react/promise": "^2.0 || ^3.0", - "rector/rector": "^1.0", - "symfony/polyfill-php81": "^1.23", - "symfony/var-exporter": "^5 || ^6 || ^7", - "thecodingmachine/safe": "^1.3 || ^2" - }, - "suggest": { - "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", - "psr/http-message": "To use standard GraphQL server", - "react/promise": "To leverage async resolving on React PHP platform" - }, - "type": "library", - "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP port of GraphQL reference implementation", - "homepage": "https://github.com/webonyx/graphql-php", - "keywords": [ - "api", - "graphql" - ], - "support": { - "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.18.1" - }, - "funding": [ - { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" - } - ], - "time": "2024-11-13T16:21:54+00:00" + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": ["jwt", "php"], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.10.0" + }, + "time": "2023-12-01T16:26:39+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.18.1", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/a167afab66d8aa74b7f552759c0bbd906afb4134", + "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.64.0", + "mll-lab/php-cs-fixer-config": "^5.9.2", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "1.12.10", + "phpstan/phpstan-phpunit": "1.4.1", + "phpstan/phpstan-strict-rules": "1.6.1", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^1.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7", + "thecodingmachine/safe": "^1.3 || ^2" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["MIT"], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": ["api", "graphql"], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.18.1" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.6.0" + ], + "time": "2024-11-13T16:21:54+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" } diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md index 6e426f1fc..699518b68 100644 --- a/docs/INSTALL_AND_CONFIG.md +++ b/docs/INSTALL_AND_CONFIG.md @@ -16,7 +16,11 @@ run pfSense. It's recommended to follow Netgate's [minimum hardware requirements - pfSense CE 2.7.2 - pfSense Plus 24.03 +- pfSense Plus 24.11 +!!! Warning + Installation of the package on unsupported versions of pfSense may result in unexpected behavior and/or system instability. + !!! Tip Don't see your version of pfSense? Older versions of pfSense may be supported by older versions of this package. Check the [releases page](https://github.com/jaredhendrickson13/pfsense-api/releases). 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 900e356c4..94f99bbf3 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 @@ -162,7 +162,14 @@ class Auth { public function authorize(): bool { # Variables $is_not_authorized = false; - $this->client_privileges = get_user_privileges(getUserEntry($this->username)); + + # Start with pfSense 24.11, getUserEntry returns an array with the key 'item' containing the user data. + # We need to handle both cases to support both. + $user_ent = getUserEntry($this->username); + $user_ent = array_key_exists('item', $user_ent) ? $user_ent['item'] : $user_ent; + + # Obtain the client's privileges and check if they have the required privileges + $this->client_privileges = get_user_privileges($user_ent); # This client is not authorized if the client does not have at least one of the required privileges if ($this->required_privileges and !array_intersect($this->required_privileges, $this->client_privileges)) { 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 f36c6c635..1e0604487 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 @@ -342,6 +342,37 @@ class Model { $this->related_objects = new ModelSet(); } + /** + * Magic method to serialize only the necessary properties of this Model object. + * @return int[]|string[] + */ + public function __sleep() { + # Variables + $excluded_properties = ['initial_object', 'client', 'parent_model', 'related_objects']; + $properties = array_keys(get_object_vars($this)); + + # Filter out excluded properties from the list of properties to serialize + return array_filter($properties, function ($prop) use ($excluded_properties) { + return !in_array($prop, $excluded_properties, true); + }); + } + + /** + * Creates an unlinked copy of this Model object. + * @return Model The cloned Model object. + */ + public function copy(): Model { + # Create a copy of this object by serializing and unserializing it + $copy = unserialize(serialize($this)); + + # Reassign the parent Model object if it exists + if ($this->parent_model) { + $copy->parent_model = $this->parent_model; + } + + return $copy; + } + /** * Checks for options passed in during object construction and maps known options to Model properties. Any * options that are not known options to the core Model will be returned so they can be merged into @@ -394,7 +425,7 @@ class Model { if ($this->config_path or $this->internal_callable) { $this->id = $id; $this->from_internal(); - $this->initial_object = unserialize(serialize($this)); + $this->initial_object = $this->copy(); } } } @@ -550,8 +581,7 @@ class Model { */ protected static function init_config(string $path) { # Initialize the configuration array of a specified path. - global $config; - array_init_path($config, $path); + config_init_path($path); } /** @@ -562,8 +592,7 @@ class Model { * path keys an empty string and $default is non-null */ public static function get_config(string $path, mixed $default = null) { - global $config; - return array_get_path($config, $path, $default); + return config_get_path($path, $default); } /** @@ -574,8 +603,7 @@ 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) { - global $config; - return array_set_path($config, $path, $value, $default); + return config_set_path($path, $value, $default); } /** @@ -616,8 +644,7 @@ class Model { * @returns array copy of the removed value or null */ public static function del_config(string $path): mixed { - global $config; - return array_del_path($config, $path); + return config_del_path($path); } /** @@ -630,8 +657,7 @@ class Model { * non-null value, otherwise false. */ public static function is_config_enabled(string $path, string $enable_key = 'enable'): bool { - global $config; - return array_path_enabled($config, $path, $enable_key); + return config_path_enabled($path, $enable_key); } /** @@ -979,7 +1005,7 @@ class Model { # Obtain the object from its internal form $this->from_internal(); - $this->initial_object = unserialize(serialize($this)); + $this->initial_object = $this->copy(); } # Loop through each field in this Model and assign their values using the `representation_data`. @@ -2018,7 +2044,7 @@ class Model { } # Refresh the initial object - $this->initial_object = unserialize(serialize($this)); + $this->initial_object = $this->copy(); } # Return the current representation of this object @@ -2108,7 +2134,7 @@ class Model { } # Refresh the initial object - $this->initial_object = unserialize(serialize($this)); + $this->initial_object = $this->copy(); } # Return the current representation of this object @@ -2286,7 +2312,7 @@ class Model { } # Refresh the initial object - $this->initial_object = unserialize(serialize($this)); + $this->initial_object = $this->copy(); } # Return the current representation of this object diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc index dfede7047..ee03055b2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc @@ -168,3 +168,28 @@ function get_classes_from_namespace(string $namespace, bool $shortnames = false) return $classes; } + +/** + * Generates a random MAC address string. + * @returns string A random MAC address string. + */ +function generate_mac_address(): string { + # Generate the first three octets (OUI) with fixed bits for unicast and locally administered addresses + $first_octet = dechex((mt_rand(0x00, 0xff) & 0xfe) | 0x02); // Ensure it is locally administered + $mac = [ + $first_octet, + dechex(mt_rand(0x00, 0xff)), + dechex(mt_rand(0x00, 0xff)), + dechex(mt_rand(0x00, 0xff)), + dechex(mt_rand(0x00, 0xff)), + dechex(mt_rand(0x00, 0xff)), + ]; + + # Zero-pad single-character hex values and return the MAC address + return implode( + ':', + array_map(function ($part) { + return str_pad($part, 2, '0', STR_PAD_LEFT); + }, $mac), + ); +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREEndpoint.inc new file mode 100644 index 000000000..51760e2ce --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/interface/gre'; + $this->model_name = 'InterfaceGRE'; + $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/InterfaceGREsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREsEndpoint.inc new file mode 100644 index 000000000..a9c3a3b37 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/interface/gres'; + $this->model_name = 'InterfaceGRE'; + $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/InterfaceLAGGEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGEndpoint.inc new file mode 100644 index 000000000..4e2b84421 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/interface/lagg'; + $this->model_name = 'InterfaceLAGG'; + $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/InterfaceLAGGsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGsEndpoint.inc new file mode 100644 index 000000000..4fe78175d --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/interface/laggs'; + $this->model_name = 'InterfaceLAGG'; + $this->many = true; + $this->request_method_options = ['GET', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc index 9292a7346..2b5c20f24 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc @@ -69,7 +69,7 @@ class CARP extends Model { public function get_carp_internal(): array { return [ 'enable' => $this->is_carp_enabled(), - 'maintenance_mode' => $this->is_config_enabled('/', 'virtualip_carp_maintenancemode'), + 'maintenance_mode' => $this->is_config_enabled('', 'virtualip_carp_maintenancemode'), ]; } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc index 4cbdac411..be3715e83 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc @@ -17,6 +17,7 @@ use RESTAPI\Validators\X509Validator; class Certificate extends Model { public StringField $descr; public UIDField $refid; + public StringField $caref; public StringField $type; public Base64Field $csr; public Base64Field $crt; @@ -37,6 +38,13 @@ class Certificate extends Model { help_text: 'The unique ID assigned to this certificate for internal system use. This value is generated ' . 'by this system and cannot be changed.', ); + $this->caref = new StringField( + default: null, + allow_null: true, + read_only: true, + help_text: 'The unique ID of the existing pfSense Certificate Authority that signed this certificate.' . + 'This value is assigned by this system and cannot be changed.', + ); $this->type = new StringField( default: 'server', choices: ['server', 'user'], @@ -83,6 +91,19 @@ class Certificate extends Model { return $prv; } + /** + * Extends the default _create() method to ensure the certificate is fully imported before creating it. + */ + public function _create(): void { + # Import the cert first using pfSense's cert_import function and relink CAs (if necessary) + $config_data = $this->to_internal(); + cert_import($config_data, $this->crt->value, $this->prv->value); + $this->caref->value = $config_data['caref'] ?? null; + + # Create the Certificate object + parent::_create(); + } + /** * Extends the default _update() method to ensure any `csr` value is removed before updating a Certificate. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc index 1ebe10cbc..bfe4645ac 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc @@ -63,7 +63,8 @@ class CertificateAuthority extends Model { help_text: 'The X509 certificate string.', ); $this->prv = new Base64Field( - required: true, + default: null, + allow_null: true, sensitive: true, validators: [new X509Validator(allow_prv: true, allow_ecprv: true)], help_text: 'The X509 private key string.', 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 39fc3a761..636d418fc 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 @@ -252,22 +252,22 @@ class DHCPServer extends Model { } /** - * Initializes configurations objects for defined interface that have not yet configured the DHCP server + * Initializes configuration objects for defined interface that have not yet configured the DHCP server */ private function init_interfaces(): void { # Variables - $ifs_using_dhcpd = array_keys($this->get_config(path: $this->config_path, default: [])); + $ifs_using_dhcp_server = array_keys($this->get_config(path: $this->config_path, default: [])); # Loop through each defined interface - foreach (NetworkInterface::read_all()->model_objects as $if) { + 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 ($if->typev4->value !== 'static' or $if->subnet->value >= 31) { + if (empty($if['ipaddr']) or $if['ipaddr'] === 'dhcp' or $if->subnet->value >= 31) { continue; } # Otherwise, make this interface eligible for a DHCP server - if (!in_array($if->id, $ifs_using_dhcpd)) { - $this->set_config(path: "$this->config_path/$if->id", value: []); + if (!in_array($if_id, $ifs_using_dhcp_server)) { + $this->set_config(path: "$this->config_path/$if_id", value: []); } } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc index dd417aa18..b626884b5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc @@ -93,34 +93,39 @@ class FirewallAlias extends Model { * @returns string The validated value to set. * @throws ValidationError When the `address` value is invalid. */ - public function validate_address(string $addresses): string { + public function validate_address(string $address): string { + # Variables + $aliases = $this->read_all(); + $type = $this->type->value; + # Ensure value is a port, port range or port alias when `type` is `port` - if ($this->type->value === 'port' and !is_port_or_range_or_alias($addresses)) { + $port_alias_q = $aliases->query(name: $address, type: 'port'); + if ($type === 'port' and !is_port_or_range($address) and !$port_alias_q->exists()) { throw new ValidationError( - message: "Port alias 'address' value '$addresses' is not a valid port, range, or alias.", + message: "Port alias 'address' value '$address' is not a valid port, range, or alias.", response_id: 'INVALID_PORT_ALIAS_ADDRESS', ); } # Ensure value is an IP, FQDN or alias when `type` is `host` - if ($this->type->value === 'host' and !is_ipaddroralias($addresses) and !is_fqdn($addresses)) { + $host_alias_q = $aliases->query(name: $address, type: 'host'); + if ($type === 'host' and !is_ipaddr($address) and !is_fqdn($address) and !$host_alias_q->exists()) { throw new ValidationError( - message: "Host alias 'address' value '$addresses' is not a valid IP, FQDN, or alias.", + message: "Host alias 'address' value '$address' is not a valid IP, FQDN, or alias.", response_id: 'INVALID_HOST_ALIAS_ADDRESS', ); } # Ensure value is a CIDR, FQDN or alias when `type` is `network` - if ($this->type->value === 'network') { - if (!is_subnet($addresses) and alias_get_type($addresses) != 'network' and !is_fqdn($addresses)) { - throw new ValidationError( - message: "Host alias 'address' value '$addresses' is not a valid CIDR, FQDN, or alias.", - response_id: 'INVALID_NETWORK_ALIAS_ADDRESS', - ); - } + $network_alias_q = $aliases->query(name: $address, type: 'network'); + if ($type === 'network' and !is_subnet($address) and !is_fqdn($address) and !$network_alias_q->exists()) { + throw new ValidationError( + message: "Host alias 'address' value '$address' is not a valid CIDR, FQDN, or alias.", + response_id: 'INVALID_NETWORK_ALIAS_ADDRESS', + ); } - return $addresses; + return $address; } /** diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceGRE.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceGRE.inc new file mode 100644 index 000000000..2ec2f8cb3 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceGRE.inc @@ -0,0 +1,178 @@ +config_path = 'gres/gre'; + $this->many = true; + $this->always_apply = true; + + # Set model fields + $this->if = new InterfaceField( + required: true, + help_text: 'The pfSense interface interface serving as the local address to be used for the GRE tunnel.', + ); + $this->greif = new StringField( + default: null, + allow_null: true, + read_only: true, + help_text: 'The real interface name for this GRE interface.', + ); + $this->descr = new StringField( + default: '', + allow_empty: true, + help_text: 'A description for this GRE interface.', + ); + $this->add_static_route = new BooleanField( + default: false, + internal_name: 'link1', + help_text: 'Whether to add an explicit static route for the remote inner tunnel address/subnet via the ' . + 'local tunnel address.', + ); + $this->remote_addr = new StringField( + required: true, + internal_name: 'remote-addr', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], + help_text: 'The remote address to use for the GRE tunnel.', + ); + $this->tunnel_local_addr = new StringField( + default: null, + allow_null: true, + internal_name: 'tunnel-local-addr', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + help_text: 'The local IPv4 address to use for the GRE tunnel.', + ); + $this->tunnel_remote_addr = new StringField( + required: true, + unique: true, + internal_name: 'tunnel-remote-addr', + conditions: ['!tunnel_local_addr' => null], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + help_text: 'The remote IPv4 address to use for the GRE tunnel.', + ); + $this->tunnel_remote_net = new IntegerField( + default: 32, + minimum: 1, + maximum: 32, + internal_name: 'tunnel-remote-net', + conditions: ['!tunnel_local_addr' => null], + help_text: 'The remote IPv4 subnet bitmask to use for the GRE tunnel.', + ); + $this->tunnel_local_addr6 = new StringField( + default: null, + allow_null: true, + internal_name: 'tunnel-local-addr6', + validators: [new IPAddressValidator(allow_ipv4: false, allow_ipv6: true)], + help_text: 'The local IPv6 address to use for the GRE tunnel.', + ); + $this->tunnel_remote_addr6 = new StringField( + required: true, + unique: true, + internal_name: 'tunnel-remote-addr6', + conditions: ['!tunnel_local_addr6' => null], + validators: [new IPAddressValidator(allow_ipv4: false, allow_ipv6: true)], + help_text: 'The remote IPv6 address to use for the GRE tunnel.', + ); + $this->tunnel_remote_net6 = new IntegerField( + default: 128, + minimum: 1, + maximum: 128, + internal_name: 'tunnel-remote-net6', + conditions: ['!tunnel_local_addr6' => null], + help_text: 'The remote IPv6 subnet bitmask to use for the GRE tunnel.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Adds extra validation to this entire Model. + * @throws ValidationError If neither a local IPv4 nor IPv6 tunnel address is present. + */ + public function validate_extra(): void { + # Require either a local IPv4 and/or IPv6 address to present + if (!$this->tunnel_local_addr->value and !$this->tunnel_local_addr6->value) { + throw new ValidationError( + message: 'GRE tunnel must have a `tunnel_local_addr` and/or `tunnel_local_addr6`.', + response_id: 'INTERFACE_GRE_HAS_NO_LOCAL_ADDRESS', + ); + } + } + + /** + * Applies changes to this Interface GRE Tunnel. + */ + public function apply(): void { + # If the greif is already assigned to an interface, reconfigure the interface + $if_q = NetworkInterface::query(if: $this->greif->value); + if ($if_q->exists()) { + interface_configure($if_q->first()->id); + } + } + + /** + * Applies the deletion of this Interface GRE Tunnel. + */ + public function apply_delete(): void { + pfSense_interface_destroy($this->greif->value); + } + + /** + * Extend the default _create method to create the GRE interface and obtain the real interface name. + */ + public function _create(): void { + $this->greif->value = interface_gre_configure($this->to_internal()); + parent::_create(); + } + + /** + * Extend the default _update method to reconfigure the GRE interface. + */ + public function _update(): void { + interface_gre_configure($this->to_internal()); + parent::_update(); + } + + /** + * Extend the default _delete method to prevent deletion of the GRE interface while in use. + * @throws ConflictError If the GRE interface is in use. + */ + public function _delete(): void { + # If the greif is in use, don't allow deletion + $if_q = NetworkInterface::query(if: $this->greif->value); + if ($if_q->exists()) { + throw new ConflictError( + message: 'Cannot delete GRE interface while it is in use.', + response_id: 'INTERFACE_GRE_CANNOT_DELETE_WHILE_IN_USE', + ); + } + + parent::_delete(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceLAGG.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceLAGG.inc new file mode 100644 index 000000000..efa0a7d9b --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceLAGG.inc @@ -0,0 +1,167 @@ +config_path = 'laggs/lagg'; + $this->many = true; + $this->always_apply = true; + + # Set model fields + $this->laggif = new StringField( + read_only: true, + maximum_length: 32, + help_text: 'The real name of the LAGG interface.', + ); + $this->descr = new StringField( + default: '', + allow_empty: true, + help_text: 'A description to help document the purpose of this LAGG interface.', + ); + $this->members = new StringField( + required: true, + many: true, + many_minimum: 1, + help_text: 'A list of member interfaces to include in the LAGG.', + ); + $this->proto = new StringField( + required: true, + choices: ['lacp', 'failover', 'loadbalance', 'roundrobin', 'none'], + help_text: 'The LAGG protocol to use.', + ); + $this->lacptimeout = new StringField( + default: 'slow', + choices: ['slow', 'fast'], + conditions: ['proto' => 'lacp'], + help_text: 'The LACP timeout mode to use.', + ); + $this->lagghash = new StringField( + default: 'l2,l3,l4', + choices: ['l2', 'l3', 'l4', 'l2,l3', 'l2,l4', 'l3,l4', 'l2,l3,l4'], + conditions: ['proto' => ['lacp', 'loadbalance']], + help_text: 'The LAGG hash algorithm to use.', + ); + $this->failovermaster = new StringField( + default: 'auto', + conditions: ['proto' => 'failover'], + help_text: 'The failover master interface to use.', + ); + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Add extra validation to the `members` field. + * @param string $value The value to validate. + * @return string The validated value to assign. + * @throws ValidationError When the requested member interface does not exist. + */ + public function validate_members(string $value): string { + # Throw a validation error if the request member does not exist + if (!does_interface_exist($value)) { + throw new ValidationError( + message: "Member interface `$value` does not exist or is invalid.", + response_id: 'INTERFACE_LAGG_MEMBER_DOES_NOT_EXIST', + ); + } + + return $value; + } + + /** + * Adds extra validation to the `failovermaster` field. + * @param string $value The value to validate. + * @return string The validated value to assign. + * @throws ValidationError When the requested failover master interface is not assigned as a member. + */ + public function validate_failovermaster(string $value): string { + # Always allow the `auto` value + if ($value === 'auto') { + return $value; + } + + # Throw a validation error if the requested failover master is not a member + if (!in_array($value, $this->members->value)) { + throw new ValidationError( + message: "Failover master interface `$value` is not a member of the LAGG.", + response_id: 'INTERFACE_LAGG_MASTER_NOT_MEMBER', + ); + } + + return $value; + } + + /** + * Extends the default _create method to capture the LAGG interface name of the newly created LAGG. + */ + public function _create(): void { + $this->laggif->value = interface_lagg_configure($this->to_internal()); + parent::_create(); + } + + /** + * Extends the default _update method to reconfigure the updated LAGG. + */ + public function _update(): void { + interface_lagg_configure($this->to_internal()); + parent::_update(); + } + + /** + * Extends the default _delete method to prevent deletion of the LAGG interface if it is in use. + */ + public function _delete(): void { + # Query for an interface using this lagg + $if_q = NetworkInterface::query(if: $this->laggif->value); + if ($if_q->exists()) { + throw new ConflictError( + message: "Cannot delete LAGG interface `{$this->laggif->value}` because it is in use by interface " . + "with ID `{$if_q->first()->id}`.", + response_id: 'INTERFACE_LAGG_CANNOT_BE_DELETED_WHILE_IN_USE', + ); + } + + parent::_delete(); + } + + /** + * Reconfigures VLANs and interfaces using this LAGG after successful creation or update. + */ + public function apply(): void { + # Check for interfaces using this LAGG and reconfigure them + $if_q = NetworkInterface::query(if: $this->laggif->value); + foreach ($if_q->model_objects as $if) { + interface_configure($if->id); + } + + # Check for VLANs using this LAGG and reconfigure them + $vlan_q = InterfaceVLAN::query(if: $this->laggif->value); + foreach ($vlan_q->model_objects as $vlan) { + interface_vlan_configure($vlan->to_internal()); + } + } + + /** + * Applies the deletion of this LAGG interface. + */ + public function apply_delete(): void { + pfSense_interface_destroy($this->laggif->value); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc index aec1ef5b2..e65976c9c 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc @@ -207,8 +207,8 @@ class RoutingGateway extends Model { # Ensure the gateway cannot be disabled while it is in use by a system DNS server # TODO: replace with a Model query when the SystemDNSServer Model is developed $dnsgw_counter = 1; - while (isset($config['system']["dns{$dnsgw_counter}gw"])) { - if ($this->name->value == $config['system']["dns{$dnsgw_counter}gw"]) { + while ($this->get_config("system/dns{$dnsgw_counter}gw")) { + if ($this->name->value == $this->get_config("system/dns{$dnsgw_counter}gw")) { throw new ValidationError( message: 'Gateway cannot be disabled because it is in use by a system DNS ' . "server with ID `$dnsgw_counter`", diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc index 022140a65..25edefe66 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc @@ -68,7 +68,16 @@ class RoutingGatewayGroup extends Model { protected function from_internal_ipprotocol(): string { # Check the IP protocol of the gateways in this group foreach ($this->priorities->value as $priority) { - $gw_obj = RoutingGateway::query(name: $priority['gateway'])->first(); + # Query for the gateway related to this priority + $gw_q = RoutingGateway::query(name: $priority['gateway']); + + # Skip this priority if the gateway does not exist. This can happen if the gateway is not in config. + if (!$gw_q->exists()) { + continue; + } + + # Check the IP protocol of the gateway + $gw_obj = $gw_q->first(); if ($gw_obj->ipprotocol->value === 'inet') { return 'inet'; } elseif ($gw_obj->ipprotocol->value === 'inet6') { diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc index a4e402de9..020342fb5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc @@ -31,6 +31,8 @@ class VirtualIP extends Model { public IntegerField $advskew; public StringField $password; public StringField $carp_status; + public StringField $carp_mode; + public StringField $carp_peer; public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { # Define model attributes @@ -122,6 +124,20 @@ class VirtualIP extends Model { help_text: 'The current CARP status of this virtual IP. This will display show whether this CARP node ' . 'is the primary or backup peer.', ); + $this->carp_mode = new StringField( + default: 'mcast', + choices: ['mcast', 'ucast'], + conditions: ['mode' => 'carp'], + help_text: 'The CARP mode to use for this virtual IP. Please note this field is exclusive to ' . + 'pfSense Plus and has no effect on CE.', + ); + $this->carp_peer = new StringField( + required: true, + conditions: ['carp_mode' => 'ucast'], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], + help_text: 'The IP address of the CARP peer. Please note this field is exclusive to pfSense Plus and ' . + 'has no effect on CE.', + ); parent::__construct($id, $parent_id, $data, ...$options); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc index bff7c7cf5..4a67f54b5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc @@ -350,8 +350,8 @@ class OpenAPISchema extends Schema { 'explode' => true, 'description' => 'The arbitrary query parameters to include in the request.

' . - 'Note: This does not define an real parameter, rather it allows for any arbitrary query ' . - 'parameters to be included in the request.', + 'Note: This does not define a real parameter (e.g. there is no `query` parameter), ' . + 'rather it allows for any arbitrary query parameters to be included in the request.', 'schema' => [ 'type' => 'object', 'default' => new stdClass(), 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 e2d0d8411..306928de6 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 @@ -7,10 +7,13 @@ use RESTAPI; use RESTAPI\Caches\RESTAPIVersionReleasesCache; use RESTAPI\Core\Auth; use RESTAPI\Core\Model; +use RESTAPI\Models\DHCPServer; +use RESTAPI\Models\DHCPServerStaticMapping; use RESTAPI\Models\FirewallAlias; use RESTAPI\Models\InterfaceVLAN; use RESTAPI\Models\SystemStatus; use RESTAPI\Models\Test; +use function RESTAPI\Models\DHCPServerStaticMapping; class APICoreModelTestCase extends RESTAPI\Core\TestCase { /** @@ -1197,4 +1200,52 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase { # Remove the write lock unlink(Model::WRITE_LOCK_FILE); } + + /** + * Ensure the copy() method creates an unlinked copy of the Model object. + */ + public function test_copy(): void { + # Create a new FirewallAlias model to test with + $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['1.2.3.4']); + $alias->id = 5; + $alias_copy = $alias->copy(); + + # Ensure the values are equal + $this->assert_equals($alias->to_internal(), $alias_copy->to_internal()); + $this->assert_equals($alias_copy->id, 5); + $this->assert_equals($alias->to_representation(), $alias_copy->to_representation()); + + # Change the copy's values and ensure the original values are not changed + $alias_copy->name->value = 'new_name'; + $this->assert_not_equals($alias->name->value, $alias_copy->name->value); + } + + /** + * Ensure the copy() method does not use excessive memory in larger datasets. This is a regression test for #617. + */ + public function test_copy_memory_usage(): void { + # Generate lots of DHCP Server Static Mappings + $static_mappings = []; + $used_macs = []; + while (count($static_mappings) < 450) { + # Generate a unique mac address for each static mapping + $mac = RESTAPI\Core\Tools\generate_mac_address(); + if (in_array($mac, $used_macs)) { + continue; + } + $static_mappings[] = ['mac' => $mac]; + } + + # Update the DHCP server with our static mappings + $dhcp_server = new DHCPServer(id: 'lan', staticmap: $static_mappings); + $dhcp_server->update(); + + # Read a static mapping and copy it + $static_mapping = new DHCPServerStaticMapping(id: 50, parent_id: 'lan'); + $static_mapping->copy(); + + # Remove the static mappings + $dhcp_server->staticmap->value = []; + $dhcp_server->update(apply: true); + } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc index 00779f693..f62b220ac 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc @@ -6,6 +6,7 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\TestCase; use function RESTAPI\Core\Tools\bandwidth_to_bits; +use function RESTAPI\Core\Tools\generate_mac_address; use function RESTAPI\Core\Tools\is_assoc_array; use function RESTAPI\Core\Tools\to_upper_camel_case; @@ -52,4 +53,13 @@ class APICoreToolsTestCase extends TestCase { $this->assert_equals(to_upper_camel_case('this is a test'), 'ThisIsATest'); $this->assert_equals(to_upper_camel_case('_this is-a test'), 'ThisIsATest'); } + + /** + * Checks that the generate_mac_address() function correctly generates a random MAC address. + */ + public function test_generate_mac_address(): void { + $mac_address = generate_mac_address(); + $this->assert_equals(strlen($mac_address), 17); + $this->assert_is_true(preg_match('/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/', $mac_address) === 1); + } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc index 56f09d2ea..b724b5c7b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc @@ -86,7 +86,7 @@ class APIModelsACMECertificateTestCase extends TestCase { $renew = new ACMECertificateRenew(certificate: $cert->name->value, async: false); $renew->create(); $this->assert_equals($renew->status->value, 'completed'); - $this->assert_str_contains($renew->result_log->value, "Renew: '{$cert->a_domainlist->value[0]['name']}'"); + $this->assert_str_contains($renew->result_log->value, "Renewing: '{$cert->a_domainlist->value[0]['name']}'"); # Delete the ACME certificate $cert->delete(); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc index 71f6ba483..a5e5245d9 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc @@ -4,6 +4,8 @@ namespace RESTAPI\Tests; use RESTAPI\Core\TestCase; use RESTAPI\Models\Certificate; +use RESTAPI\Models\CertificateAuthorityGenerate; +use RESTAPI\Models\CertificateGenerate; class APIModelsCertificateTestCase extends TestCase { const EXAMPLE_CRT = "-----BEGIN CERTIFICATE----- @@ -110,4 +112,56 @@ R02Pul8ulWQ8Kl3Q3pou8As7W1mMzA2DxQ== }, ); } + + /** + * Checks that certificates are relinked to their CAs (if found) when they are created/imported. + */ + public function test_certificate_is_relinked_to_ca_on_create(): void { + # Create a CA we can use to test the relinking + $ca = new CertificateAuthorityGenerate( + descr: 'test_ca', + trust: true, + randomserial: true, + is_intermediate: false, + keytype: 'RSA', + keylen: 2048, + digest_alg: 'sha256', + lifetime: 3650, + dn_country: 'US', + dn_state: 'UT', + dn_city: 'Salt Lake City', + dn_organization: 'ACME Org', + dn_organizationalunit: 'IT', + ); + $ca->always_apply = false; # Disable always_apply so we can test the create method without overloading cpu + $ca->create(); + + # Generate a new certificates using the CA + $cert = new CertificateGenerate( + descr: 'testcert', + caref: $ca->refid->value, + keytype: 'RSA', + keylen: 2048, + digest_alg: 'sha256', + lifetime: 3650, + type: 'user', + dn_country: 'US', + dn_state: 'UT', + dn_city: 'Salt Lake City', + dn_organization: 'ACME Org', + dn_organizationalunit: 'IT', + dn_commonname: 'testcert.example.com', + ); + $cert->create(); + + # Capture the crt and prv values of the certificate and delete it + $crt = $cert->crt->value; + $prv = $cert->prv->value; + $cert->delete(); + + # Import the certificate and ensure it is automatically relinked to the CA + $cert = new Certificate(descr: 'testcert', type: 'user', crt: $crt, prv: $prv); + $cert->create(); + $this->assert_equals($ca->refid->value, $cert->caref->value); + } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc index 21c5591c4..c98d8e425 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc @@ -144,4 +144,22 @@ class APIModelsFirewallAliasTestCase extends TestCase { }, ); } + + /** + * Checks that we can reference a nested alias during replace_all() calls. This is regression test for #619. + */ + public function test_nested_alias_reference_in_replace_all(): void { + # Ensure we can reference a nested alias during replace_all() calls without an error being thrown + $this->assert_does_not_throw( + callable: function () { + $alias = new FirewallAlias(); + $alias->replace_all( + data: [ + ['name' => 'test_alias1', 'type' => 'host', 'address' => []], + ['name' => 'test_alias2', 'type' => 'host', 'address' => ['test_alias1']], + ], + ); + }, + ); + } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceGRETestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceGRETestCase.inc new file mode 100644 index 000000000..f7e431427 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceGRETestCase.inc @@ -0,0 +1,100 @@ +assert_throws_response( + response_id: 'INTERFACE_GRE_HAS_NO_LOCAL_ADDRESS', + code: 400, + callable: function () { + $gre = new InterfaceGRE(); + $gre->validate_extra(); + }, + ); + + # Ensure no error is thrown in a tunnel_local_addr is assigned + $this->assert_does_not_throw( + callable: function () { + $gre = new InterfaceGRE(tunnel_local_addr: '1.2.3.4'); + $gre->validate_extra(); + }, + ); + + # Ensure no error is thrown in a tunnel_local_addr6 is assigned + $this->assert_does_not_throw( + callable: function () { + $gre = new InterfaceGRE(tunnel_local_addr6: '1234::1'); + $gre->validate_extra(); + }, + ); + } + + /** + * Ensure an existing GRE cannot be deleted if it is in use. + */ + public function test_delete_in_use(): void { + # Ensure an error is thrown if the GRE is in use + $this->assert_throws_response( + response_id: 'INTERFACE_GRE_CANNOT_DELETE_WHILE_IN_USE', + code: 409, + callable: function () { + $gre = new InterfaceGRE( + if: 'lan', + remote_addr: '1.2.3.4', + tunnel_local_addr: '4.3.2.1', + tunnel_remote_addr: '1.1.2.2', + tunnel_remote_net: 32, + ); + $gre->create(); + + # Mock a GRE interface in use + InterfaceGRE::set_config('interfaces/opt99', ['if' => $gre->greif->value]); + $gre->delete(); + }, + ); + + # Remove all GRE interfaces used in the test + InterfaceGRE::del_config('interfaces/opt99'); + InterfaceGRE::delete_all(); + } + + /** + * Ensure we can create, read, update and delete a GRE interface. + */ + public function test_crud(): void { + # Create a new GRE tunnel and ensure it's interface is seen in ifconfig + $gre = new InterfaceGRE( + if: 'lan', + remote_addr: '127.1.2.3', + tunnel_local_addr: '127.3.2.1', + tunnel_remote_addr: '127.5.5.5', + tunnel_remote_net: 32, + ); + $gre->create(); + $ifconfig_output = new Command('ifconfig'); + $this->assert_str_contains($ifconfig_output->output, $gre->greif->value); + $this->assert_str_contains($ifconfig_output->output, 'tunnel inet 192.168.1.1 --> 127.1.2.3'); + $this->assert_str_contains($ifconfig_output->output, 'inet 127.3.2.1 --> 127.5.5.5 netmask 0xffffffff'); + + # Update the GRE tunnel and ensure the changes are reflected in ifconfig + $gre->remote_addr->value = '2.3.4.5'; + $gre->update(); + $ifconfig_output = new Command('ifconfig'); + $this->assert_str_does_not_contain($ifconfig_output->output, 'tunnel inet 192.168.1.1 --> 127.1.2.3'); + $this->assert_str_contains($ifconfig_output->output, 'tunnel inet 192.168.1.1 --> 2.3.4.5'); + + # Delete the GRE tunnel and ensure it's interface is no longer seen in ifconfig + $gre->delete(); + $ifconfig_output = new Command('ifconfig'); + $this->assert_str_does_not_contain($ifconfig_output->output, $gre->greif->value); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceLAGGTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceLAGGTestCase.inc new file mode 100644 index 000000000..84ee7ea79 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceLAGGTestCase.inc @@ -0,0 +1,108 @@ +assert_throws_response( + response_id: 'INTERFACE_LAGG_MEMBER_DOES_NOT_EXIST', + code: 400, + callable: function () { + $lagg = new InterfaceLAGG(); + $lagg->validate_members('nonexistent'); + }, + ); + + # Ensure no error is thrown if the member interface exists + $this->assert_does_not_throw( + callable: function () { + $lagg = new InterfaceLAGG(); + $lagg->validate_members($this->env['PFREST_OPT1_IF']); + }, + ); + } + + /** + * Checks that the `failovermaster` field must be a member of the LAGG. + */ + public function test_failovermaster_field_must_be_member_of_lagg(): void { + # Ensure an error is thrown if the failover master is not a member + $this->assert_throws_response( + response_id: 'INTERFACE_LAGG_MASTER_NOT_MEMBER', + code: 400, + callable: function () { + $lagg = new InterfaceLAGG(); + $lagg->members->value = [$this->env['PFREST_OPT1_IF']]; + $lagg->validate_failovermaster('nonexistent'); + }, + ); + + # Ensure no error is thrown if the failover master is a member + $this->assert_does_not_throw( + callable: function () { + $lagg = new InterfaceLAGG(); + $lagg->members->value = [$this->env['PFREST_OPT1_IF']]; + $lagg->validate_failovermaster($this->env['PFREST_OPT1_IF']); + }, + ); + } + + /** + * Ensure that we can create, read, update and delete a LAGG interface. + */ + public function test_crud(): void { + # Create a new LAGG interface + $lagg = new InterfaceLAGG(members: [$this->env['PFREST_OPT1_IF']], proto: 'lacp'); + $lagg->create(); + $this->assert_is_not_empty($lagg->laggif->value); + $ifconfig_output = new Command("ifconfig {$lagg->laggif->value}"); + $this->assert_str_contains($ifconfig_output->output, 'laggproto lacp'); + $this->assert_str_contains($ifconfig_output->output, "laggport: {$this->env['PFREST_OPT1_IF']}"); + + # Update the LAGG interface + $lagg->proto->value = 'failover'; + $lagg->update(); + $ifconfig_output = new Command("ifconfig {$lagg->laggif->value}"); + $this->assert_str_contains($ifconfig_output->output, 'laggproto failover'); + $this->assert_str_does_not_contain($ifconfig_output->output, 'laggproto lacp'); + + # Delete the LAGG interface + $lagg->delete(); + $ifconfig_output = new Command("ifconfig {$lagg->laggif->value}"); + $this->assert_str_contains($ifconfig_output->output, 'does not exist'); + } + + /** + * Ensure that we cannot delete a LAGG that is in use. + */ + public function test_cannot_delete_lagg_in_use(): void { + # Create a new LAGG + $lagg = new InterfaceLAGG(members: [$this->env['PFREST_OPT1_IF']], proto: 'lacp'); + $lagg->create(); + + # Mock an interface that is using the LAGG + InterfaceLAGG::set_config('interfaces/opt99', ['if' => $lagg->laggif->value]); + + # Ensure we cannot delete the LAGG + $this->assert_throws_response( + response_id: 'INTERFACE_LAGG_CANNOT_BE_DELETED_WHILE_IN_USE', + code: 409, + callable: function () use ($lagg) { + # Try to delete the LAGG + $lagg->delete(); + }, + ); + + # Remove the mock interface and actually delete the LAGG + InterfaceLAGG::del_config('interfaces/opt99'); + $lagg->delete(); + } +} diff --git a/requirements.txt b/requirements.txt index eed3ea924..cc5659e41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ jinja2~=3.1.4 pylint~=3.3.1 black~=24.10.0 mkdocs~=1.6.1 -boto3~=1.35.54 \ No newline at end of file diff --git a/tools/make_package.py b/tools/make_package.py index 42d52959c..ec58dba39 100644 --- a/tools/make_package.py +++ b/tools/make_package.py @@ -140,7 +140,7 @@ def build_on_remote_host(self): "mkdir -p ~/build/", f"rm -rf ~/build/{REPO_NAME}", f"git clone https://github.com/{REPO_OWNER}/{REPO_NAME}.git ~/build/{REPO_NAME}/", - f"git -C ~/build/{REPO_NAME} checkout " + self.args.branch, + f"git -C ~/build/{REPO_NAME} checkout '{self.args.branch}'", f"composer install --working-dir ~/build/{REPO_NAME}", f"cp -r ~/build/{REPO_NAME}/vendor/* {includes_dir}", f"python3 ~/build/{REPO_NAME}/tools/make_package.py --tag {self.args.tag} {notests}",