diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b83baeea..39f560f77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,8 @@ jobs: include: - PFSENSE_VERSION: pfSense-2.8.0-RELEASE FREEBSD_ID: freebsd15 + - PFSENSE_VERSION: pfSense-2.8.1-RELEASE + FREEBSD_ID: freebsd15 steps: - uses: actions/checkout@v5 - uses: actions/download-artifact@v5 @@ -105,6 +107,8 @@ jobs: include: - PFSENSE_VERSION: pfSense-2.8.0-RELEASE FREEBSD_ID: freebsd15 + - PFSENSE_VERSION: pfSense-2.8.1-RELEASE + FREEBSD_ID: freebsd15 steps: - uses: actions/checkout@v5 - uses: actions/download-artifact@v5 @@ -132,6 +136,8 @@ jobs: include: - PFSENSE_VERSION: pfSense-2.8.0-RELEASE FREEBSD_ID: freebsd15 + - PFSENSE_VERSION: pfSense-2.8.1-RELEASE + FREEBSD_ID: freebsd15 steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65091d098..0334a4d2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,8 +28,14 @@ jobs: # Note: The first item in this matrix must use env.DEFAULT_PFSENSE_VERSION as the PFSENSE_VERSION! - FREEBSD_VERSION: FreeBSD-15.0-CURRENT PFSENSE_VERSION: "2.8.0" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "2.8.1" - FREEBSD_VERSION: FreeBSD-15.0-CURRENT PFSENSE_VERSION: "24.11" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "25.07" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "25.07.1" steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index 7490d35a0..c96e0300c 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,13 @@ commands are included below for quick reference. Install on pfSense CE: ```bash -pkg-static add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-2.8.0-pkg-RESTAPI.pkg +pkg-static add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-2.8.1-pkg-RESTAPI.pkg ``` Install on pfSense Plus: ```bash -pkg-static -C /dev/null add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-24.11-pkg-RESTAPI.pkg +pkg-static -C /dev/null add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-25.07.1-pkg-RESTAPI.pkg ``` > [!WARNING] diff --git a/composer.lock b/composer.lock index c6821ab45..9e051d691 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": "a32ab4a8fc071e68a251a9446caf15b9", - "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": "a32ab4a8fc071e68a251a9446caf15b9", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.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.11.1", - "source": { - "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.4", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^2.0||^3.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.11.1" - }, - "time": "2025-04-09T20:32:01+00:00" + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" }, { - "name": "webonyx/graphql-php", - "version": "v15.24.0", - "source": { - "type": "git", - "url": "https://github.com/webonyx/graphql-php.git", - "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/030a04d22d52d7fc07049d0e3b683d2b40f90457", - "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457", - "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.86.0", - "mll-lab/php-cs-fixer-config": "5.11.0", - "nyholm/psr7": "^1.5", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.22", - "phpstan/phpstan-phpunit": "2.0.7", - "phpstan/phpstan-strict-rules": "2.0.6", - "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": "^2.0", - "symfony/polyfill-php81": "^1.23", - "symfony/var-exporter": "^5 || ^6 || ^7", - "thecodingmachine/safe": "^1.3 || ^2 || ^3" - }, - "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.24.0" - }, - "funding": [ - { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" - } - ], - "time": "2025-08-20T10:09:37+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.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.24.0", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/030a04d22d52d7fc07049d0e3b683d2b40f90457", + "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457", + "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.86.0", + "mll-lab/php-cs-fixer-config": "5.11.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.22", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "2.0.6", + "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": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7", + "thecodingmachine/safe": "^1.3 || ^2 || ^3" + }, + "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.24.0" + }, + "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": "2025-08-20T10:09:37+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/BUILDING_CUSTOM_ENDPOINT_CLASSES.md b/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md index fea1a6fe1..287124c4e 100644 --- a/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md +++ b/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md @@ -108,6 +108,11 @@ The `tag` property is used to define the [OpenAPI tag](https://swagger.io/docs/s for the endpoint. This is used to group related endpoints together in the API documentation. This property is optional and defaults to the first section of the URL after the `/api/v2/` prefix. +### deprecated + +The `deprecated` property is used to indicate if the endpoint is deprecated. This property defaults to `false`. When set +to `true`, the endpoint will be marked as deprecated in applicable documentation and schemas. + ### requires_auth The `requires_auth` property is used to specify whether the endpoint requires authentication and authorization. This property diff --git a/docs/COMMON_CONTROL_PARAMETERS.md b/docs/COMMON_CONTROL_PARAMETERS.md index 823f67a2a..4df632922 100644 --- a/docs/COMMON_CONTROL_PARAMETERS.md +++ b/docs/COMMON_CONTROL_PARAMETERS.md @@ -1,8 +1,12 @@ # Common Control Parameters The API utilizes a set of common parameters to control certain behaviors of API calls. These parameters are available to -all endpoints and requests, but some endpoints may not support all parameters. Below are the available control -parameters you can use: +all endpoints and requests, but some endpoints may not support all parameters. + +!!! Note + Requests must pass these parameters according to your specific [content type](CONTENT_AND_ACCEPT_TYPES.md). For + example, if you are using the `application/json` content-type, these parameters should be included in the JSON body + of your request. Content types cannot be mixed. ## append diff --git a/docs/CONTENT_AND_ACCEPT_TYPES.md b/docs/CONTENT_AND_ACCEPT_TYPES.md index 2a52d3f61..79b42450d 100644 --- a/docs/CONTENT_AND_ACCEPT_TYPES.md +++ b/docs/CONTENT_AND_ACCEPT_TYPES.md @@ -6,6 +6,10 @@ The REST API has been designed to allow multiple content and accept types to be Content types are used to specify the format of the data being sent in your request. You must specify the content type in the `Content-Type` header of your request. The REST API supports the following content types: +!!! Important + Only one content type can be specified per request. Data formats cannot be mixed. For example, a request cannot + use both a JSON body and URL query string parameters to send data. + ### application/json - MIME Type: `application/json` - Description: Use this content type to send JSON data in the body of your request. The data should be formatted as a JSON object or array. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index bc9b097d9..6083c284e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -27,6 +27,30 @@ To make a code contribution, please follow these steps: If your contribution involves a security vulnerability, please do not open a public issue or pull request. Instead, please report the vulnerability to a [project maintainer](index.md#maintainers) directly. +## Pull Requests + +When submitting a pull request, please be aware of the various development branches used by the project and base your branch accordingly. Ensure your pull request's target branch is set to merge into the appropriate branch. + +### next_patch + +The `next_patch` branch is used for small bug fixes, minor documentation corrections, and other small changes that do not introduce new features or break existing functionality. Changes implemented in this branch will be included in the next patch release. + +### next_minor + +The `next_minor` branch is used for new features, enhancements, fixes, and other changes that do not introduce major breaking changes. Breaking changes _are_ allowed in minor release as long as the impact is minimal and justified. Changes implemented in this branch will be included in the next minor release. + +### next_major + +The `next_major` branch is dedicated to major changes to the projects structure, schemas, and overall functionality. Pull requests to this branch are allowed, but are rarely accepted as the branch is intended for fundamental changes to the project. Changes implemented in this branch will be included in the next major release. + +### master + +The `master` branch reflects the latest stable release of the project. Pull requests should _not_ be made to this branch directly. Instead, pull requests should be made to one of the other branches and merged into `master` when a new release is ready. Notable exceptions to this rule are: + +- Emergency security fixes that need to be applied to the latest stable release. +- Dependency updates that do not break existing functionality. +- Documentation updates that are not tied to a specific release. + ## Project Structure The majority of the pfSense REST API package code can be found at `pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI`. diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md index c730e1d14..19a9ca41f 100644 --- a/docs/INSTALL_AND_CONFIG.md +++ b/docs/INSTALL_AND_CONFIG.md @@ -15,7 +15,10 @@ run pfSense. It's recommended to follow Netgate's [minimum hardware requirements ### Supported pfSense versions - pfSense CE 2.8.0 +- pfSense CE 2.8.1 - pfSense Plus 24.11 +- pfSense Plus 25.07 +- pfSense Plus 25.07.1 !!! Warning Installation of the package on unsupported versions of pfSense may result in unexpected behavior and/or system instability. diff --git a/docs/NATIVE_SCHEMA.md b/docs/NATIVE_SCHEMA.md new file mode 100644 index 000000000..6ef47ffd8 --- /dev/null +++ b/docs/NATIVE_SCHEMA.md @@ -0,0 +1,39 @@ +# Native Schema + +The package's framework also generates a proprietary schema that describes components of the API in more detail than +the OpenAPI or GraphQL schemas. This is intended for third-party tools and integrations that need more context about +the API's structure, behavior and attributes. The native schema is generated by extracting data the codebases PHP class +properties directly and use internal metadata that is not normally exposed. + +## Accessing the Schema + +The native schema can by obtained by making a `GET` request to the `/api/v2/schema/native` endpoint. This endpoint +does not require authentication and is accessible to all users as it only provides descriptive metadata about the API +that is already publicly available by referencing the code on GitHub. + +## Understanding the Structure + +Since the native schema is generated from the codebase itself, the easiest way to understand its contents is to +review the properties available to [Endpoint](BUILDING_CUSTOM_ENDPOINT_CLASSES.md#define-__construct-method-properties), +[Model](BUILDING_CUSTOM_MODEL_CLASSES.md#define-__construct-method-properties) and [Field](BUILDING_CUSTOM_MODEL_CLASSES.md#define-field-objects) +classes. Below is a basic outline of the schema's structure: + +```json +{ + "endpoints": { + "/endpoint/url/path": { + ... + } + }, + "models": { + "ModelName": { + "fields": { + "field_name": { + ... + }, + ... + } + } + } +} +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 2c1ee0ce0..d50975458 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - Building Custom Dispatchers: BUILDING_CUSTOM_DISPATCHER_CLASSES.md - Building Custom Caches: BUILDING_CUSTOM_CACHE_CLASSES.md - Building Custom Content Handlers: BUILDING_CUSTOM_CONTENT_HANDLER_CLASSES.md + - Native Schema: NATIVE_SCHEMA.md - PHP Reference: https://pfrest.org/php-docs/ theme: name: readthedocs 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 d17e1f1ea..2f183d1f9 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 @@ -68,20 +68,36 @@ class TestCase { # Set the current method undergoing testing $this->method = $method; + $ref_method = new ReflectionMethod($this, $method); # Gather the description for this test method from its doc comment - $this->method_docstring = (new ReflectionMethod($this, $method))->getDocComment(); + $this->method_docstring = $ref_method->getDocComment(); $this->method_docstring = str_replace([PHP_EOL, '/**', '*/', '*', ' '], '', $this->method_docstring); - # Try to run the test. On failure, restore the original config and throw the exception - try { - $this->$method(); - } catch (Error | Exception $e) { - # Restore the original configuration, teardown the TestCase and throw the encountered error - $config = $original_config; - write_config("Restored config after API test '$method'"); - $this->teardown(); - throw $e; + # Get the retry settings for this method + $retry_settings = $this->get_method_retry_settings($ref_method); + + # Always attempt once, then add retries if configured + for ($attempt = 0; $attempt <= $retry_settings->retries; $attempt++) { + try { + # Try to run the test + $this->$method(); + 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->teardown(); + + # If we have retries left, wait the configured delay and try again + if ($attempt < $retry_settings->retries) { + sleep($retry_settings->delay); + continue; + } + + # If we made it here, we have no retries left. Throw the exception to the caller. + throw $e; + } } # Restore the config as it was when the test began. @@ -137,6 +153,23 @@ class TestCase { */ public function teardown(): void {} + /** + * @param ReflectionMethod $method The method to check for a TestCaseRetry attribute. + * @return TestCaseRetry The TestCaseRetry attribute found on the method, or a default TestCaseRetry object + */ + protected function get_method_retry_settings(ReflectionMethod $method): TestCaseRetry { + # Use default retry settings if no attribute is found + $retries = new TestCaseRetry(); + + # If this method has a TestCaseRetry attribute, use it + $attributes = $method->getAttributes(TestCaseRetry::class); + if (count($attributes) > 0) { + $retries = $attributes[0]->newInstance(); + } + + return $retries; + } + /** * Obtains the environment variable with a given name. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc new file mode 100644 index 000000000..88cec5f2e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc @@ -0,0 +1,21 @@ +retries = max(0, $retries); + $this->delay = max(0, $delay); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc new file mode 100644 index 000000000..c4c439021 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc @@ -0,0 +1,28 @@ +url = '/api/v2/diagnostics/table'; + $this->model_name = 'Table'; + $this->request_method_options = ['GET', 'DELETE']; + + # Set help texts + $this->get_help_text = 'Retrieves the entries in a specified table.'; + $this->delete_help_text = + 'Flushes all entries in a specified table. Please note this does not ' . + 'delete the table itself, only its entries.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc new file mode 100644 index 000000000..0adcf8993 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc @@ -0,0 +1,28 @@ +url = '/api/v2/diagnostics/tables'; + $this->model_name = 'Table'; + $this->request_method_options = ['GET']; + $this->many = true; + + # Set help texts + $this->get_help_text = + 'Retrieves the entries from all tables. For systems with a large number of tables or ' . + 'entries, it is recommended to use pagination and/or querying to limit the amount of data returned.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc new file mode 100644 index 000000000..da0fb20e9 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/freeradius/client'; + $this->model_name = 'FreeRADIUSClient'; + $this->many = false; + $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/ServicesFreeRADIUSClientsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc new file mode 100644 index 000000000..144d07f0a --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/freeradius/clients'; + $this->model_name = 'FreeRADIUSClient'; + $this->many = true; + $this->request_method_options = ['GET', 'PUT', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc new file mode 100644 index 000000000..cfe550403 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/freeradius/interface'; + $this->model_name = 'FreeRADIUSInterface'; + $this->many = false; + $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/ServicesFreeRADIUSInterfacesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc new file mode 100644 index 000000000..d37946fda --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/services/freeradius/interfaces'; + $this->model_name = 'FreeRADIUSInterface'; + $this->many = true; + $this->request_method_options = ['GET', 'PUT', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc new file mode 100644 index 000000000..c149bca18 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc @@ -0,0 +1,25 @@ +url = '/api/v2/system/timezone'; + $this->model_name = 'SystemTimezone'; + $this->request_method_options = ['GET', 'PATCH']; + $this->get_help_text = 'Reads the current system timezone.'; + $this->patch_help_text = 'Updates the system timezone.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc new file mode 100644 index 000000000..e630cd8c7 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/openvpn/client_export/config'; + $this->model_name = 'OpenVPNClientExportConfig'; + $this->request_method_options = ['GET', 'POST', 'PATCH', 'DELETE']; + $this->many = false; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc new file mode 100644 index 000000000..99026a82c --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/openvpn/client_export/configs'; + $this->model_name = 'OpenVPNClientExportConfig'; + $this->many = true; + $this->request_method_options = ['GET', 'PUT', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc new file mode 100644 index 000000000..69a112e08 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -0,0 +1,36 @@ +url = '/api/v2/vpn/openvpn/client_export'; + $this->model_name = 'OpenVPNClientExport'; + $this->request_method_options = ['POST']; + $this->encode_content_handlers = ['BinaryContentHandler', 'JSONContentHandler']; + $this->many = false; + + # Set help texts + $this->post_help_text = + "Export an OpenVPN Client configuration.\n\n" . + 'Before using this endpoint, you must define a default export configuration for your ' . + 'OpenVPN server(s) using the the endpoint at /api/v2/openvpn/vpn/client_export/config ' . + 'as you will need its ID to use this endpoint. Any specific configurations made to this ' . + 'endpoint will override the default configurations, but will not store them in the pfSense ' . + "configuration.\n\n" . + "Exports of exe, zip and other binary file types MUST use the 'application/octet-stream' accept " . + 'type as their data is not serializable.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc new file mode 100644 index 000000000..db9f20a0a --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc @@ -0,0 +1,208 @@ +server = new ForeignModelField( + model_name: 'OpenVPNServer', + model_field: 'vpnid', + model_query: ['disable' => false, 'mode__startswith' => 'server'], + required: true, + help_text: 'The VPN ID of the OpenVPN server this client export corresponds to.', + ); + $this->useaddr = new StringField( + required: false, + default: 'serveraddr', + choices: ['serveraddr', 'servermagic', 'servermagichost', 'serverhostname', 'other'], + help_text: 'The method to use for the OpenVPN server address listed in the config export.', + ); + $this->useaddr_hostname = new StringField( + required: false, + default: '', + allow_empty: true, + conditions: ['useaddr' => 'other'], + validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)], + help_text: 'The hostname to use for the OpenVPN server address.', + ); + $this->verifyservercn = new StringField( + default: 'auto', + choices: ['auto', 'none'], + help_text: 'Verify the server certificate Common Name (CN) when the client connects.', + ); + $this->blockoutsidedns = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Block access to DNS servers except across OpenVPN while connected, forcing clients to ' . + 'use only VPN DNS servers.', + ); + $this->legacy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Do not include OpenVPN 2.5 and later settings in the client configuration.', + ); + $this->silent = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Create Windows installer for unattended deploy.', + ); + $this->bindmode = new StringField( + default: 'nobind', + choices: ['nobind', 'lport0', 'bind'], + help_text: 'The port binding mode to use. If OpenVPN client binds to the default OpenVPN port (1194), ' . + 'two clients may not run concurrently.', + ); + $this->usepkcs11 = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.', + ); + $this->pkcs11providers = new StringField( + required: true, + many: true, + delimiter: ' ', + conditions: ['usepkcs11' => true], + help_text: 'The client local path to the PKCS#11 provider(s) (DLL, module)', + ); + $this->pkcs11id = new StringField( + required: true, + conditions: ['usepkcs11' => true], + help_text: 'The object\'s ID on the PKCS#11 device.', + ); + $this->usetoken = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use Microsoft Certificate Storage instead of local files.', + ); + $this->usepass = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use a password to protect the PKCS#12 file contents or key in Viscosity bundles.', + ); + $this->pass = new StringField( + required: true, + sensitive: true, + conditions: ['usepass' => true], + help_text: 'Password used to protect the certificate file contents.', + ); + $this->p12encryption = new StringField( + default: 'high', + choices: ['high', 'low', 'legacy'], + help_text: 'The level of encryption to use when exporting a PKCS#12 archive. Encryption support varies ' . + 'by Operating System and program', + ); + $this->useproxy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use proxy to communicate with the OpenVPN server.', + ); + $this->useproxytype = new StringField( + default: 'http', + choices: ['http', 'socks'], + conditions: ['useproxy' => true], + help_text: 'The proxy type to use.', + ); + $this->proxyaddr = new StringField( + required: true, + conditions: ['useproxy' => true], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_fqdn: true)], + help_text: 'The IP address or hostname of the proxy server to use.', + ); + $this->proxyport = new PortField( + required: true, + allow_alias: false, + allow_range: false, + conditions: ['useproxy' => true], + help_text: 'The port where the proxy server is listening.', + ); + $this->useproxypass = new StringField( + required: true, + choices: ['none', 'basic', 'ntlm'], + conditions: ['useproxy' => true], + help_text: 'The type of authentication to use for the proxy server.', + ); + $this->proxyuser = new StringField( + required: true, + conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], + help_text: 'The username to use to authenticate with the proxy server.', + ); + $this->proxypass = new StringField( + required: true, + sensitive: true, + conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], + help_text: 'The password to use to authenticate with the proxy server.', + ); + $this->advancedoptions = new Base64Field( + required: false, + default: '', + allow_empty: true, + help_text: 'Additional options to add to the OpenVPN client export configuration.', + ); + } + + /** + * Add extra validation to the 'legacy' field. This is used to ensure that legacy ciphers are even + * supported by the OpenVPN server. + */ + public function validate_legacy(bool $legacy): bool { + if ($legacy) { + global $legacy_incompatible_ciphers; + $settings = get_openvpnserver_by_id($this->server->value); + if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { + throw new ConflictError( + message: 'The Fallback Data Encryption Algorithm for the selected server is not compatible with ' . + 'Legacy clients.', + response_id: 'OPENVPN_CLIENT_EXPORT_CONFIG_LEGACY_CIPHER_NOT_COMPATIBLE', + ); + } + } + + return $legacy; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc new file mode 100644 index 000000000..93393476a --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc @@ -0,0 +1,194 @@ +packages = ['pfSense-pkg-freeradius3']; + $this->package_includes = ['freeradius.inc']; + $this->config_path = 'installedpackages/freeradiusclients/config'; + $this->many = true; + $this->always_apply = true; + + # Set model fields + $this->addr = new StringField( + required: true, + unique: true, + internal_name: 'varclientip', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], + help_text: 'The IP address or network of the RADIUS client(s) in CIDR notation. This is the IP of the ' . + 'NAS (switch, access point, firewall, router, etc.)', + ); + $this->ip_version = new StringField( + default: 'ipaddr', + choices: ['ipaddr', 'ipv6addr'], + internal_name: 'varclientipversion', + help_text: 'The IP version of the this Client.', + ); + $this->shortname = new StringField( + required: true, + internal_name: 'varclientshortname', + help_text: 'A short name for the client. This is generally the hostname of the NAS.', + ); + $this->secret = new StringField( + required: true, + sensitive: true, + maximum_length: 31, + internal_name: 'varclientsharedsecret', + help_text: 'This is the shared secret (password) which the NAS (switch, accesspoint, etc.) needs to ' . + 'communicate with the RADIUS server.', + ); + + $this->proto = new StringField( + default: 'udp', + choices: ['udp', 'tcp'], + internal_name: 'varclientproto', + help_text: 'The protocol the client uses.', + ); + $this->nastype = new StringField( + default: 'other', + choices: [ + 'cisco', + 'cvx', + 'computone', + 'digitro', + 'livingston', + 'juniper', + 'max40xx', + 'mikrotik', + 'mikrotik_snmp', + 'dot1x', + 'other', + ], + allow_empty: true, + internal_name: 'varclientnastype', + help_text: 'The NAS type of the client. This is used by checkrad.pl for simultaneous use checks.', + ); + $this->msgauth = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: 'no', + internal_name: 'varrequiremessageauthenticator', + help_text: 'RFC5080 requires Message-Authenticator in Access-Request. But older NAS (switches or ' . + 'accesspoints) do not include that.', + ); + $this->maxconn = new IntegerField( + default: 16, + minimum: 1, + maximum: 32, + internal_name: 'varclientmaxconnections', + help_text: 'Takes only effect if you use TCP as protocol. Limits the number of simultaneous TCP + connections from a client.', + ); + $this->naslogin = new StringField( + default: '', + allow_empty: true, + internal_name: 'varclientlogininput', + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of ' . + '(s)radutmp file and accounting. Leave empty to choose (s)radutmp.', + ); + $this->naspassword = new StringField( + default: '', + allow_empty: true, + sensitive: true, + internal_name: 'varclientpasswordinput', + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of ' . + '(s)radutmp file and accounting. Leave empty to choose (s)radutmp.', + ); + + $this->description = new StringField( + required: false, + default: '', + allow_empty: true, + validators: [ + new RegexValidator( + pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", + error_msg: 'Value contains invalid characters.', + ), + ], + help_text: 'The description for this interface.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Perform extra validation on the Model's 'addr' field. + * @param string $value The value to validate. + * @returns string The validated value. + * @throws ValidationError If the value does not match IP version specified in the 'ip_version' field. + */ + public function validate_addr(string $value): string { + # Do not allow the value to be an IPv4 address if ip_version is 'ipv6addr' + if ($this->addr->has_label('is_ipaddrv4') and $this->ip_version->value === 'ipv6addr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv4 address when `ip_version` is set to `ipv6addr`, received `$value`.", + response_id: 'FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED', + ); + } + + # Do not allow the value to be an IPv6 address if ip_version is 'ipaddr' + if ($this->addr->has_label('is_ipaddrv6') and $this->ip_version->value === 'ipaddr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv6 address when `ip_version` is set to `ipaddr`, received `$value`.", + response_id: 'FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED', + ); + } + + return $value; + } + + /** + * Perform additional validation on the Model's fields and data. + */ + /** + * Perform additional validation on the Model's fields and data. + */ + public function validate_extra(): void { + # If there were validation errors that were not caught by the model fields, throw a ValidationError. + # Ideally the Model should catch all validation errors itself so prompt the user to report this error + $input_errors = []; + freeradius_validate_clients($this->to_internal(), $input_errors); + if (!empty($input_errors)) { + throw new ValidationError( + message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . + 'https://github.com/jaredhendrickson13/pfsense-api/issues/new', + response_id: 'FREERADIUS_CLIENTS_UNEXPECTED_VALIDATION_ERROR', + ); + } + } + + /** + * Apply specific action on Client(s) + */ + public function apply(): void { + freeradius_clients_resync(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc new file mode 100644 index 000000000..0a147af73 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc @@ -0,0 +1,131 @@ +packages = ['pfSense-pkg-freeradius3']; + $this->package_includes = ['freeradius.inc']; + $this->config_path = 'installedpackages/freeradiusinterfaces/config'; + $this->many = true; + $this->always_apply = true; + $this->unique_together_fields = ['addr', 'port', 'ip_version']; + + # Set model fields + $this->addr = new StringField( + required: true, + internal_name: 'varinterfaceip', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_keywords: ['*'])], + help_text: 'The IP address of the listening interface. If you choose * then it means all interfaces.', + ); + $this->port = new PortField( + required: false, + default: '1812', + allow_alias: false, + allow_range: false, + internal_name: 'varinterfaceport', + help_text: 'The port number of the listening interface. Different interface types need different ports.', + ); + $this->ip_version = new StringField( + required: true, + choices: ['ipaddr', 'ipv6addr'], + internal_name: 'varinterfaceipversion', + help_text: 'The IP version of the listening interface.', + ); + $this->type = new StringField( + required: false, + default: 'auth', + choices: ['auth', 'acct', 'proxy', 'detail', 'status', 'coa'], + internal_name: 'varinterfacetype', + help_text: 'The type of the listening interface: Authentication/Accounting.', + ); + $this->description = new StringField( + required: false, + default: '', + allow_empty: true, + validators: [ + new RegexValidator( + pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", + error_msg: 'Value contains invalid characters.', + ), + ], + help_text: 'The description for this interface.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Perform extra validation on the Model's 'addr' field. + * @param string $value The value to validate. + * @returns string The validated value. + * @throws ValidationError If the value does not match IP version specified in the 'ip_version' field. + */ + public function validate_addr(string $value): string { + # Asterisk (*) is always a valid value for the addr field, so return it without further validation + if ($value === '*') { + return $value; + } + + # Do not allow the value to be an IPv4 address if ip_version is 'ipv6addr' + if ($this->addr->has_label('is_ipaddrv4') and $this->ip_version->value === 'ipv6addr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv4 address when `ip_version` is set to `ipv6addr`, received `$value`.", + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV4_NOT_ALLOWED', + ); + } + + # Do not allow the value to be an IPv6 address if ip_version is 'ipaddr' + if ($this->addr->has_label('is_ipaddrv6') and $this->ip_version->value === 'ipaddr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv6 address when `ip_version` is set to `ipaddr`, received `$value`.", + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV6_NOT_ALLOWED', + ); + } + + return $value; + } + + /** + * Perform additional validation on the Model's fields and data. + */ + public function validate_extra(): void { + # If there were validation errors that were not caught by the model fields, throw a ValidationError. + # Ideally the Model should catch all validation errors itself so prompt the user to report this error + $input_errors = []; + freeradius_validate_interfaces($this->to_internal(), $input_errors); + if (!empty($input_errors)) { + throw new ValidationError( + message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . + 'https://github.com/jaredhendrickson13/pfsense-api/issues/new', + response_id: 'FREERADIUS_INTERFACE_UNEXPECTED_VALIDATION_ERROR', + ); + } + } + + /** + * Apply the action on Interface(s) + */ + public function apply(): void { + freeradius_settings_resync(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc index 8c845982a..5a87b576d 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc @@ -43,6 +43,7 @@ class InterfaceVLAN extends Model { required: false, default: '', allow_empty: true, + read_only: true, help_text: 'Displays the full interface VLAN. This value is automatically populated and cannot be set.', ); $this->pcp = new IntegerField( diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc new file mode 100644 index 000000000..60506b8ec --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc @@ -0,0 +1,399 @@ +config_path = 'installedpackages/vpn_openvpn_export/serverconfig/item'; + $this->packages = ['pfSense-pkg-openvpn-client-export']; + $this->package_includes = ['openvpn-client-export.inc']; + $this->auto_create_id = false; // Create is used to initiate the export process, not creating new objects + $this->many = true; + + # Setup shared fields defined in OpenVPNClientExportTraits + $this->__setup_fields(); + + # Set fields specific to this model + $this->type = new StringField( + required: true, + choices: [ + 'confzip', + 'conf_yealink_t28', + 'conf_yealink_t38g', + 'conf_yealink_t38g2', + 'conf_snom', + 'confinline', + 'confinlinedroid', + 'confinlineconnect', + 'confinlinevisc', + 'inst-Win7', + 'inst-Win10', + 'inst-x86-previous', + 'inst-x64-previous', + 'inst-x86-current', + 'inst-x64-current', + 'visc', + ], + help_text: 'The type of OpenVPN client export to generate. This determines the format and content of ' . + 'the export file.', + ); + $this->username = new ForeignModelField( + model_name: 'User', + model_field: 'name', + default: null, + allow_null: true, + help_text: 'The username of the user this client export corresponds to. This is only applicable ' . + 'for OpenVPN servers that use the Local Database AND client certificates.', + ); + $this->certref = new ForeignModelField( + model_name: 'Certificate', + model_field: 'refid', + default: null, + allow_null: true, + help_text: 'The reference ID of the certificate to use for this OpenVPN client export. This is only ' . + 'applicable for OpenVPN servers that require client certificates.', + ); + $this->filename = new StringField( + required: false, + default: null, + allow_null: true, + read_only: true, + help_text: 'The filename used when exporting the OpenVPN client export. This value cannot be changed', + ); + $this->binary_data = new StringField( + required: false, + default: '', + allow_empty: true, + read_only: true, + help_text: 'The binary data of the OpenVPN client export. This is used to store the actual ' . + 'exported configuration file content. When the content-type is set to "application/octet-stream", ' . + 'this field will contain the data of the OpenVPN client export download.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Performs extra validation on the server field. This is primarily intended to ensure that the requested + * export parameters are compatible with the OpenVPN server settings. + * @param int $server The OpenVPN server ID to validate. + * @return int The validated OpenVPN server ID. + * @throws ValidationError If the server mode requires a certificate reference but none is provided, + * @throws ValidationError If the server mode requires a username but none is provided. + */ + public function validate_server(int $server): int { + # Obtain the server object + $server_obj = $this->server->get_related_model(); + + # If this server supports client certs, require certref to be set. + if (in_array($server_obj->mode->value, ['server_tls', 'server_tls_user']) and !$this->certref->value) { + throw new ValidationError( + message: "Field 'certref' is required for OpenVPN server mode '{$server_obj->mode->value}'.", + response_id: 'OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED', + ); + } + + # Require a username to be set if the server is only using the Local Database for authentication. + if ($server_obj->authmode->value === ['Local Database'] and !$this->username->value) { + throw new ValidationError( + message: "Field 'username' is required for OpenVPN server mode '{$server_obj->mode->value}' " . + "with authentication mode 'Local Database'.", + response_id: 'OPENVPN_CLIENT_EXPORT_USERNAME_REQUIRED', + ); + } + + return $server; + } + + /** + * Perform extra validation to the type field. This is primarily used to ensure that certain parameters are set + * or not set when a specific type of export is requested. + * @param string $type The type of OpenVPN client export to validate. + * @return string The validated type of OpenVPN client export. + * @throws ValidationError If the type is not compatible with the 'usetoken' field. + */ + public function validate_type(string $type): string { + # Do not allow confinline types to have 'usetoken' enabled + if (in_array($type, self::USETOKEN_FORBIDDEN_TYPES) and $this->usetoken->value) { + throw new ConflictError( + message: "Field 'usetoken' cannot be enabled for OpenVPN client export type '$type'.", + response_id: 'OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED', + ); + } + + return $type; + } + + /** + * Perform extra validation to the certref field. This is primarily intended to ensure the specified user is the + * owner of the certificate being referenced. + * @param string $certref The certificate reference ID to validate. + * @return string The validated certificate reference ID. + * @throws NotFoundError If the specified certificate reference ID does not belong to the user. + * @throws NotAcceptableError If the certificate does not have a private key associated with it and is required + */ + public function validate_certref(string $certref): string { + # Skip this validation if no username is specified. + if (!$this->username->value) { + return $certref; + } + + # Ensure the certref is listed in the user's certificates. + if (!in_array($certref, $this->username->get_related_model()->cert->value)) { + throw new NotFoundError( + message: "User '{$this->username->value}' is not assigned a certificate with reference ID '$certref'.", + response_id: 'OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND', + ); + } + + # Do not allow the referenced cert to not have a private key unless usepkcs11 or usetoken is set + $cert_obj = $this->certref->get_related_model(); + if (!$cert_obj->prv->value and (!$this->usepkcs11->value or !$this->usetoken->value)) { + throw new ValidationError( + message: "Certificate with reference ID '$certref' does not have a private key. " . + 'Ensure the certificate is valid and has a private key.', + response_id: 'OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY', + ); + } + + return $certref; + } + + /** + * Generates the OpenVPN client export configuration file based on the provided parameters. + * @throws ServerError If the export fails for an unknown reason + */ + protected function _create(): void { + global $input_errors; + + # Generate a the file export based on the export type + $export_data = null; + if (str_starts_with($this->type->value, 'conf')) { + $export_data = $this->export_config(); + } + if (str_starts_with($this->type->value, 'inst-')) { + $export_data = $this->export_installer(); + } + if ($this->type->value === 'visc') { + $export_data = $this->export_viscosity(); + } + + # If import errors were found during the export, raise an error + if ($input_errors) { + throw new ServerError( + message: "The OpenVPN client export failed for the following reason: $input_errors[0]", + response_id: 'OPENVPN_CLIENT_EXPORT_CREATION_FAILED_FOR_KNOWN_REASON', + ); + } + + # If no valid filepath was given after generating, we know the export failed. Throw an error. + if (!$export_data) { + throw new ServerError( + message: 'The OpenVPN client export could not be created for unknown reasons.', + response_id: 'OPENVPN_CLIENT_EXPORT_CREATION_FAILED_FOR_UNKNOWN_REASON', + ); + } + + # When the export data is a filepath, set the binary data to its contents + if (is_file($export_data)) { + $this->filename->value = $this->__get_export_filename(); + $this->binary_data->value = file_get_contents($export_data); + @unlink($export_data); + return; + } + + # Otherwise, just use the value directly + $this->binary_data->value = $export_data; + } + + /** + * Generates the OpenVPN client export configuration file based on this objects properties. + * @return string|null The filepath to the generate OpenVPN client export configuration file, or null if the + * export failed. + * @note If a confinline type is used, the configuration file will be returned as a string instead of a filepath + */ + public function export_config(): string|null { + return openvpn_client_export_config( + srvid: $this->server->value, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, + useaddr: $this->useaddr_hostname->value ?? $this->useaddr->value, + verifyservercn: $this->verifyservercn->value, + blockoutsidedns: $this->blockoutsidedns->value, + legacy: $this->legacy->value, + bindmode: $this->bindmode->value, + usetoken: $this->usetoken->value, + nokeys: $this->server->get_related_model()->mode->value === 'server_user', + proxy: $this->__get_proxy_config(), + expformat: str_replace(['conf_', 'conf'], '', $this->type->value), + outpass: $this->pass->value, + p12encryption: $this->p12encryption->value, + skiptls: false, + doslines: false, + advancedoptions: $this->advancedoptions->value, + usepkcs11: $this->usepkcs11->value, + pkcs11providers: $this->pkcs11providers->value, + pkcs11id: $this->pkcs11id->value, + ); + } + + /** + * Exports the OpenVPN client export installer file based on the provided parameters. + * @return string|null The filepath to the generated OpenVPN client export installer file, or null if the + * export failed. + */ + public function export_installer(): string|null { + # Ensure legacy incompatible ciphers are always an array + global $legacy_incompatible_ciphers; + $legacy_incompatible_ciphers = $legacy_incompatible_ciphers ?? []; + + return openvpn_client_export_installer( + srvid: $this->server->value, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, + useaddr: $this->useaddr_hostnam->value ?? $this->useaddr->value, + verifyservercn: $this->verifyservercn->value, + blockoutsidedns: $this->blockoutsidedns->value, + legacy: $this->legacy->value, + bindmode: $this->bindmode->value, + usetoken: $this->usetoken->value, + outpass: $this->pass->value, + p12encryption: $this->p12encryption->value, + proxy: $this->__get_proxy_config(), + advancedoptions: $this->advancedoptions->value, + openvpn_version: substr($this->type->value, 5), + usepkcs11: $this->usepkcs11->value, + pkcs11providers: $this->pkcs11providers->value, + pkcs11id: $this->pkcs11id->value, + silent: $this->silent->value, + ); + } + + /** + * Exports the OpenVPN client export configuration file for Viscosity based on the provided parameters. + * @return string|null The filepath to the generated OpenVPN client export configuration file for Viscosity, + * or null if the export failed. + */ + public function export_viscosity(): string|null { + return viscosity_openvpn_client_config_exporter( + srvid: $this->server->value, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, + useaddr: $this->useaddr_hostname->value ?? $this->useaddr->value, + verifyservercn: $this->verifyservercn->value, + blockoutsidedns: $this->blockoutsidedns->value, + legacy: $this->legacy->value, + bindmode: $this->bindmode->value, + usetoken: $this->usetoken->value, + outpass: $this->pass->value, + p12encryption: $this->p12encryption->value, + proxy: $this->__get_proxy_config(), + advancedoptions: $this->advancedoptions->value, + usepkcs11: $this->usepkcs11->value, + pkcs11providers: $this->pkcs11providers->value, + pkcs11id: $this->pkcs11id->value, + ); + } + + /** + * Obtains the proxy configuration array expected by pfSense functions. + * @return array the proxy configuration array expected by pfSense functions + */ + private function __get_proxy_config(): array { + return [ + 'ip' => $this->proxyaddr->value, + 'port' => $this->proxyport->value, + 'user' => $this->proxyuser->value, + 'password' => $this->proxypass->value, + 'proxy_type' => $this->useproxytype->value, + 'proxy_authtype' => $this->useproxypass->value, + ]; + } + + private function __get_export_filename(): string { + global $current_openvpn_version, $current_openvpn_version_rev; + global $previous_openvpn_version, $previous_openvpn_version_rev; + global $legacy_openvpn_version, $legacy_openvpn_version_rev; + + # Obtain the filename prefix + $filename_prefix = openvpn_client_export_prefix( + srvid: $this->server->value, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, + ); + + # Determine the conf file suffix based on the export type + if (str_starts_with($this->type->value, 'conf')) { + $filename_suffix = match ($this->type->value) { + 'confzip' => '-config.zip', + 'conf_yealink_t38g', 'conf_yealink_t28', 'conf_yealink_t38g2' => 'client.tar', + 'conf_snom' => 'vpnclient.tar', + 'confinlinedroid' => '-android-config.ovpn', + 'confinlineconnect' => '-connect-config.ovpn', + 'confinlinevisc' => '-viscosity-config.ovpn', + default => '-config.ovpn', + }; + } + # Determine the installer file suffix based on the export type + elseif (str_starts_with($this->type->value, 'inst-')) { + $filename_suffix = match ($this->type->value) { + 'inst-Win7' => "$legacy_openvpn_version}-I$legacy_openvpn_version_rev-Win7.exe", + 'inst-Win10' => "$legacy_openvpn_version-I$legacy_openvpn_version_rev-Win10.exe", + 'inst-x86-previous' => "$previous_openvpn_version-I$previous_openvpn_version_rev-x86.exe", + 'inst-x64-previous' => "$previous_openvpn_version-I$previous_openvpn_version_rev-amd64.exe", + 'inst-x86-current' => "$current_openvpn_version-I$current_openvpn_version_rev-x86.exe", + default => "$current_openvpn_version-I$current_openvpn_version_rev-amd64.exe", + }; + } + # If the type is not recognized, throw an error + else { + throw new ServerError( + message: 'Could not determine appropriate file name.', + response_id: 'OPENVPN_CLIENT_EXPORT_FILENAME_NOT_DETERMINED', + ); + } + + return urlencode($filename_prefix . $filename_suffix); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc new file mode 100644 index 000000000..0e8ecb411 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc @@ -0,0 +1,32 @@ +config_path = 'installedpackages/vpn_openvpn_export/serverconfig/item'; + $this->packages = ['pfSense-pkg-openvpn-client-export']; + $this->package_includes = ['openvpn-client-export.inc']; + $this->always_apply = true; + $this->many = true; + + # Setup shared fields defined in OpenVPNClientExportTraits + $this->__setup_fields(); + + parent::__construct($id, $parent_id, $data, ...$options); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc new file mode 100644 index 000000000..c4291ac78 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc @@ -0,0 +1,47 @@ +config_path = 'system'; + $this->update_strategy = 'merge'; + $this->always_apply = true; + + # Set model Fields + $this->timezone = new StringField( + default: 'UTC', + choices_callable: 'get_timezone_choices', + allow_empty: false, + allow_null: false, + many: false, + help_text: 'Set geographic region name (Continent/Location) to determine the timezone for the firewall.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Apply these Timezone changes to the system. + */ + public function apply() { + system_timezone_configure(); + } + + /** + * Obtains the list of available timezones. + * @return array The list of timezones available to the system + */ + public function get_timezone_choices(): array { + return system_get_timezone_list(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc new file mode 100644 index 000000000..8df2b4064 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc @@ -0,0 +1,69 @@ +internal_callable = 'get_tables'; + $this->id_type = 'string'; + $this->many = true; + + $this->entries = new StringField(many: true, delimiter: ' ', help_text: 'The entries currently in the table.'); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Retrieves the list of available tables from the pfctl command. + * @return array The list of available table names. + */ + public function get_available_table_names(): array { + $table_names_ouptput = new Command('/sbin/pfctl -sT'); + return explode("\n", $table_names_ouptput->output); + } + + /** + * Obtains the auth log as an array. This method is the internal callable for this Model. + * @return array The auth log as an array of objects. + */ + protected function get_tables(): array { + $tables = []; + + # Loop through each table and expand its entries + foreach ($this->get_available_table_names() as $table_name) { + # Get the entries for the table + $table_entries_output = new Command( + '/sbin/pfctl -t ' . escapeshellarg($table_name) . ' -T show', + trim_whitespace: true, + ); + $tables[$table_name] = ['entries' => trim($table_entries_output->output)]; + } + + return $tables; + } + + /** + * Overrides the default _delete method to flush the table entries instead. + */ + protected function _delete(): void { + # Flush the table entries using pfctl + $flush_command = new Command('/sbin/pfctl -t ' . escapeshellarg($this->id) . ' -T flush'); + if ($flush_command->result_code !== 0) { + throw new ServerError( + message: 'Failed to flush table entries for ' . $this->id . ': ' . $flush_command->output, + response_id: 'TABLE_FLUSH_FAILED', + ); + } + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc new file mode 100644 index 000000000..75efcf1ac --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc @@ -0,0 +1,148 @@ + $field->name, + 'class' => $field->get_class_shortname(), + 'verbose_name' => $field->verbose_name, + 'verbose_name_plural' => $field->verbose_name_plural, + 'type' => $field->type, + 'required' => $field->required, + 'default' => $field->default, + 'choices' => $field->choices, + 'unique' => $field->unique, + 'allow_empty' => $field->allow_empty, + 'allow_null' => $field->allow_null, + 'editable' => $field->editable, + 'sensitive' => $field->sensitive, + 'read_only' => $field->read_only, + 'write_only' => $field->write_only, + 'many' => $field->many, + 'many_minimum' => $field->many_minimum, + 'many_maximum' => $field->many_maximum, + 'minimum' => property_exists($field, 'minimum') ? $field->minimum : null, + 'maximum' => property_exists($field, 'maximum') ? $field->maximum : null, + 'minimum_length' => property_exists($field, 'minimum_length') ? $field->minimum_length : null, + 'maximum_length' => property_exists($field, 'maximum_length') ? $field->maximum_length : null, + 'internal_name' => $field->internal_name, + 'internal_namespace' => $field->internal_namespace, + 'referenced_by' => $field->referenced_by, + 'nested_model_class' => $field instanceof NestedModelField ? $field->model->get_class_shortname() : null, + 'foreign_model_class' => $field instanceof ForeignModelField ? $field->model_name : null, + 'foreign_model_field' => $field instanceof ForeignModelField ? $field->model_field : null, + 'conditions' => $field->conditions, + 'help_text' => $field->help_text, + ]; + } + + /** + * Extracts all applicable metadata from a given model object + * @param Model $model The model object to extract metadata from + * @return array An associative array containing metadata about the model. + */ + public function model_to_metadata(Model $model): array { + # Set base metadata + $metadata = [ + 'class' => $model->get_class_shortname(), + 'id_type' => $model->many ? $model->id_type : null, + 'parent_model_class' => $model->parent_model_class ?? null, + 'parent_id_type' => $model->parent_model_class ? $model->parent_id_type : null, + 'verbose_name' => $model->verbose_name, + 'verbose_name_plural' => $model->verbose_name_plural, + 'many' => $model->many, + 'many_minimum' => $model->many_minimum, + 'many_maximum' => $model->many_maximum, + 'packages' => $model->packages, + 'unique_together_fields' => $model->unique_together_fields, + 'always_apply' => $model->always_apply, + 'subsystem' => $model->subsystem, + 'fields' => [], + ]; + + # Convert each field to metadata + foreach ($model->get_fields() as $field_name) { + $metadata['fields'][$field_name] = $this->field_to_metadata($model->$field_name); + } + + return $metadata; + } + + /** + * Extracts all applicable metadata from a given endpoint object + * @param Endpoint $endpoint The endpoint object to extract metadata from + * @return array An associative array containing metadata about the endpoint. + */ + public function endpoint_to_metadata(Endpoint $endpoint): array { + # Set base metadata + return [ + 'url' => $endpoint->url, + 'class' => $endpoint->get_class_shortname(), + 'model_class' => $endpoint->model_name, + 'tag' => $endpoint->tag, + 'deprecated' => $endpoint->deprecated, + 'many' => $endpoint->many, + 'request_method_options' => $endpoint->request_method_options, + 'requires_auth' => $endpoint->requires_auth, + 'auth_methods' => $endpoint->auth_methods, + 'get_privileges' => $endpoint->get_privileges, + 'post_privileges' => $endpoint->post_privileges, + 'patch_privileges' => $endpoint->patch_privileges, + 'put_privileges' => $endpoint->put_privileges, + 'delete_privileges' => $endpoint->delete_privileges, + 'get_help_text' => $endpoint->get_help_text, + 'post_help_text' => $endpoint->post_help_text, + 'patch_help_text' => $endpoint->patch_help_text, + 'put_help_text' => $endpoint->put_help_text, + 'delete_help_text' => $endpoint->delete_help_text, + ]; + } + + /** + * Obtains the full schema string for this Schema class in JSON format + * @return string The full schema string for this Schema class in JSON format + */ + public function get_schema_str(): string { + $schema = ['endpoints' => [], 'models' => []]; + + # Get all endpoint metadata + foreach (get_classes_from_namespace('RESTAPI\Endpoints') as $endpoint_class) { + $endpoint = new $endpoint_class(); + $schema['endpoints'][$endpoint->url] = $this->endpoint_to_metadata($endpoint); + } + + # Get all model metadata + foreach (get_classes_from_namespace('RESTAPI\Models') as $model_class) { + $model = new $model_class(skip_init: true); + $schema['models'][$model->get_class_shortname()] = $this->model_to_metadata($model); + } + + return json_encode($schema); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc index ef3205772..bf3273a0a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc @@ -86,12 +86,11 @@ class APIModelsCertificateRenewTestCase extends TestCase { } /** - * Ensure when we renew the Certificate while reusing the key and serial, both the certificate and + * Ensure when we renew the Certificate while reusing the key and serial, both the serial and * the key remain the same. */ public function test_renew_certificate_reuse(): void { # Before we renew, obtain the existing CA cert - $old_cert = $this->cert->crt->value; $old_key = $this->cert->prv->value; # Renew the Certificate @@ -107,8 +106,7 @@ class APIModelsCertificateRenewTestCase extends TestCase { # Refresh our CA object $this->cert = Certificate::query(refid: $this->cert->refid->value)->first(); - # Ensure the certificate, key and serial are the same - $this->assert_equals($old_cert, $this->cert->crt->value); + # Ensure the key and serial are the same $this->assert_equals($old_key, $this->cert->prv->value); $this->assert_equals($renew->oldserial->value, $renew->newserial->value); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 47ed22120..5135ec1a1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -5,14 +5,58 @@ namespace RESTAPI\Tests; use RESTAPI\Core\Auth; use RESTAPI\Core\Command; use RESTAPI\Core\TestCase; +use RESTAPI\Core\TestCaseRetry; use RESTAPI\Models\FirewallRule; use RESTAPI\Models\FirewallSchedule; use RESTAPI\Models\RoutingGateway; use RESTAPI\Models\TrafficShaper; use RESTAPI\Models\TrafficShaperLimiter; use RESTAPI\Models\TrafficShaperLimiterQueue; +use RESTAPI\Responses\ServerError; class APIModelsFirewallRuleTestCase extends TestCase { + /** + * Reads the active ruleset directly from pfctl. If pfctl is not ready, it will retry up to 5 times before + * throwing an error. + * @param string|null $needle Optional string to search for in the output before returning + * @return Command The Command object containing the pfctl output + */ + public function read_pfctl_rules(?string $needle = null): Command { + # Keywords that indicate pf is not ready yet + $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy', 'pfctl: DIOCGETRULENV: Device busy']; + + # Check the pf ruleset until it appears to be fully loaded, or until we've tried 5 times + $attempt = 0; + $max_attempts = 5; + while ($attempt < $max_attempts) { + $cmd = new Command('/sbin/pfctl -sr'); + $ready = true; + + foreach ($not_ready_keywords as $keyword) { + # pf is not ready if any of the keywords are found in the output + if (str_contains($cmd->output, $keyword)) { + $ready = false; + break; + } + } + + # If pfctl is ready and either no needle was specified or the needle was found, return the output + if ($ready and (!$needle or str_contains($cmd->output, $needle))) { + sleep(1); + return $cmd; + } + + # Otherwise, wait a second and try again + sleep(1); + $attempt++; + } + + throw new ServerError( + message: "pfctl ruleset was not ready after $max_attempts attempts.", + response_id: 'API_MODELS_FIREWALL_RULE_TEST_CASE_PFCTL_NOT_READY', + ); + } + /** * Checks that multiple interfaces cannot be assigned to a FirewallRule unless `floating` is enabled. */ @@ -474,6 +518,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { scheduler: 'FAIRQ', bandwidth: 100, bandwidthtype: 'Mb', + async: false, queue: [ [ 'name' => 'TestQueue1', @@ -508,20 +553,20 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the rule with the queue is seen in pfctl - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains($pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue1'); # Update the rule to use a different queue and ensure it is seen in pfctl $rule->defaultqueue->value = 'TestQueue2'; $rule->update(apply: true); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains($pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue2'); # Delete the rule and ensure the rule referencing the queue no longer exists $rule->delete(); $shaper1->delete(); $rule->apply(); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_does_not_contain( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue1', @@ -542,6 +587,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { scheduler: 'FAIRQ', bandwidth: 100, bandwidthtype: 'Mb', + async: false, queue: [ [ 'name' => 'TestQueue1', @@ -577,7 +623,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the rule with the queue is seen in pfctl - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue1, TestQueue2)', @@ -587,7 +633,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->defaultqueue->value = 'TestQueue2'; $rule->ackqueue->value = 'TestQueue1'; $rule->update(apply: true); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue2, TestQueue1)', @@ -597,7 +643,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->delete(); $shaper1->delete(); $rule->apply(); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_does_not_contain( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue1, TestQueue2)', @@ -772,6 +818,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { /** * Checks that a rule with a TrafficShaperLimiter object assigned to dnpipe is correctly represented in pfctl. */ + #[TestCaseRetry(retries: 3, delay: 2)] public function test_dnpipe_as_limiter_in_pfctl(): void { # Create a limiter and rule to test with $limiter = new TrafficShaperLimiter( @@ -796,8 +843,8 @@ class APIModelsFirewallRuleTestCase extends TestCase { ); $rule->create(apply: true); - # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = new Command('pfctl -sr'); + # Ensure the dnpipe is correctly represented + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe {$limiter->number->value}", @@ -844,7 +891,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnqueue {$queue->number->value}", @@ -894,7 +941,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe({$limiter1->number->value}, {$limiter2->number->value})", diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc new file mode 100644 index 000000000..ff7a9a6bf --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc @@ -0,0 +1,120 @@ +create(); + $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); + $this->assert_str_contains($raddb, 'client "testclient" {'); + $this->assert_str_contains($raddb, 'ipaddr = 1.2.3.4'); + $this->assert_str_contains($raddb, 'proto = udp'); + $this->assert_str_contains($raddb, 'nas_type = dot1x'); + $this->assert_str_contains($raddb, 'login = testlogin'); + $this->assert_str_contains($raddb, 'password = testpassword'); + $this->assert_str_contains($raddb, 'require_message_authenticator = no'); + $this->assert_str_contains($raddb, 'max_connections = 16'); + + # Ensure we can read the created user from the config + $read_client = new FreeRADIUSClient(id: $client->id); + $this->assert_equals($read_client->addr->value, '1.2.3.4'); + $this->assert_equals($read_client->ip_version->value, 'ipaddr'); + $this->assert_equals($read_client->shortname->value, 'testclient'); + $this->assert_str_contains($read_client->proto->value, 'udp'); + $this->assert_str_contains($read_client->nastype->value, 'dot1x'); + $this->assert_equals($read_client->msgauth->value, false); + $this->assert_equals($read_client->maxconn->value, 16); + $this->assert_equals($read_client->naslogin->value, 'testlogin'); + $this->assert_equals($read_client->naspassword->value, 'testpassword'); + $this->assert_equals($read_client->description->value, 'Test client'); + + # Ensure we can update the user + $client = new FreeRADIUSClient( + id: $read_client->id, + addr: '4321::1', + ip_version: 'ipv6addr', + shortname: 'newtestclient', + secret: 'newtestsecret', + proto: 'tcp', + nastype: 'cisco', + msgauth: true, + maxconn: 32, + naslogin: 'newtestlogin', + naspassword: 'newtestpassword', + description: 'New test client', + ); + $client->update(); + $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); + $this->assert_str_does_not_contain($raddb, 'client "testclient" {'); + $this->assert_str_contains($raddb, 'client "newtestclient" {'); + $this->assert_str_contains($raddb, 'ipv6addr = 4321::1'); + $this->assert_str_contains($raddb, 'proto = tcp'); + $this->assert_str_contains($raddb, 'nas_type = cisco'); + $this->assert_str_contains($raddb, 'login = newtestlogin'); + $this->assert_str_contains($raddb, 'password = newtestpassword'); + $this->assert_str_contains($raddb, 'require_message_authenticator = yes'); + $this->assert_str_contains($raddb, 'max_connections = 32'); + + # Delete the user and ensure it is removed from the database + $client->delete(); + $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); + $this->assert_str_does_not_contain($raddb, 'client "newtestclient" {'); + } + + /** + * Checks that an error is thrown if the ip_version does not match the value provided + * in the addr field. + */ + public function test_ip_version_mismatch(): void { + $this->assert_throws_response( + response_id: 'FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED', + code: 400, + callable: function () { + $client = new FreeRADIUSClient(addr: '1.2.3.4', ip_version: 'ipv6addr'); + $client->addr->validate(); # Validate to so labels are populated + $client->validate_addr('1.2.3.4'); + }, + ); + $this->assert_throws_response( + response_id: 'FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED', + code: 400, + callable: function () { + $client = new FreeRADIUSClient(addr: '1234::1', ip_version: 'ipaddr'); + $client->addr->validate(); # Validate to so labels are populated + $client->validate_addr('1234::1'); + }, + ); + + # Ensure * is always allowed + $this->assert_does_not_throw( + callable: function () { + $client = new FreeRADIUSClient(ip_version: 'ipaddr'); + $client->validate_addr('*'); + $client = new FreeRADIUSClient(ip_version: 'ipv6addr'); + $client->validate_addr('*'); + }, + ); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc new file mode 100644 index 000000000..13ab4ffff --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc @@ -0,0 +1,95 @@ +create(); + $raddb = file_get_contents('/usr/local/etc/raddb/sites-enabled/default'); + $this->assert_str_contains($raddb, 'type = auth'); + $this->assert_str_contains($raddb, 'ipv6addr = *'); + $this->assert_str_contains($raddb, 'port = 1812'); + + # Ensure we can read the created user from the config + $read_fr_interface = new FreeRADIUSInterface(id: $fr_interface->id); + $this->assert_equals($read_fr_interface->addr->value, '*'); + $this->assert_equals($read_fr_interface->port->value, '1812'); + $this->assert_equals($read_fr_interface->type->value, 'auth'); + $this->assert_equals($read_fr_interface->ip_version->value, 'ipv6addr'); + $this->assert_equals($read_fr_interface->description->value, 'Test interface'); + + # Ensure we can update the user + $fr_interface = new FreeRADIUSInterface( + id: $fr_interface->id, + addr: '127.0.0.1', + port: '1813', + type: 'acct', + ip_version: 'ipaddr', + description: 'New test interface', + ); + $fr_interface->update(); + $raddb = file_get_contents('/usr/local/etc/raddb/sites-enabled/default'); + $this->assert_str_does_not_contain($raddb, 'type = auth'); + $this->assert_str_does_not_contain($raddb, 'ipv6addr = *'); + $this->assert_str_does_not_contain($raddb, 'port = 1812'); + $this->assert_str_contains($raddb, 'type = acct'); + $this->assert_str_contains($raddb, 'ipaddr = 127.0.0.1'); + $this->assert_str_contains($raddb, 'port = 1813'); + + # Delete the user and ensure it is removed from the database + $fr_interface->delete(); + $raddb = file_get_contents('/usr/local/etc/raddb/sites-enabled/default'); + $this->assert_str_does_not_contain($raddb, 'listen {'); + } + + /** + * Checks that an error is thrown if the ip_version does not match the value provided + * in the addr field. + */ + public function test_ip_version_mismatch(): void { + $this->assert_throws_response( + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV4_NOT_ALLOWED', + code: 400, + callable: function () { + $fr_interface = new FreeRADIUSInterface(addr: '1.2.3.4', ip_version: 'ipv6addr'); + $fr_interface->addr->validate(); # Validate to so labels are populated + $fr_interface->validate_addr('1.2.3.4'); + }, + ); + $this->assert_throws_response( + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV6_NOT_ALLOWED', + code: 400, + callable: function () { + $fr_interface = new FreeRADIUSInterface(addr: '1234::1', ip_version: 'ipaddr'); + $fr_interface->addr->validate(); # Validate to so labels are populated + $fr_interface->validate_addr('1234::1'); + }, + ); + + # Ensure * is always allowed + $this->assert_does_not_throw( + callable: function () { + $fr_interface = new FreeRADIUSInterface(ip_version: 'ipaddr'); + $fr_interface->validate_addr('*'); + $fr_interface = new FreeRADIUSInterface(ip_version: 'ipv6addr'); + $fr_interface->validate_addr('*'); + }, + ); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc new file mode 100644 index 000000000..5ec1858ac --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc @@ -0,0 +1,444 @@ +server_ca = new CertificateAuthority( + descr: 'test_ca', + crt: file_get_contents('/usr/local/pkg/RESTAPI/Tests/assets/test_x509_certificate.crt'), + prv: file_get_contents('/usr/local/pkg/RESTAPI/Tests/assets/test_x509_rsa.key'), + ); + $this->server_ca->create(); + + # Create a new server certificate we can test with + $this->server_cert = new CertificateGenerate( + descr: 'test_user_cert', + caref: $this->server_ca->refid->value, + keytype: 'RSA', + keylen: 2048, + digest_alg: 'sha256', + dn_commonname: 'ovpns', + type: 'server', + ); + $this->server_cert->create(); + + # Create a new user certificate we can test with + $this->user_cert = new CertificateGenerate( + descr: 'test_user_cert', + caref: $this->server_ca->refid->value, + keytype: 'RSA', + keylen: 2048, + digest_alg: 'sha256', + dn_commonname: 'ovpntest', + type: 'user', + ); + $this->user_cert->create(); + + # Create a user we can use for testing + $this->user = new User(name: 'ovpntest', password: 'ovpntest', cert: [$this->user_cert->refid->value]); + $this->user->create(); + + # Create a remote auth server to use for testing + $this->authserver = new AuthServer( + type: 'radius', + name: 'TEST_RADIUS', + host: 'radius.example.com', + radius_auth_port: '1812', + radius_acct_port: '1813', + radius_secret: 'secret', + radius_protocol: 'MSCHAPv2', + radius_timeout: 30, + radius_nasip_attribute: 'wan', + ); + $this->authserver->create(); + + # Create a new OpenVPNServer model object + $this->ovpns = new OpenVPNServer( + mode: 'server_tls', + dev_mode: 'tun', + protocol: 'UDP4', + interface: 'wan', + use_tls: true, + tls: file_get_contents('/usr/local/pkg/RESTAPI/Tests/assets/test_openvpn_tls.key'), + tls_type: 'auth', + dh_length: '2048', + ecdh_curve: 'none', + data_ciphers: ['AES-256-GCM'], + data_ciphers_fallback: 'AES-256-GCM', + digest: 'SHA256', + caref: $this->server_ca->refid->value, + certref: $this->server_cert->refid->value, + async: true, + ); + $this->ovpns->create(apply: true); + $this->ovpns->reload_config(); + + # Create a client export config for the server + $this->ovpnce = new OpenVPNClientExportConfig(server: $this->ovpns->vpnid->value); + $this->ovpnce->create(); + } + + /** + * Remove the CA and cert used for testing after tests complete. + */ + public function teardown(): void { + $this->user->delete(); + $this->authserver->delete(); + $this->ovpns->delete(apply: true); + $this->user_cert->delete(); + $this->server_cert->delete(); + $this->server_ca->delete(); + } + + /** + * Ensure the 'certref' field is required for OpenVPNServers using the 'server_tls' mode + */ + public function test_certref_required_for_server_tls(): void { + # Ensure the server_tls server mode requires a certref for export + $this->assert_throws_response( + response_id: 'OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED', + code: 400, + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = 'server_tls'; + $this->ovpns->update(); + + # Ensure the validate_server method throws an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id); + $export->certref->value = null; + $export->validate_server($this->ovpns->vpnid->value); + }, + ); + } + + /** + * Ensure the 'certref' field is required for OpenVPNServers using the 'server_tls_user' mode + */ + public function test_certref_required_for_server_tls_user(): void { + # Ensure the server_tls_user server mode requires a certref for export + $this->assert_throws_response( + response_id: 'OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED', + code: 400, + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = 'server_tls_user'; + $this->ovpns->authmode->value = $this->ovpns->authmode->default; + $this->ovpns->update(); + + # Ensure the validate_server method throws an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id); + $export->certref->value = null; + $export->validate_server($this->ovpns->vpnid->value); + }, + ); + } + + /** + * Ensure the 'certref' field is not required for OpenVPNServers using the 'server_user' mode + */ + public function test_certref_not_required_for_server_user(): void { + # Ensure the server_user server mode does not require a certref for export + $this->assert_does_not_throw( + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = 'server_user'; + $this->ovpns->authmode->value = $this->ovpns->authmode->default; + $this->ovpns->update(); + + # Ensure the validate_server method does not throw an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id); + $export->certref->value = null; + $export->username->value = $this->user->name->value; # username is required for server_user mode + $export->validate_server($this->ovpns->vpnid->value); + }, + ); + } + + /** + * Ensure the username field is required for OpenVPN servers using the 'Local Database' authmode + */ + public function test_username_required_for_local_database_authmode(): void { + # Ensure a username is required when using the local database authmode + $this->assert_throws_response( + response_id: 'OPENVPN_CLIENT_EXPORT_USERNAME_REQUIRED', + code: 400, + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = 'server_tls_user'; + $this->ovpns->authmode->value = $this->ovpns->authmode->default; + $this->ovpns->update(); + + # Ensure the validate_server method throws an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id, certref: $this->user_cert->refid->value); + $export->username->value = null; + $export->validate_server($this->ovpns->vpnid->value); + }, + ); + } + + /** + * Ensure the username field is not required for OpenVPN servers using a remote authmode + */ + public function test_username_not_required_for_remote_authmode(): void { + # Ensure a username is not required when using a remote authmode + $this->assert_does_not_throw( + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = 'server_tls_user'; + $this->ovpns->authmode->value = [$this->authserver->name->value]; + $this->ovpns->update(); + + # Ensure the validate_server method does not throw an error for no username + $export = new OpenVPNClientExport(id: $this->ovpnce->id, certref: $this->user_cert->refid->value); + $export->username->value = null; + $export->validate_server($this->ovpns->vpnid->value); + }, + ); + } + + /** + * Ensure an error is thrown when 'usetoken' is enabled with a forbidden export 'type' + */ + public function test_usetoken_forbidden_types(): void { + foreach (OpenVPNClientExport::USETOKEN_FORBIDDEN_TYPES as $forbidden_type) { + $this->assert_throws_response( + response_id: 'OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED', + code: 409, + callable: function () use ($forbidden_type) { + # Create a new OpenVPNClientExport with 'usetoken' enabled and a forbidden export 'type' + $export = new OpenVPNClientExport(usetoken: true); + $export->validate_type($forbidden_type); + }, + ); + } + } + + /** + * Ensure no error is thrown when 'usetoken' is disabled, and a usetoken forbidden type is used. + */ + public function test_usetoken_forbidden_types_disabled(): void { + foreach (OpenVPNClientExport::USETOKEN_FORBIDDEN_TYPES as $forbidden_type) { + $this->assert_does_not_throw( + callable: function () use ($forbidden_type) { + # Create a new OpenVPNClientExport with 'usetoken' disabled and a forbidden export 'type' + $export = new OpenVPNClientExport(usetoken: false); + $export->validate_type($forbidden_type); + }, + ); + } + } + + /** + * Ensure no error is thrown when 'usetoken' is enabled, and a non usetoken forbidden type is used. + */ + public function test_usetoken_non_forbidden_types(): void { + $this->assert_does_not_throw( + callable: function () { + # Create a new OpenVPNClientExport with 'usetoken' enabled and a non forbidden export 'type' + $export = new OpenVPNClientExport(usetoken: true); + $export->validate_type('inst-Win10'); + }, + ); + } + + /** + * Ensure the certref validation for user certs is skipped if there is no username specified. + */ + public function test_certref_validation_skipped_for_user_certs_without_username(): void { + # Create a new OpenVPNClientExport with a user cert and no username + $export = new OpenVPNClientExport(); + $export->username->value = null; + $this->assert_equals($export->validate_certref('anything'), 'anything'); + $this->assert_equals($export->validate_certref($this->user_cert->refid->value), $this->user_cert->refid->value); + } + + /** + * Ensure an error is thrown if the provided certref is not assigned to the provided user. + */ + public function test_certref_validation_user_cert_not_assigned_to_user(): void { + # Ensure an error is thrown if the certref is not assigned to the user + $this->assert_throws_response( + response_id: 'OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND', + code: 404, + callable: function () { + # Create a new OpenVPNClientExport with a user cert and a username + $export = new OpenVPNClientExport(username: $this->user->name->value); + + # Try to use the server cert (which isn't assigned to the user), this should throw an error + $export->validate_certref($this->server_cert->refid->value); + }, + ); + } + + /** + * Ensure an error is thrown if the requested user cert does not have a private key + * and usepkcs11 or usetoken is not used + */ + public function test_certref_validation_user_cert_no_private_key(): void { + # Temporarily unset the prv value for the user cert + Certificate::set_config("{$this->user_cert->get_config_path()}/prv", null); + + # Ensure an error is thrown if the user cert does not have a private key + $this->assert_throws_response( + response_id: 'OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY', + code: 400, + callable: function () { + # Create a new OpenVPNClientExport with a user cert and a username + $export = new OpenVPNClientExport( + username: $this->user->name->value, + usepkcs11: false, + usetoken: false, + ); + + # Ensure the validate_certref method throws an error for no private key + $export->validate_certref($this->user_cert->refid->value); + }, + ); + } + + /** + * Checks that the export config correctly generates an inline client export + */ + public function test_export_config_inline(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: 'confinline', + username: $this->user->name->value, + certref: $this->user_cert->refid->value, + ); + + $config = $exporter->export_config(); + + # Ensure the user ca, cert and key are present in the config + $this->assert_str_contains($config, $this->user_cert->crt->value); + $this->assert_str_contains($config, $this->user_cert->prv->value); + $this->assert_str_contains($config, $this->server_ca->crt->value); + } + + /** + * Checks that the export config correctly generates a zip client export + */ + public function test_export_config_zip(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: 'confzip', + username: $this->user->name->value, + certref: $this->user_cert->refid->value, + ); + $filepath = $exporter->export_config(); + + # Ensure the generated file exists and is a zip + $this->assert_is_true(file_exists($filepath)); + $this->assert_str_contains($filepath, '.zip'); + @unlink($filepath); // Clean up the generated file + } + + /** + * Checks that the export installer correctly generates a exe client export + */ + public function test_export_installer(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: 'inst-Win10', + username: $this->user->name->value, + certref: $this->user_cert->refid->value, + ); + $filepath = $exporter->export_installer(); + + # Ensure the generated file exists and is a exe + $this->assert_is_true(file_exists($filepath)); + $this->assert_str_contains($filepath, '.exe'); + @unlink($filepath); // Clean up the generated file + } + + /** + * Checks that the export viscosity correctly generates a viscosity client export + */ + public function test_export_viscosity(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: 'visc', + username: $this->user->name->value, + certref: $this->user_cert->refid->value, + ); + $filepath = $exporter->export_installer(); + + # Ensure the generated file exists and is a exe + $this->assert_is_true(file_exists($filepath)); + $this->assert_str_contains($filepath, '.exe'); + @unlink($filepath); // Clean up the generated file + } + + /** + * Checks that the create methods behaves as intended + */ + public function test_create(): void { + # Create a new OpenVPNClientExport with the required fields + $export = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: 'confinline', + blockoutsidedns: true, + useaddr: 'other', + useaddr_hostname: 'ovpn.example.com', + username: $this->user->name->value, + certref: $this->user_cert->refid->value, + ); + + # Ensure the export can be created without errors + $export->create(); + + # Ensure the binary_data field is populated with the correct config + $this->assert_is_not_empty($export->binary_data->value); + $this->assert_str_contains($export->binary_data->value, $this->user_cert->crt->value); + $this->assert_str_contains($export->binary_data->value, $this->user_cert->prv->value); + $this->assert_str_contains($export->binary_data->value, $this->server_ca->crt->value); + $this->assert_str_contains($export->binary_data->value, 'remote ovpn.example.com'); + $this->assert_str_contains($export->binary_data->value, 'block-outside-dns'); + + # Change the export config and ensure the config is regenerated + $export->useaddr->value = 'other'; + $export->useaddr_hostname->value = 'new.ovpn.example.com'; + $export->blockoutsidedns->value = false; + $export->create(); + + # Ensure the binary_data field is updated with the new config + $this->assert_str_contains($export->binary_data->value, 'remote new.ovpn.example.com'); + $this->assert_str_does_not_contain($export->binary_data->value, 'block-outside-dns'); + + # Export a file based type + $export->type->value = 'confzip'; + $export->create(); + + # Ensure a filename was set, the binary_data is populated (as actual binary data), and the actual file has + # already been removed + $this->assert_str_contains($export->filename->value, '.zip'); + $this->assert_is_not_empty($export->binary_data->value); + $this->assert_is_true(!ctype_print($export->binary_data->value)); + $this->assert_is_false(file_exists("/tmp/{$export->filename->value}")); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc index e75b1381c..28cfa8e60 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc @@ -65,41 +65,4 @@ class APIModelsRESTAPISettingsSyncTestCase extends TestCase { $this->assert_equals($original_api_config, $new_api_config); $this->assert_not_equals($sync_api_config, $new_api_config); } - - /** - * Checks that the 'sync()' method correctly syncs the API config to HA peers. - */ - public function test_sync(): void { - # Use a non-pfSense host as an HA peer - $api_settings = new RESTAPISettings(); - $api_settings->ha_sync->value = true; - $api_settings->ha_sync_hosts->value = ['www.example.com']; - $api_settings->ha_sync_username->value = 'admin'; - $api_settings->ha_sync_password->value = 'pfsense'; - $api_settings->update(); - - # Read the syslog and ensure the synced failed - # TODO: This test is flaky and needs to be reworked - // RESTAPISettingsSync::sync(); - // $syslog = file_get_contents('/var/log/system.log'); - // $this->assert_str_contains( - // $syslog, - // 'Failed to sync REST API settings to example.com: received unexpected response.', - // ); - - # Use a non-existent host as an HA peer and ensure the sync failed - $api_settings->ha_sync_hosts->value = ['127.1.2.3']; - $api_settings->update(); - RESTAPISettingsSync::sync(); - $syslog = file_get_contents('/var/log/system.log'); - $this->assert_str_contains($syslog, 'Failed to sync REST API settings to 127.1.2.3: no response received.'); - - # Use bad credentials and ensure the sync failed - $api_settings->ha_sync_hosts->value = ['127.0.0.1']; - $api_settings->ha_sync_password->value = 'bad password'; - $api_settings->update(); - RESTAPISettingsSync::sync(); - $syslog = file_get_contents('/var/log/system.log'); - $this->assert_str_contains($syslog, 'Failed to sync REST API settings to 127.0.0.1: Authentication failed'); - } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc new file mode 100644 index 000000000..6775ba198 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc @@ -0,0 +1,46 @@ +update(); + + # Ensure the timezone was updated correctly + $zoneinfo = file_get_contents('/var/db/zoneinfo'); + $this->assert_equals($zoneinfo, 'America/Denver' . PHP_EOL); + + # Update the timezone again + $timezone = new SystemTimezone(timezone: 'UTC'); + $timezone->update(); + + # Ensure the timezone was updated correctly + $zoneinfo = file_get_contents('/var/db/zoneinfo'); + $this->assert_equals($zoneinfo, 'UTC' . PHP_EOL); + } + + /** + * Checks that the get_timezone_choices method returns a valid list of timezones. + */ + public function test_get_timezone_choices(): void { + $timezone = new SystemTimezone(); + $choices = $timezone->get_timezone_choices(); + $expected_choices = ['America/Denver', 'America/New_York', 'UTC']; + + # Ensure the expected choices are in the list + foreach ($expected_choices as $choice) { + $this->assert_is_true( + in_array($choice, $choices), + message: "Expected timezone '$choice' not found in choices.", + ); + } + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc new file mode 100644 index 000000000..45dee568f --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc @@ -0,0 +1,105 @@ +output, $entries[0])) { + return $add_cmd; + } + sleep(1); + } + + throw new ServerError( + message: "Failed to add table $table_name with entries", + response_id: 'API_MODELS_TABLE_TEST_CASE_ADD_TABLE_FAILED', + ); + } + + /** + * Checks that we can successfully retrieve the list of available table names. + */ + #[TestCaseRetry(retries: 3, delay: 1)] + public function test_get_available_table_names(): void { + # Create a new pf table to test with + $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4']); + + # Ensure get_available_table_names returns the test table + $table = new Table(); + $this->assert_is_true( + in_array('pfrest_test_table', $table->get_available_table_names()), + message: 'The test table should be in the list of available tables.', + ); + + # Delete the test table after the test + new Command('/sbin/pfctl -t pfrest_test_table -T kill'); + + # Ensure the test table is no longer available + $this->assert_is_false( + in_array('pfrest_test_table', $table->get_available_table_names()), + message: 'The test table should no longer be in the list of available tables.', + ); + } + + /** + * Checks that we can successfully read entries from a table + */ + #[TestCaseRetry(retries: 3, delay: 1)] + public function test_read(): void { + # Create a new pf table to test with + $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4', '4.3.2.1']); + + # Load the Table model + $table = new Table(id: 'pfrest_test_table'); + + # Ensure the table has the expected entries + $this->assert_equals($table->entries->value, ['1.2.3.4', '4.3.2.1']); + + # Delete the test table after the test + new Command('/sbin/pfctl -t pfrest_test_table -T kill'); + } + + /** + * Checks that we can successfully delete (flush) entrries from a table + */ + public function test_delete(): void { + # Create a new pf table to test with + $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4', '4.3.2.1']); + sleep(1); + + # Load the Table model + $table = new Table(id: 'pfrest_test_table'); + + # Ensure the table entries are present (so we know delete actually flushes them) + $this->assert_equals($table->entries->value, ['1.2.3.4', '4.3.2.1']); + + # Delete (flush) the test table and ensure the entries are actually flushed + $table->delete(); + $table_show = new Command('/sbin/pfctl -t pfrest_test_table -T show'); + $this->assert_str_does_not_contain($table_show->output, '1.2.3.4'); + $this->assert_str_does_not_contain($table_show->output, '4.3.2.1'); + + # Delete the test table after the test + new Command('/sbin/pfctl -t pfrest_test_table -T kill'); + } +}