diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ea11878d..01bcfd46 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -86,7 +86,7 @@ jobs:
sleep 5
- name: Fetch OpenAPI schema from pfSense
- run: curl -s -k -u admin:pfsense -X GET https://${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com/api/v2/schema > openapi-${{ matrix.PFSENSE_VERSION }}.json
+ run: curl -s -k -u admin:pfsense -X GET https://${{ matrix.PFSENSE_VERSION }}.jaredhendrickson.com/api/v2/schema/openapi > openapi-${{ matrix.PFSENSE_VERSION }}.json
- name: Teardown pfSense VM
if: "${{ always() }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f5918568..a6b96b3d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -62,7 +62,7 @@ jobs:
with:
files: pfSense-${{ matrix.PFSENSE_VERSION }}-pkg-RESTAPI.pkg
- build_openapi:
+ build_schemas:
runs-on: self-hosted
needs: [release_pkg]
steps:
@@ -92,8 +92,10 @@ jobs:
ssh -o StrictHostKeyChecking=no -o LogLevel=quiet admin@pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-RELEASE.jaredhendrickson.com "pkg -C /dev/null add /tmp/pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-pkg-RESTAPI.pkg"
sleep 5
- - name: Fetch OpenAPI schema from pfSense
- run: curl -s -k -u admin:pfsense -X GET https://pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-RELEASE.jaredhendrickson.com/api/v2/schema > openapi.json
+ - name: Fetch schemas from pfSense
+ run: |
+ curl -s -k -u admin:pfsense -X GET https://pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-RELEASE.jaredhendrickson.com/api/v2/schema/openapi > openapi.json
+ curl -s -k -u admin:pfsense -X GET https://pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-RELEASE.jaredhendrickson.com/api/v2/schema/graphql > schema.graphql
- name: Teardown pfSense VM
if: "${{ always() }}"
@@ -101,13 +103,20 @@ jobs:
/usr/local/bin/VBoxManage controlvm pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-RELEASE poweroff || true
/usr/local/bin/VBoxManage snapshot pfSense-${{ env.DEFAULT_PFSENSE_VERSION }}-RELEASE restore initial
- - uses: actions/upload-artifact@v4
+ - name: Upload OpenAPI schema
+ uses: actions/upload-artifact@v4
with:
name: openapi.json
path: openapi.json
+ - name: Upload GraphQL schema
+ uses: actions/upload-artifact@v4
+ with:
+ name: schema.graphql
+ path: schema.graphql
+
release_docs:
- needs: [build_openapi]
+ needs: [build_schemas]
runs-on: ubuntu-latest
if: ${{ !github.event.release.prerelease }}
environment:
@@ -139,6 +148,12 @@ jobs:
name: openapi.json
path: openapi.json
+ - name: Download GraphQL schema
+ uses: actions/download-artifact@v4
+ with:
+ name: schema.graphql
+ path: schema.graphql
+
- name: Build Swagger UI
run: |
mkdir -p ./www/api-docs/
@@ -166,6 +181,11 @@ jobs:
});
};' > ./www/api-docs/swagger-initializer.js
+ - name: Write GraphQL schema
+ run: |
+ mkdir -p ./www/graphql-docs/
+ cp schema.graphql/schema.graphql ./www/graphql-docs/schema.graphql
+
- name: Build PHP reference documentation
run: |
mkdir ./www/php-docs
diff --git a/.gitignore b/.gitignore
index b1eb15df..89d4a163 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,7 @@ tests/e2e_test_framework/__pycache__/
.phplint-cache
*.pyc
venv/
-vendor/
+/vendor/
.vagrant
*.pkg
node_modules
diff --git a/README.md b/README.md
index 6dce8a19..493d6d9d 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,19 @@
[![Build](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/build.yml/badge.svg)](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/build.yml)
[![Quality](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/quality.yml/badge.svg)](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/quality.yml)
-[![Release](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/release.yml/badge.svg)](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/release.yml)
+[![Release](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/release.yml/badge.svg)](https://github.com/jaredhendrickson13/pfsense-api/actions/workflows/release.yml)
+![Downloads](https://img.shields.io/github/downloads/jaredhendrickson13/pfsense-api/total?label=Downloads)
+![License](https://img.shields.io/github/license/jaredhendrickson13/pfsense-api?label=License)
+![Docs](https://img.shields.io/website?url=https%3A%2F%2Fpfrest.org&label=Documentation)
-The pfSense REST API package is an unofficial, open-source REST API for pfSense CE and pfSense Plus firewalls. This package is
-designed to be light-weight, fast, and easy to use. This guide will help you get started with the REST API package and
-provide you with the information you need to configure and use the package effectively.
+The pfSense REST API package is an unofficial, open-source REST and GraphQL API for pfSense CE and pfSense Plus
+firewalls.It is designed to be light-weight, fast, and easy to use. This guide will help you get started with the REST
+API package and provide you with the information you need to configure and use the package effectively.
## Key Features
-- 100+ endpoints available for managing your firewall and associated services
+- 200+ REST endpoints available for managing your firewall and associated services
+- A GraphQL API for flexible data retrieval and mutation
- Easy to use querying and filtering
- Configurable security settings
- Supports HATEOAS driven development
@@ -22,7 +26,7 @@ provide you with the information you need to configure and use the package effec
- [Installation and Configuration](https://pfrest.org/INSTALL_AND_CONFIG/)
- [Authentication and Authorization](https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
- [Swagger and OpenAPI](https://pfrest.org/SWAGGER_AND_OPENAPI/)
-- [Queries and Filters](https://pfrest.org/QUERIES_AND_FILTERS/)
+- [Queries, Filters, and Sorting](https://pfrest.org/QUERIES_FILTERS_AND_SORTING/)
## Quickstart
diff --git a/composer.json b/composer.json
index 1f612253..d810e15b 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,6 @@
{
"require": {
- "firebase/php-jwt": "v6.10.*"
+ "firebase/php-jwt": "v6.10.*",
+ "webonyx/graphql-php": "^15.13"
}
}
diff --git a/composer.lock b/composer.lock
index fbaa06eb..fa6ca71e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4f4c86b9227b9325c9ae20c18dbb69b1",
+ "content-hash": "df390555a5bc256768abe12103f30b54",
"packages": [
{
"name": "firebase/php-jwt",
@@ -63,6 +63,75 @@
"source": "https://github.com/firebase/php-jwt/tree/v6.10.0"
},
"time": "2023-12-01T16:26:39+00:00"
+ },
+ {
+ "name": "webonyx/graphql-php",
+ "version": "v15.13.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webonyx/graphql-php.git",
+ "reference": "b3b8c5bba097b0db95098fadb63e8980e184a03b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/b3b8c5bba097b0db95098fadb63e8980e184a03b",
+ "reference": "b3b8c5bba097b0db95098fadb63e8980e184a03b",
+ "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.63.2",
+ "mll-lab/php-cs-fixer-config": "^5",
+ "nyholm/psr7": "^1.5",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "1.12.0",
+ "phpstan/phpstan-phpunit": "1.4.0",
+ "phpstan/phpstan-strict-rules": "1.6.0",
+ "phpunit/phpunit": "^9.5 || ^10.5.21",
+ "psr/http-message": "^1 || ^2",
+ "react/http": "^1.6",
+ "react/promise": "^2.0 || ^3.0",
+ "rector/rector": "^1.0",
+ "symfony/polyfill-php81": "^1.23",
+ "symfony/var-exporter": "^5 || ^6 || ^7",
+ "thecodingmachine/safe": "^1.3 || ^2"
+ },
+ "suggest": {
+ "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
+ "psr/http-message": "To use standard GraphQL server",
+ "react/promise": "To leverage async resolving on React PHP platform"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "GraphQL\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": ["MIT"],
+ "description": "A PHP port of GraphQL reference implementation",
+ "homepage": "https://github.com/webonyx/graphql-php",
+ "keywords": ["api", "graphql"],
+ "support": {
+ "issues": "https://github.com/webonyx/graphql-php/issues",
+ "source": "https://github.com/webonyx/graphql-php/tree/v15.13.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/webonyx-graphql-php",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2024-08-29T10:55:21+00:00"
}
],
"packages-dev": [],
diff --git a/docs/AUTHENTICATION_AND_AUTHORIZATION.md b/docs/AUTHENTICATION_AND_AUTHORIZATION.md
index 8074c3aa..d969c233 100644
--- a/docs/AUTHENTICATION_AND_AUTHORIZATION.md
+++ b/docs/AUTHENTICATION_AND_AUTHORIZATION.md
@@ -84,7 +84,7 @@ curl -H "Authorization: Bearer xxxxxxxxxxxxxxxxxxxxxxx" https://pfsense.example.
### Custom Authentication
For advanced users, the REST API's framework allows for custom authentication methods to be added using PHP. See
-[Building Custom Authentication](./BUILDING_CUSTOM_AUTH_CLASSES.md) for more information.
+[Building Custom Authentication](BUILDING_CUSTOM_AUTH_CLASSES.md) for more information.
## Authorization
diff --git a/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md b/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md
index a7973b08..fea1a6fe 100644
--- a/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md
+++ b/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md
@@ -7,6 +7,7 @@ classes are also responsible for following:
- Defining the URL path for the endpoint.
- Specifying which request methods are allowed.
- Adding additional documentation to the endpoint's OpenAPI definition.
+- Defining an associated GraphQL query/mutation type.
- Generating the PHP file in the pfSense web root to expose the endpoint.
## Getting Started
@@ -240,10 +241,10 @@ and the endpoint will be accessible via the REST API.
## Generating Documentation
-To regenerate the OpenAPI documentation for all Endpoint classes, run the following command:
+To regenerate the OpenAPI and GraphQL schemas for all Endpoint classes, run the following command:
```shell
-pfsense-restapi generatedocs
+pfsense-restapi buildschemas
```
## Examples
diff --git a/docs/BUILDING_CUSTOM_MODEL_CLASSES.md b/docs/BUILDING_CUSTOM_MODEL_CLASSES.md
index 76aac5c5..e5e8f489 100644
--- a/docs/BUILDING_CUSTOM_MODEL_CLASSES.md
+++ b/docs/BUILDING_CUSTOM_MODEL_CLASSES.md
@@ -232,40 +232,40 @@ $this->unique_together_fields = ['name', 'port'];
- The `unique_together_fields` property is only applicable when the `many` property is set to `true`.
- The fields defined in the `unique_together_fields` property must be defined in the Model's Field objects.
-### sort_by_field
+### sort_by
-The `sort_by_field` property is used to define the field that objects of the Model will be sorted by. When this property is
-set, objects created and updated will be sorted according to the assigned field using the assigned `sort_option`.
+The `sort_by` property is used to define the fields that objects of the Model will be sorted by. When this property is
+set, objects created and updated will be sorted according to the assigned field using the assigned `sort_order`.
Example:
```php
-$this->sort_by_field = 'name';
+$this->sort_by = ['name'];
```
!!! Warning
The use of sorting in a Model may cause IDs to be re-ordered when objects are created or updated.
!!! Notes
- - The `sort_by_field` property is only applicable when the `many` property is set to `true`.
- - The field defined in the `sort_by_field` property must be defined in the Model's Field objects.
+ - The `sort_by` property is only applicable when the `many` property is set to `true`.
+ - The field defined in the `sort_by` property must be defined in the Model's Field objects.
-### sort_option
+### sort_order
-The `sort_option` property is used to define the PHP sorting option for objects of the Model. When this property is set,
+The `sort_order` property is used to define the PHP sorting option for objects of the Model. When this property is set,
objects created and updated will be sorted according to the assigned option. For valid sorting options, refer to: For valid value options for this property, refer to the
[PHP multi-sort function type flags](https://www.php.net/manual/en/function.array-multisort.php).
Example:
```php
-$this->sort_option = SORT_ASC;
+$this->sort_order = SORT_ASC;
```
!!! Warning
The use of sorting in a Model may cause IDs to be re-ordered when objects are created or updated.
!!! Notes
- - The `sort_option` property is only applicable when the `many` property is set to `true`.
- - The `sort_option` property is only applicable when a `sort_by_field` is defined.
+ - The `sort_order` property is only applicable when the `many` property is set to `true`.
+ - The `sort_order` property is only applicable when a `sort_by` is defined.
### subsystem
diff --git a/docs/COMMON_CONTROL_PARAMETERS.md b/docs/COMMON_CONTROL_PARAMETERS.md
index 23c95e51..3464ce2a 100644
--- a/docs/COMMON_CONTROL_PARAMETERS.md
+++ b/docs/COMMON_CONTROL_PARAMETERS.md
@@ -34,7 +34,7 @@ parameters you can use:
immediately.
!!! Tip
- The [Swagger documentation](./SWAGGER_AND_OPENAPI.md#swagger-documentation) will indicate if an endpoint applies
+ The [Swagger documentation](SWAGGER_AND_OPENAPI.md#swagger-documentation) will indicate if an endpoint applies
changes immediately or requires a separate apply call. If an endpoint applies changes immediately, this parameter
will have no effect.
@@ -75,6 +75,16 @@ parameters you can use:
submitted in your request will be removed from the existing array values. This is useful when you want to remove
specific values from an array field without needing to retrieve the existing values first.
+!!! Warning
+ If you set this parameter to `true`, it will apply to all array fields. You can't choose to remove to some array
+ fields and replace others at the same time. To work around this, first make a request with the data for the fields
+ you want to remove from. Then, make another request for the fields you want to replace.
+
+!!! Notes
+ - This parameter is only available for `PATCH` requests.
+ - This parameter is only applicable to array fields.
+ - If the submitted array values match the existing array values exactly, the API will not make any changes to that field to avoid removing all values unintentionally.
+
## reverse
- Type: Boolean
@@ -84,5 +94,22 @@ parameters you can use:
looking for an object near the end of the list. Additionally, it is helpful for time-sorted objects, such as logs,
where you may want to view the most recent entries first.
-!!! Note
- This parameter is only available for `GET` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints).
\ No newline at end of file
+## sort_by
+
+- Type: String or Array
+- Default: _Defaults to the primary sort attribute for the endpoint, typically `null`._
+- Description: This parameters allows you to select the fields to use to sort the objects related to the endpoint. The
+ behavior of this parameter varies based on the request method and endpoint type. Refer to the
+ [Sorting](QUERIES_FILTERS_AND_SORTING.md#sorting) section for more information.
+
+## sort_order
+
+- Type: String
+- Default: `SORT_ASC`
+- Choices:
+ - `SORT_ASC`
+ - `SORT_DESC`
+- Description: This parameter allows you to control the order in which the objects are sorted. The default value is
+ `SORT_ASC` which sorts the objects in ascending order. Setting this parameter to `SORT_DESC` will sort the objects in
+ descending order. The behavior of this parameter varies based on the request method and endpoint type. Refer to the
+ [Sorting](QUERIES_FILTERS_AND_SORTING.md#sorting) section for more information.
\ No newline at end of file
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 2e111015..bc9b097d 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -47,7 +47,7 @@ While not a PHP namespace, this directory contains common resources and tools us
Contains the cache files for the REST API package. Cache files are typically JSON datasets populated by [\RESTAPI\Core\Cache](https://pfrest.org/php-docs/classes/RESTAPI-Core-Cache.html) child classes and are refreshed on the schedule defined in the class.
-#### includes/
+#### vendor/
Contains additional PHP libraries and classes required by the REST API package. Because pfSense does not include a PHP package manager, these libraries are installed to this directory via composer when the package is built.
@@ -319,5 +319,18 @@ REST API > Documentation'. If you make changes to the package's codebase, you ca
to include the changes by running the following command on your pfSense instance:
```bash
-pfsense-restapi generatedocs
+pfsense-restapi buildschemas
+```
+
+### GraphQL
+
+The GraphQL schema provides accurate documentation for the various GraphQL queries and mutations available in the package.
+The GraphQL schema is automatically generated by the package using properties defined in Endpoint, Models, and Fields.
+Each of these classes have properties that map to GraphQL properties and are used to generate the GraphQL schema. The
+GraphQL schema is automatically generated when the package is initially installed and the GraphQL schema is accessible
+at /api/v2/graphql/schema. If you make changes to the package's codebase, you can regenerate the GraphQL schema to include
+the changes by running the following command on your pfSense instance:
+
+```bash
+pfsense-restapi buildschemas
```
\ No newline at end of file
diff --git a/docs/ENDPOINT_TYPES.md b/docs/ENDPOINT_TYPES.md
index 5952ae28..66985e76 100644
--- a/docs/ENDPOINT_TYPES.md
+++ b/docs/ENDPOINT_TYPES.md
@@ -14,7 +14,7 @@ endpoints will help you determine which endpoint to use for your specific use ca
Singular endpoints are endpoints that interact with and return a single object. For example, a singular endpoint might
return a single user or a single firewall rule. These endpoints will always return the `data` of the response as an
-associative array. Singular endpoints will often require an [object ID](./WORKING_WITH_OBJECT_IDS.md).
+associative array. Singular endpoints will often require an [object ID](WORKING_WITH_OBJECT_IDS.md).
Some examples of singular endpoints are:
@@ -61,9 +61,9 @@ Some examples of plural endpoints are:
### GET Requests to Plural Endpoints
A `GET` request to a plural endpoint will allow you to retrieve a list of all existing objects at once. GET requests to plural
-endpoints also support [querying and filtering](QUERIES_AND_FILTERS.md) to help you narrow down the list of objects
+endpoints also support [querying and filtering](QUERIES_FILTERS_AND_SORTING.md) to help you narrow down the list of objects
returned by specifying criteria for the objects you want to retrieve. This includes support for
-[pagination](QUERIES_AND_FILTERS.md#pagination) which will allow you to limit the amount of objects returned in a single request.
+[pagination](QUERIES_FILTERS_AND_SORTING.md#pagination) which will allow you to limit the amount of objects returned in a single request.
### PUT Requests to Plural Endpoints
@@ -87,7 +87,7 @@ one by one, and can instead replace the entire dataset as a whole in a single re
### DELETE Requests to Plural Endpoints
-A `DELETE` request to a plural endpoint will allow you to delete many objects at once using a [query](QUERIES_AND_FILTERS.md).
+A `DELETE` request to a plural endpoint will allow you to delete many objects at once using a [query](QUERIES_FILTERS_AND_SORTING.md).
This is useful when you need to remove a large number of objects from the system, such as when decommissioning services,
cleaning up old data, or removing objects that are no longer needed. This is primarily used as a method of deleting
objects without requiring an ID.
diff --git a/docs/GRAPHQL.md b/docs/GRAPHQL.md
new file mode 100644
index 00000000..95fe75c7
--- /dev/null
+++ b/docs/GRAPHQL.md
@@ -0,0 +1,327 @@
+# GraphQL
+
+The package also includes a fully-featured [GraphQL API](https://graphql.org/learn/). GraphQL is a popular alternative to
+traditional REST APIs and provides a more flexible and efficient way to interact with objects from the API. The GraphQL
+API is built on top of the same underlying components as the REST API and provides virtually the same functionality as
+the REST API, with the added benefits of GraphQL.
+
+!!! Tips
+ If you are new to APIs in general, it is recommended to start with the REST API first. The REST API is more
+ straightforward and easier to understand for beginners. Once you are comfortable with the REST API, you may
+ benefit from the additional features and flexibility provided by the GraphQL API.
+
+## Authentication and Authorization
+
+The GraphQL API uses the same authentication methods and privileges as the REST API. For more information on
+authentication, please refer to the [Authentication and Authorization](AUTHENTICATION_AND_AUTHORIZATION.md)
+documentation.
+
+!!! Important
+ The user must at least have the `api-v2-graphql-post` privilege to access the GraphQL API. From there, the user
+ must also have the relevant privileges to perform any desired operations within the GraphQL API.
+
+## Content and Accept Types
+
+The GraphQL API supports the same content and accept types as the REST API. However, it is highly recommended to use
+`application/json` as both the content and accept types when making requests to the GraphQL API. For more information
+on content and accept types, please refer to the [Content and Accept Types](CONTENT_AND_ACCEPT_TYPES.md) documentation.
+
+## Differences from REST API
+
+There are some key differences in behavior between the REST and GraphQL APIs. Below are some of the most notable differences:
+
+- All GraphQL requests are made to the `/api/v2/graphql` endpoint.
+- GraphQL requests are made using the `POST` HTTP method exclusively.
+- Responses from the GraphQL API follow a different schema than the REST API. The GraphQL API will return a `data` object
+ containing the result of the query or mutation, as well as an `errors` array containing any errors that occurred during
+ the request.
+- All GraphQL responses will return a 200 OK status code, regardless if the result contains errors. This is because GraphQL
+ responses may include both success data and error messages. Do not rely on the status code to determine the success or
+ failure of the request, use the `errors` array in the response instead.
+- Fields with pre-defined choices must use the GraphQL enum values. See the [Enums](#enums) section for more information.
+
+## Schema and Documentation
+
+The full schema for the GraphQL API can be found [here](https://pfrest.org/graphql-docs/schema.graphql),
+or you can access the schema directly from your pfSense instance using the /api/v2/schema/graphql endpoint. It is also
+highly recommended to read through the [GraphQL documentation](https://graphql.org/learn/) to fully understand how
+to interact with the GraphQL API.
+
+## Why Use GraphQL?
+
+GraphQL is a powerful tool for building APIs that allow clients to request only the data they need. This can lead to
+faster and more efficient API interactions, as clients can avoid over-fetching or under-fetching data. GraphQL also
+provides a more flexible way to query and mutate data, allowing clients to obtain all the data they need in a single
+request instead of making multiple requests to different endpoints for various resources.
+
+In summary, the primary benefits of the GraphQL API are:
+
+1. **Flexibility**: Clients can specify exactly which field values they need, reducing the amount of data transferred over the network.
+2. **Efficiency**: Clients can request multiple resources in a single query, reducing the number of round trips to the server.
+3. **Versatility**: GraphQL can handle complex operations out of the box, which often requires custom logic on the client-side when using REST APIs.
+
+However, GraphQL is not always the best choice for everyone. GraphQL is more complex and may require more effort to
+implement than a traditional REST API. For simple apps, scripts and integrations where you only need to read or write
+a small number of resources, the REST API may be a more straightforward approach.
+
+## Getting Started
+
+To get started with the GraphQL API, you will need to make requests to the `/api/v2/graphql` endpoint using the `POST`
+HTTP method. The body of the request should contain a JSON object with a `query` key and a GraphQL query string as the
+value. The query string should look something like this:
+
+```json
+{
+ "query": "query { operationName { some_field_i_need_data_from } }"
+}
+```
+
+Take this scenario for example. Let's say you have the following firewall alias configured on your system:
+
+```json
+[
+ {
+ "id": 0,
+ "name": "alias1",
+ "type": "host",
+ "address": [
+ "127.0.0.1"
+ ],
+ "detail": ["localhost"]
+ }
+]
+```
+
+You'd like to read this firewall alias, but you really only need the `name` and `address` fields. With the REST API, you
+could make a request to the `/api/v2/firewall/alias` endpoint, but you will always receive the full object. With GraphQL,
+you can make a request that only obtains the `name` and `address` fields from the alias:
+
+```
+curl -s -k -u admin:pfsense -X POST -H "Content-Type: application/json" -d '{"query": "query { readFirewallAlias(id: 0) { name address } }"}' https://localhost/api/v2/graphql
+```
+
+```json
+{"data":{"readFirewallAlias":{"name":"alias1","address":["127.0.0.1"]}}}
+```
+
+Note how the response only contains the `name` and `address` fields, even though the alias object has more fields. This
+is the beauty of GraphQL - you get exactly the data you need, no more, no less.
+
+## Enums
+
+GraphQL uses enums to define a set of possible values for a field. In other words, these are simply a list of pre-defined
+choices that a field can have. It is important to note that GraphQL adds an additional layer of abstraction when working
+with enums. When querying or mutating objects that contain enum fields, you must use the enum values formatted
+as `VAL_`, where `VAL_` is a prefix and `` is the actual value in uppercase. For example, the `type` field of
+a firewall alias object may be returned as `VAL_HOST` instead of `host`. When you are querying or mutating objects with
+enum fields, you must use these GraphQL enum values instead of the actual values.
+
+!!! Important
+ - Enum values will not contain any special characters that may be present in the actual value except for underscores.
+ - When working with enum fields in GraphQL, do not wrap the enum value in quotes. They must be passed as raw values.
+ Correct usage: `type: VAL_HOST`. Incorrect usage: `type: "VAL_HOST"`.
+
+## Queries and Mutations
+
+GraphQL queries are operations used to read data from the API, while mutations are operations used to create, update,
+or delete data. The GraphQL API supports a wide range of queries and mutations for interacting with the pfSense
+configuration. For a full list of available queries and mutations, please refer to
+the [GraphQL schema](https://pfrest.org/graphql-docs/schema.graphql).
+
+!!! Important
+ Queries and mutations are executed in the order they are defined in your request. If you need to perform multiple
+ operations in a single request, you can define them in the same query or mutation block but you must ensure they are
+ defined in the correct order to avoid conflicts.
+
+### Operation Naming
+
+For consistency, query and mutation operation names are based off the equivalent REST API endpoints URL, with the
+following modifications:
+
+1. The `/api/v2/` prefix is not included in the operation name.
+2. The operation names are camel-cased (e.g. readFirewallAlias)
+3. The operation name will be prefixed with an operation type that directly corresponds with the REST API endpoint's supported HTTP methods:
+ - `read` for `GET` requests to [singular endpoints](ENDPOINT_TYPES.md#singular-endpoints)
+ - `query` for `GET` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints)
+ - `create` for `POST` requests to [singular endpoints](ENDPOINT_TYPES.md#singular-endpoints)
+ - `update` for `PATCH` requests to [singular endpoints](ENDPOINT_TYPES.md#singular-endpoints)
+ - `replaceAll` for `PUT` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints)
+ - `delete` for `DELETE` requests to [singular endpoints](ENDPOINT_TYPES.md#singular-endpoints)
+ - `deleteAll` for `DELETE` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints)
+ - `deleteMany` for queried `DELETE` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints)
+
+Examples:
+
+- `readFirewallAlias` is equivalent to `GET /api/v2/firewall/alias`
+- `queryFirewallAliases` is equivalent to `GET /api/v2/firewall/aliases`
+- `createFirewallAlias` is equivalent to `POST /api/v2/firewall/alias`
+- `updateFirewallAlias` is equivalent to `PATCH /api/v2/firewall/alias`
+- `replaceAllFirewallAliases` is equivalent to `PUT /api/v2/firewall/aliases`
+- `deleteFirewallAlias` is equivalent to `DELETE /api/v2/firewall/alias`
+- `deleteAllFirewallAliases` is equivalent to `DELETE /api/v2/firewall/aliases` with `all` query parameter set to `true`
+- `deleteManyFirewallAliases` is equivalent to `DELETE /api/v2/firewall/aliases` with query parameters to filter the deletion
+
+### Query Syntax
+
+GraphQL queries follow a specific syntax that defines the structure of the request. Multiple queries can be defined in
+a single request to read objects of many different types at once. Below is a basic example of a GraphQL query:
+
+```graphql
+query {
+ readFirewallAlias(id: 0) {
+ name
+ address
+ }
+}
+```
+
+In this query, we are asking the API to return the `name` and `address` fields for the alias with an `id` of `0`. The
+response will contain only these requested fields.
+
+### Mutation Syntax
+
+Mutations are used to create, update, or delete data in the API. Multiple mutations can be defined in a single request
+to perform multiple operations at once. Below is a basic example of a GraphQL mutation:
+
+```graphql
+mutation {
+ createFirewallAlias(name: "alias2", type: VAL_HOST, address: ["127.0.0.1"], detail: ["localhost"]) {
+ id
+ name
+ address
+ }
+}
+```
+
+In this mutation, we are creating a new firewall alias. We specify the `name`, `type`, `address`, and `detail` fields for
+the new alias and request the response only include the `id`, `name`, and `address` fields of the newly created alias.
+
+### Variables
+
+GraphQL queries and mutations can accept variables to parameterize the request. This allows you to reuse the same query
+or mutation with different input values. Variables are defined in the query or mutation string and are passed as a separate
+JSON object in the request body. Below is an example of a query with variables:
+
+```json
+{
+ "query": "query ReadFirewallAlias($id: Int!) { readFirewallAlias(id: $id) { name address } }",
+ "variables": {
+ "id": 0
+ }
+}
+```
+
+In this query, we define a variable `$id` of type `Int!` (non-nullable integer) and use it in the `readFirewallAlias`
+operation. The variable's value is assigned in the `variables` object in the request body. For more information on
+variables, refer to the [GraphQL documentation](https://graphql.org/learn/queries/#variables).
+
+## Common Use Cases
+
+GraphQL is a powerful tool that can be used in a variety of ways to interact with the pfSense configuration. Below are
+a few common use cases for the GraphQL API:
+
+### Creating an alias and firewall rule in a single request
+
+You can use a single GraphQL request to create a new alias and a new firewall rule that references that alias.
+
+```graphql
+mutation {
+ createFirewallAlias(
+ name: "example_alias",
+ type: VAL_HOST,
+ address: ["example.com"],
+ detail: ["Example FQDN"]
+ ) { id }
+ createFirewallRule(
+ interface: "lan",
+ type: VAL_PASS,
+ ipprotocol: VAL_INET,
+ protocol: VAL_TCP,
+ source: "lan",
+ destination: "example_alias",
+ descr: "Allow traffic to hosts defined in the newly created alias"
+ ) { id interface type ipprotocol protocol source destination descr }
+```
+
+```json
+{
+ "data": {
+ "createFirewallAlias": {
+ "id": 1
+ },
+ "createFirewallRule": {
+ "id": 4,
+ "interface": [
+ "lan"
+ ],
+ "type": "VAL_PASS",
+ "ipprotocol": "VAL_INET",
+ "protocol": "VAL_TCP",
+ "source": "lan",
+ "destination": "alias2",
+ "descr": "Allow traffic to hosts defined in the newly created alias"
+ }
+ }
+}
+```
+
+### Creating a new VLAN interface with a new gateway in a single request
+
+You can use a single GraphQL request to create a new VLAN, interface and a new gateway that is used by that interface.
+
+```graphql
+mutation {
+ createInterfaceVLAN(
+ if: "em0",
+ tag: 5,
+ descr: "Example VLAN Interface",
+ ) { if tag descr }
+ createInterface(
+ if: "em0.5",
+ descr: "Example Interface",
+ type: VAL_STATICV4,
+ ipaddr: "192.168.5.100",
+ subnet: 24
+ ) { if descr type ipaddr subnet }
+ createRoutingGateway(
+ name: "example_gateway",
+ ipporotocol: VAL_INET,
+ interface: "opt1",
+ gateway: "192.168.5.1"
+ ) { name ipporotocol interface gateway }
+ updateInterface(
+ id: "opt1",
+ gateway: "example_gateway",
+ apply: true
+ ) { id gateway }
+}
+```
+
+```json
+{
+ "data": {
+ "createInterfaceVLAN": {
+ "if": "em0",
+ "tag": 5,
+ "descr": "Example VLAN Interface"
+ },
+ "createInterface": {
+ "if": "em0.5",
+ "descr": "Example Interface",
+ "type": "VAL_STATICV4",
+ "ipaddr": "192.168.5.100",
+ "subnet": 24
+ },
+ "createRoutingGateway": {
+ "name": "example_gateway",
+ "ipporotocol": "VAL_INET",
+ "interface": "opt1",
+ "gateway": "192.168.5.1"
+ },
+ "updateInterface": {
+ "id": "opt1",
+ "gateway": "example_gateway"
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/docs/LIMITATIONS_AND_FAQS.md b/docs/LIMITATIONS_AND_FAQS.md
index a1fd7f30..d79b0235 100644
--- a/docs/LIMITATIONS_AND_FAQS.md
+++ b/docs/LIMITATIONS_AND_FAQS.md
@@ -16,10 +16,11 @@ Keep the following limitations in mind when using this API:
I'd love for it to be an official package! A few years back, Netgate opened a friendly dialogue about the
possibility of making this package official. There was some back and forth about the direction of the package and pfSense
itself. Since then, I have been working on addressing most of the items that were brought up during the discussions but
-it seems interest has waned on Netgate's side. I am still open to the idea of making this an official package and would
-still love to work with Netgate to make that happen.
+it seems it is no longer in Netgate's interest to make this package official. I am still open to the idea of making this
+package official, but sadly with the announcement of pfSense Plus's [multi-instance management](https://www.netgate.com/multi-instance-management-pfsense-plus)
+it seems highly unlikely that this package will ever be made official.
-For now, I will say there are still some benefits to this package being unofficial. It allows for more rapid development and
+There are still some benefits to this package being unofficial. It allows for more rapid development and
more flexibility in the direction of the package. It also allows for more community involvement in the development of the
package. It also ensures the package remains free and open-source.
@@ -39,3 +40,11 @@ designed to comply with RESTful principles and was only meant to be REST-like. T
up with RESTful principles in mind and is a much more accurate representation of a RESTful API, therefor it was decided
to create a new package to reflect this change while also differentiating it from Netgate's supposed RESTCONF plans.
+### Why can I not see passwords/keys/hashes in API responses?
+
+By default, sensitive fields such as passwords, keys and hashes are **not** included in API responses. This is an important
+security measure to help prevent critical information from being leaked. Although it is highly advised you keep these
+sensitive fields hidden, you can override this behavior by enabling the 'Expose Sensitive Fields' option in
+System > REST API > Settings, or by setting the `expose_sensitive_fields` option in a `PATCH` request to
+[/api/v2/system/restapi/settings](https://pfrest.org/api-docs/#/SYSTEM/patchSystemRESTAPISettingsEndpoint).
+If you do choose to expose sensitive fields, it's recommended you only do so temporarily and only when necessary.
\ No newline at end of file
diff --git a/docs/QUERIES_AND_FILTERS.md b/docs/QUERIES_FILTERS_AND_SORTING.md
similarity index 70%
rename from docs/QUERIES_AND_FILTERS.md
rename to docs/QUERIES_FILTERS_AND_SORTING.md
index 0e7b5d21..1f9944aa 100644
--- a/docs/QUERIES_AND_FILTERS.md
+++ b/docs/QUERIES_FILTERS_AND_SORTING.md
@@ -1,4 +1,4 @@
-# Queries and Filters
+# Queries, Filters and Sorting
## Queries
@@ -7,7 +7,7 @@ query parameters in the URL and are formatted as `key=value`. Multiple queries c
separating them with an ampersand `&`.
!!! Important
- - Queries are only available for `GET` requests to [plural endpoints](./ENDPOINT_TYPES.md#plural-many-endpoints).
+ - Queries are only available for `GET` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints).
- While it is not standard HTTP practice, the REST API will allow you to pass query parameters in the request body
for `GET` requests as long as the correct Content-Type header is set. This may be useful when you need to force
the type of a query parameter or when the query parameter is too long to fit in the URL.
@@ -114,10 +114,46 @@ Search for objects whose field value matches a given PCRE regular expression.
- Name: `regex`
- Example: `https://pfsense.example.com/api/v2/examples?fieldname__regex=^example`
-## Custom Query Filters
+### Custom Query Filters
For advanced users, the REST API's framework allows for custom query filter classes to be added using PHP. Refer to
-[Building Custom Query Filters](./BUILDING_CUSTOM_QUERY_FILTER_CLASSES.md) for more information.
+[Building Custom Query Filters](BUILDING_CUSTOM_QUERY_FILTER_CLASSES.md) for more information.
+
+## Sorting
+
+Sorting can be used to order the data that is returned from the API based on specific criteria, as well as sorting the
+objects written to the pfSense configuration. Sorting is controlled by two common control parameters:
+[`sort_by`](COMMON_CONTROL_PARAMETERS.md#sort_by) and [`sort_order`](COMMON_CONTROL_PARAMETERS.md#sort_order).
+
+!!! Note
+ - Sorting is only available for model objects that allow many instances, meaning multiple objects of its type can
+ exist in the pfSense configuration (e.g. firewall rules, static routes, etc.).
+ - Sorting requires additional processing time and may impact performance. Use sorting only when
+ necessary.
+
+The behavior of sorting varies based on the request method and endpoint type:
+
+### GET requests to Plural (Many) Endpoints
+
+For `GET` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints), sorting allows to you sort the
+objects returned in the `data` section of the API response by a specific field and a specific ordering. This does not
+affect the order of the objects stored in the pfSense configuration.
+
+### POST and PATCH requests to Singular Endpoints
+
+For `POST` and `PATCH` requests to [singular endpoints](ENDPOINT_TYPES.md#singular-endpoints), sorting allows you to
+sort the relevant objects in the pfSense configuration after creating or updating an object. This is useful when you
+need to control the order of objects in the configuration, especially for objects where the order of objects directly
+affects the behavior (like ACLs). Some example use cases for sorting the configuration include:
+
+- Reordering firewall rules based on a custom description.
+- Reordering NAT rules based on interface.
+
+!!! Warning
+ - Use caution when setting the sort order of objects which may be sensitive to order such as firewall rules. Placing
+ the object in the wrong location may have unintended consequences such as blocking all traffic or allowing all traffic.
+ - Some endpoints may already have default sorting attributes. Setting the `sort_by` parameter will override these
+ defaults which may result in unexpected behavior.
## Pagination
@@ -129,5 +165,5 @@ reduce the amount of data returned in a single request.
!!! Important
- Most endpoints do not impose a limit on the number of items returned by default. Refer to the endpoint's
[documentation](https://pfrest.org/api-docs/) to determine if a limit is imposed by default.
- - Pagination is only available for `GET` and `DELETE` requests to [plural endpoints](./ENDPOINT_TYPES.md#plural-many-endpoints).
+ - Pagination is only available for `GET` and `DELETE` requests to [plural endpoints](ENDPOINT_TYPES.md#plural-many-endpoints).
- If combined with a query, pagination will be applied after the initial query is executed.
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 8a8b0017..e0249a66 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -6,7 +6,7 @@ Below are versions that are currently supported and will receive security update
| Version | Supported |
|---------| ------------------ |
-| 2.0.x | :white_check_mark: |
+| 2.2.x | :white_check_mark: |
| 1.7.x | :white_check_mark: |
| <=1.6.x | :x: |
diff --git a/docs/SWAGGER_AND_OPENAPI.md b/docs/SWAGGER_AND_OPENAPI.md
index fe345285..aa7cb340 100644
--- a/docs/SWAGGER_AND_OPENAPI.md
+++ b/docs/SWAGGER_AND_OPENAPI.md
@@ -20,7 +20,7 @@ characteristics of the endpoint. These characteristics include:
#### Endpoint type
Displays the [Endpoint type](ENDPOINT_TYPES.md) for the endpoint. This will tell you if the endpoint interacts with a
-single objects or multiple objects at once.
+single object or multiple objects at once.
#### Associated model
@@ -83,11 +83,11 @@ restarted/reloaded. This is typically done by calling the relevant service's `ap
Indicates whether the Model class associated with the endpoint utilizes a cache to populate data. This attribute will
display the name of the [Cache class used](https://pfrest.org/php-docs/namespaces/restapi-caches.html) by the Model class.
-If a cache is utilized, data returned by the endpoint is fetch periodically in the background on a schedule. This means
+If a cache is utilized, data returned by the endpoint is fetched periodically in the background on a schedule. This means
the data returned by the endpoint may not always be up-to-date.
## OpenAPI Schema
The pfSense REST API package was designed around the OpenAPI specification and is built to automatically document its
-components as OpenAPI schemas. The full OpenAPI schema is available at the `/api/v2/schema` endpoint. This schema can be
-used to quickly generate client libraries, documentation, and more.
+components as OpenAPI schemas. The full OpenAPI schema is available at the `/api/v2/schema/openapi` endpoint. This
+schema can be used to quickly generate client libraries, documentation, and more.
diff --git a/docs/WORKING_WITH_HATEOAS.md b/docs/WORKING_WITH_HATEOAS.md
index d3bbb963..9c615637 100644
--- a/docs/WORKING_WITH_HATEOAS.md
+++ b/docs/WORKING_WITH_HATEOAS.md
@@ -15,7 +15,7 @@ easily navigate the API and discover available actions and resources related to
!!! Important
Enabling HATEOAS can greatly increase the size of API responses as additional links are included in the response data;
which may also impact performance on large datasets. It is strongly recommended to only enable HATEOAS when needed and
- to use [pagination](./QUERIES_AND_FILTERS.md#pagination) to limit the amount of data returned in a single request.
+ to use [pagination](QUERIES_FILTERS_AND_SORTING.md#pagination) to limit the amount of data returned in a single request.
### Link types
@@ -28,11 +28,11 @@ API response.
#### next
-Provides a link to the next set of data when [pagination](./QUERIES_AND_FILTERS.md#pagination) is used.
+Provides a link to the next set of data when [pagination](QUERIES_FILTERS_AND_SORTING.md#pagination) is used.
#### prev
-Provides a link to the previous set of data when [pagination](./QUERIES_AND_FILTERS.md#pagination) is used.
+Provides a link to the previous set of data when [pagination](QUERIES_FILTERS_AND_SORTING.md#pagination) is used.
#### self
diff --git a/docs/index.md b/docs/index.md
index 262ee894..02c93525 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,12 +1,12 @@
# pfSense REST API
-The pfSense REST API package is an unofficial, open-source REST API for pfSense CE and pfSense Plus firewalls that is
-designed to be light-weight, fast, and easy to use. This guide will help you get started with the REST API package and
-provide you with the information you need to configure and use the package effectively.
+The pfSense REST API package is an unofficial, open-source REST and GraphQL API for pfSense CE and pfSense Plus
+firewalls. It is designed to be light-weight, fast, and easy to use. This guide will help you get started with the REST
+API package and provide you with the information you need to configure and use the package effectively.
!!! Tip
- Looking for documentation on REST API endpoints and parameters? An interactive [Swagger documentation](./SWAGGER_AND_OPENAPI.md)
- site is available on your pfSense instance after [installing the package](./INSTALL_AND_CONFIG.md) under
+ Looking for documentation on REST API endpoints and parameters? An interactive [Swagger documentation](SWAGGER_AND_OPENAPI.md)
+ site is available on your pfSense instance after [installing the package](INSTALL_AND_CONFIG.md) under
'System' -> 'REST API' -> 'Documentation'. Alternatively, a simplified version of the Swagger documentation is
available [here](https://pfrest.org/api-docs/).
@@ -16,7 +16,8 @@ provide you with the information you need to configure and use the package effec
## Key Features
-- 100+ endpoints available for managing your firewall and associated services
+- 200+ REST endpoints available for managing your firewall and associated services
+- A GraphQL API for flexible data retrieval and mutation
- Easy to use querying and filtering
- Configurable security settings
- Supports HATEOAS driven development
diff --git a/mkdocs.yml b/mkdocs.yml
index 0c16b300..d4c930e7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -7,10 +7,11 @@ nav:
- Endpoint Types: ENDPOINT_TYPES.md
- Authentication & Authorization: AUTHENTICATION_AND_AUTHORIZATION.md
- Content & Accept Types: CONTENT_AND_ACCEPT_TYPES.md
- - Queries & Filters: QUERIES_AND_FILTERS.md
+ - Queries, Filters & Sorting: QUERIES_FILTERS_AND_SORTING.md
- Working with Object IDs: WORKING_WITH_OBJECT_IDS.md
- Working with HATEOAS: WORKING_WITH_HATEOAS.md
- Common Control Parameters: COMMON_CONTROL_PARAMETERS.md
+ - GraphQL: GRAPHQL.md
- Limitations & FAQs: LIMITATIONS_AND_FAQS.md
- API Reference: https://pfrest.org/api-docs/
- Advanced Topics:
diff --git a/pfSense-pkg-RESTAPI/files/pkg-install.in b/pfSense-pkg-RESTAPI/files/pkg-install.in
index 6a0fc741..3572bedf 100644
--- a/pfSense-pkg-RESTAPI/files/pkg-install.in
+++ b/pfSense-pkg-RESTAPI/files/pkg-install.in
@@ -13,7 +13,7 @@ fi
# Build Endpoints, Forms, privs, documentation and restore an existing configuration if present/configured
/usr/local/bin/php -f /usr/local/pkg/RESTAPI/.resources/scripts/manage.php buildendpoints
/usr/local/bin/php -f /usr/local/pkg/RESTAPI/.resources/scripts/manage.php buildforms
-/usr/local/bin/php -f /usr/local/pkg/RESTAPI/.resources/scripts/manage.php generatedocs
+/usr/local/bin/php -f /usr/local/pkg/RESTAPI/.resources/scripts/manage.php buildschemas
/usr/local/bin/php -f /usr/local/pkg/RESTAPI/.resources/scripts/manage.php restore
# Setup cron jobs for Dispatchers that run on a schedule
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/schemas/README.md b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/schemas/README.md
new file mode 100644
index 00000000..23725b30
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/schemas/README.md
@@ -0,0 +1,10 @@
+# pfSense-pkg-RESTAPI Schemas
+
+This directory contains schema files that are used to represent the structure of the REST API responses in various
+formats. These schemas are typically used to generate documentation, or provide an alternative interface for interacting
+with the REST API. Schema files are populated by Schema classes found in `\RESTAPI\Schemas\`.
+
+Some common schema formats include:
+
+- OpenAPI
+- GraphQL
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php
index 48ebe605..eda18d29 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/scripts/manage.php
@@ -96,6 +96,24 @@ function build_privs(): void {
echo 'done.' . PHP_EOL;
}
+/**
+ * Automatically generates the package schemas/documentation files for each Schema class defined in \RESTAPI\Schemas
+ * and writes them to /usr/local/pkg/RESTAPI/.resources/schemas/.
+ */
+function build_schemas(): void {
+ # Import each schema class
+ foreach (glob('/usr/local/pkg/RESTAPI/Schemas/*.inc') as $file) {
+ # Import classes files and create object
+ require_once $file;
+ $schema_class = '\\RESTAPI\\Schemas\\' . str_replace('.inc', '', basename($file));
+ print "Generating schema files for $schema_class... ";
+ $schema_obj = new $schema_class();
+ $schema_obj->save_schema();
+ $schema_obj->build_schema_url();
+ print 'done.' . PHP_EOL;
+ }
+}
+
/**
* Runs the process for a specified Dispatcher class in \RESTAPI\Dispatchers.
* @param string|null $dispatcher_name
@@ -387,7 +405,7 @@ function help(): void {
echo ' buildendpoints : Build all REST API Endpoints included in this package' . PHP_EOL;
echo ' buildforms : Build all REST API Forms included in this package' . PHP_EOL;
echo ' buildprivs : Build all REST API privileges included in this package' . PHP_EOL;
- echo ' generatedocs : Regenerates the OpenAPI documentation' . PHP_EOL;
+ echo ' buildschemas : Build all Schema/documentation files' . PHP_EOL;
echo ' notifydispatcher : Start a dispatcher process' . PHP_EOL;
echo ' scheduledispatchers : Sets up cron jobs for dispatchers and caches on a schedule.' . PHP_EOL;
echo ' refreshcache : Refresh the cache file for a given cache class.' . PHP_EOL;
@@ -416,10 +434,8 @@ function help(): void {
build_privs();
}
# GENERATEDOCUMENTATION COMMAND
-elseif ($argv[1] == 'generatedocs') {
- echo 'Generating OpenAPI documentation... ';
- RESTAPI\Core\Tools\generate_documentation();
- echo 'done.' . PHP_EOL;
+elseif ($argv[1] == 'buildschemas') {
+ build_schemas();
}
# NOTIFY_DISPATCHER COMMAND
elseif ($argv[1] == 'notifydispatcher') {
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/includes/README.md b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/vendor/README.md
similarity index 60%
rename from pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/includes/README.md
rename to pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/vendor/README.md
index c7f693ec..35c564a8 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/includes/README.md
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/vendor/README.md
@@ -1,5 +1,5 @@
-# pfSense-pkg-RESTAPI Includes
+# pfSense-pkg-RESTAPI Vendor Includes
This directory contains the PHP dependencies for the pfSense-pkg-RESTAPI package.
These dependencies are automatically installed via composer when the package is
-built.
+built. This directory will also contain the composer autoloader file.
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/RESTAPIVersionReleasesCache.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/RESTAPIVersionReleasesCache.inc
index 411ec65d..6db84fb2 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/RESTAPIVersionReleasesCache.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Caches/RESTAPIVersionReleasesCache.inc
@@ -5,7 +5,6 @@ namespace RESTAPI\Caches;
require_once 'RESTAPI/autoloader.inc';
use RESTAPI\Core\Cache;
-use RESTAPI\Core\Command;
use RESTAPI\Models\RESTAPIVersion;
/**
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/BinaryContentHandler.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/BinaryContentHandler.inc
new file mode 100644
index 00000000..2d38e885
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/BinaryContentHandler.inc
@@ -0,0 +1,91 @@
+mime_type => [
+ 'schema' => [
+ 'type' => 'string',
+ 'format' => 'binary',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/JSONContentHandler.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/JSONContentHandler.inc
index bcffd5ba..c68843de 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/JSONContentHandler.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ContentHandlers/JSONContentHandler.inc
@@ -4,6 +4,7 @@ namespace RESTAPI\ContentHandlers;
use JsonException;
use RESTAPI\Core\ContentHandler;
+use RESTAPI\Core\Response;
use RESTAPI\Responses\ValidationError;
/**
@@ -11,14 +12,19 @@ use RESTAPI\Responses\ValidationError;
* REST API and corresponds with the 'application/json' MIME type.
*/
class JSONContentHandler extends ContentHandler {
+ /**
+ * The MIME type associated with this content handler.
+ * @var string $mime_type
+ */
public string $mime_type = 'application/json';
/**
* Encodes the given content as JSON.
* @param mixed|null $content The content to encode.
+ * @param Response|null $context The Response object that contains the content to encode.
* @return mixed The encoded content.
*/
- protected function _encode(mixed $content = null): mixed {
+ protected function _encode(mixed $content = null, ?Response $context = null): mixed {
return $content ? json_encode($content) : [];
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ContentHandler.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ContentHandler.inc
index 042d57f7..99c52272 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ContentHandler.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ContentHandler.inc
@@ -7,6 +7,7 @@ use ReflectionMethod;
use RESTAPI\Responses\MediaTypeError;
use RESTAPI\Responses\NotAcceptableError;
use RESTAPI\Responses\ServerError;
+use RESTAPI\Responses\Success;
use function RESTAPI\Core\Tools\get_classes_from_namespace;
require_once 'RESTAPI/autoloader.inc';
@@ -41,11 +42,14 @@ class ContentHandler {
* Defines the steps necessary to encode the given content into the correct format. This method is generally used
* to encode the remote client's response in the format requested by the client.
* @note This method MUST be overridden by the child class to be used in response to a client's Accept header
- * @param mixed $content The content to be encoded in this ContentHandler's format.
+ * @param mixed $content The content to be encoded in this ContentHandler's format. This generally the array
+ * representation of the Response object, but can be any content that needs to be encoded.
+ * @param Response|null $context The Response object that this content is being encoded for. This useful for
+ * ContentHandlers that need more context from the Response object that is being encoded.
* @return mixed The content in this ContentHandler's respective format.
* @throws NotAcceptableError When this ContentHandler does not support encoding content.
*/
- protected function _encode(mixed $content = null): mixed {
+ protected function _encode(mixed $content = null, ?Response $context = null): mixed {
throw new NotAcceptableError(
message: "Content handler `$this->mime_type` cannot format a response as it does not support content " .
'encoding. Change the value of your `Accept` header to a valid option and try again.',
@@ -56,11 +60,14 @@ class ContentHandler {
/**
* Encodes the provided content into the format corresponding with this ContentHandler's MIME type and sets the
* Content-Type and Content-Length response headers accordingly.
- * @param mixed $content The content to be encoded in this ContentHandler's format.
+ * @param mixed $content The content to be encoded in this ContentHandler's format. This generally the array
+ * representation of the Response object, but can be any content that needs to be encoded.
+ * @param Response|null $context The Response object that this content is being encoded for. This useful for
+ * ContentHandlers that need more context from the Response object that is being encoded.
* @return mixed The content in this ContentHandler's respective format.
*/
- final public function encode(mixed $content): mixed {
- $encoded_content = $this->_encode($content);
+ final public function encode(mixed $content, ?Response $context = null): mixed {
+ $encoded_content = $this->_encode($content, context: $context);
header("content-type: $this->mime_type");
return $encoded_content;
}
@@ -253,4 +260,46 @@ class ContentHandler {
return $mime_type;
}
+
+ /**
+ * Converts this ContentHandler into an OpenAPI 'content' schema. This schema details which MIME types are allowed
+ * by the Accept header and also provides a reference to the schema of the Response object.
+ * @param Response $response The Response object this content schema describes.
+ * @param Endpoint $endpoint The Endpoint object this content schema is being generated for.
+ * @return array This ContentHandler as an OpenAPI 'content' schema for this MIME type in a given Response object.
+ */
+ public function to_openapi_schema(Response $response, Endpoint $endpoint): array {
+ # Format the data schema based on the endpoint for successful responses
+ if ($response instanceof Success and $endpoint->many) {
+ $data_schema = [
+ 'type' => 'array',
+ 'items' => ['$ref' => "#/components/schemas/{$endpoint->model->get_class_shortname()}"],
+ ];
+ } elseif ($response instanceof Success and !$endpoint->many) {
+ $data_schema = ['$ref' => "#/components/schemas/{$endpoint->model->get_class_shortname()}"];
+ } else {
+ $data_schema = [
+ 'oneOf' => [['type' => 'array', 'items' => ['type' => 'object']], ['type' => 'object']],
+ ];
+ }
+
+ # Default to an empty schema if this ContentHandler cannot encode content, otherwise use the schema
+ return !$this->can_encode()
+ ? []
+ : [
+ $this->mime_type => [
+ 'schema' => [
+ 'allOf' => [
+ ['$ref' => "#/components/schemas/{$response->get_class_shortname()}"],
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'data' => $data_schema,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc
index 3aac7392..86e7f93d 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc
@@ -25,6 +25,7 @@ use RESTAPI\Models\RESTAPISettings;
use RESTAPI\Models\VirtualIP;
use RESTAPI\Responses\ForbiddenError;
use RESTAPI\Responses\MethodNotAllowedError;
+use RESTAPI\Responses\NotAcceptableError;
use RESTAPI\Responses\ServerError;
use RESTAPI\Responses\ServiceUnavailableError;
use RESTAPI\Responses\Success;
@@ -73,6 +74,14 @@ class Endpoint {
*/
protected array $errors = [];
+ /**
+ * @var array $response_types
+ * An array of Response classes that can be returned by this Endpoint. This array
+ * should contain the class names of the Response objects that can be returned by
+ * this Endpoint.
+ */
+ public array $response_types = [];
+
/**
* @var Auth $client
* The \RESTAPI\Core\Auth object created by this Endpoint during API calls, contains
@@ -117,6 +126,22 @@ class Endpoint {
*/
public array $request_method_options = [];
+ /**
+ * @var array $encode_content_handlers
+ * Defines which ContentHandler class (as shortnames) can be used to encode the API response. If this array is
+ * empty, all ContentHandlers with encode capabilities can be used. This directly relates to which MIME-types
+ * this endpoint supports in the Accept header.
+ */
+ public array $encode_content_handlers = [];
+
+ /**
+ * @var array $decode_content_handlers
+ * Defines which ContentHandler class (as shortnames) can be used to decode the API request. If this array is
+ * empty, all ContentHandlers with decode capabilities can be used. This directly relates to which MIME-types
+ * this endpoint supports in the Content-Type header.
+ */
+ public array $decode_content_handlers = [];
+
/**
* @property bool $requires_auth
* Specify whether this Endpoint should require remote clients to authenticate
@@ -290,6 +315,24 @@ class Endpoint {
*/
public bool $reverse = false;
+ /**
+ * @var string|null $sort_by
+ * Sets the default value(s) for the `sort_by` field in the request data. This value is used to control the sorting of
+ * the Model objects returned by this Endpoint. This value can be overridden by the client in the request data. Use
+ * caution when assigning this value as it may force objects to be sorted in this order when they are written to the
+ * configuration file.
+ */
+ public string|array|null $sort_by = null;
+
+ /**
+ * @var string $sort_order
+ * Sets the default value for the `sort_order` field in the request data. This value is used to control the sorting
+ * order of the Model objects returned by this Endpoint. This value can be overridden by the client in the request
+ * data. This value only takes effect when the `sort_by` field is also set. This value must be the name of a valid
+ * PHP sort order constant ('SORT_ASC' or 'SORT_DESC').
+ */
+ public string $sort_order = 'SORT_ASC';
+
/**
* @var bool $append
* Sets the default value for the `append` field in the request data. This value is used to control how Model objects
@@ -522,6 +565,40 @@ class Endpoint {
return $privs;
}
+ /**
+ * Checks if the requested decode ContentHandler is supported.
+ * @throws NotAcceptableError When the requested ContentHandler is not supported.
+ */
+ public function check_decode_content_handler_supported(ContentHandler $content_handler): void {
+ # Obtain the shortname for the class
+ $content_handler_sn = $content_handler->get_class_shortname();
+
+ # Check that this ContentHandler is allowed to decode the client's request
+ if ($this->decode_content_handlers and !in_array($content_handler_sn, $this->decode_content_handlers)) {
+ throw new NotAcceptableError(
+ message: "The requested Content-Type '$content_handler->mime_type' is not supported by this endpoint.",
+ response_id: 'ENDPOINT_CONTENT_TYPE_NOT_SUPPORTED',
+ );
+ }
+ }
+
+ /**
+ * Checks if the requested encode ContentHandler is supported.
+ * @throws NotAcceptableError When the requested ContentHandler is not supported.
+ */
+ public function check_encode_content_handler_supported(ContentHandler $content_handler): void {
+ # Obtain the shortname for the class
+ $content_handler_sn = $content_handler->get_class_shortname();
+
+ # Check that this ContentHandler is allowed to encode the client's request
+ if ($this->encode_content_handlers and !in_array($content_handler_sn, $this->encode_content_handlers)) {
+ throw new NotAcceptableError(
+ message: "The requested Accept value '$content_handler->mime_type' is not supported by this endpoint.",
+ response_id: 'ENDPOINT_ACCEPT_NOT_SUPPORTED',
+ );
+ }
+ }
+
/**
* Checks if authentication and authorization for this Endpoint is successful. This will attempt authentication
* and authorization for each allowed auth method. Only one auth method needs to succeed to allow access.
@@ -652,6 +729,11 @@ class Endpoint {
private function check_request_data(): void {
# Obtain the ContentHandler associated with the HTTP Content-Type header sent by the client
$content_handler = ContentHandler::get_decode_handler();
+
+ # Ensure this endpoint supports the decode handler
+ $this->check_decode_content_handler_supported($content_handler);
+
+ # Decode the request data
$this->request_data = $content_handler->decode();
$this->validate_endpoint_fields();
}
@@ -702,6 +784,76 @@ class Endpoint {
}
}
+ /**
+ * Validates the $sort_by common control parameter for this request.
+ * @note If the `sort_by` field was not provided in the request data, the request will default to the Endpoint's assigned
+ * $sort_by property value.
+ * @throws ValidationError When the `sort_by` field is not a string or is not a valid field in the Model.
+ */
+ private function validate_sort_by(): void {
+ # Only validate this field if the client specifically requested it in the request data
+ if (isset($this->request_data['sort_by'])) {
+ # Ensure value is an array
+ $this->request_data['sort_by'] = is_array($this->request_data['sort_by'])
+ ? $this->request_data['sort_by']
+ : [$this->request_data['sort_by']];
+
+ # Check each field in the array
+ foreach ($this->request_data['sort_by'] as $sort_by) {
+ # Ensure value is a string
+ if (!is_string($sort_by)) {
+ throw new ValidationError(
+ message: 'Field `sort_by` must be of type `string`.',
+ response_id: 'ENDPOINT_SORT_BY_FIELD_INVALID_TYPE',
+ );
+ }
+
+ # Ensure the field is a valid field in the Model
+ if (!in_array($sort_by, $this->model->get_fields())) {
+ throw new ValidationError(
+ message: 'Field `sort_by` must be a valid field in the Model.',
+ response_id: 'ENDPOINT_SORT_BY_FIELD_NON_EXISTENT_FIELD',
+ );
+ }
+ }
+
+ # Update the sort_by property to use the client's requested value and remove it from the request data
+ $this->sort_by = $this->request_data['sort_by'];
+ unset($this->request_data['sort_by']);
+ }
+ }
+
+ /**
+ * Validates the $sort_order common control parameter for this request.
+ * @note If the `sort_order` field was not provided in the request data, the request will default to the Endpoint's assigned
+ * $sort_order property value.
+ * @throws ValidationError When the `sort_order` field is not a string or is not a valid sort order constant.
+ */
+ private function validate_sort_order(): void {
+ # Only validate this field if the client specifically requested it in the request data
+ if (isset($this->request_data['sort_order'])) {
+ # Ensure value is a string
+ if (!is_string($this->request_data['sort_order'])) {
+ throw new ValidationError(
+ message: 'Field `sort_order` must be of type `string`.',
+ response_id: 'ENDPOINT_SORT_ORDER_FIELD_INVALID_TYPE',
+ );
+ }
+
+ # Ensure the field is a valid sort order constant
+ if (!in_array($this->request_data['sort_order'], ['SORT_ASC', 'SORT_DESC'])) {
+ throw new ValidationError(
+ message: 'Field `sort_order` must be one of: [\'SORT_ASC\', \'SORT_DESC\'].',
+ response_id: 'ENDPOINT_SORT_ORDER_FIELD_UNKNOWN_SORT_ORDER',
+ );
+ }
+
+ # Update the sort_order property to use the client's requested value and remove it from the request data
+ $this->sort_order = $this->request_data['sort_order'];
+ unset($this->request_data['sort_order']);
+ }
+ }
+
/**
* Validates the $append common control parameter for this request.
* @note If the `append` field was not provided in the request data, the request will default to the Endpoint's assigned
@@ -810,6 +962,8 @@ class Endpoint {
# Ensure control parameters are of the correct type if the client requested them
$this->validate_async();
$this->validate_reverse();
+ $this->validate_sort_by();
+ $this->validate_sort_order();
$this->validate_append();
$this->validate_remove();
$this->validate_limit();
@@ -846,6 +1000,9 @@ class Endpoint {
# This will throw an error if the client's Accept header is not supported by an existing ContentHandler
$content_handler = ContentHandler::get_encode_handler();
+ # Ensure the ContentHandler is also supported by this Endpoint
+ $this->check_encode_content_handler_supported($content_handler);
+
# Checks that request data was properly received and parsed.
$this->check_request_method();
$this->check_request_data();
@@ -896,9 +1053,19 @@ class Endpoint {
);
}
+ # Send the Response through the response handler for any final processing
+ $response = $this->response_handler($response);
+
# Format the HTTP response as JSON and set response code
- http_response_code($response->code);
- return $content_handler->encode($response->to_representation()) . PHP_EOL;
+ try {
+ http_response_code($response->code);
+ return $content_handler->encode($response->to_representation(), context: $response) . PHP_EOL;
+ } catch (Response $response) {
+ # If an error occurs during the response encoding process, return the error message as JSON
+ http_response_code($response->code);
+ $content_handler = new JSONContentHandler();
+ return $content_handler->encode($response->to_representation(), context: $response) . PHP_EOL;
+ }
}
/**
@@ -913,6 +1080,8 @@ class Endpoint {
limit: $this->limit,
offset: $this->offset,
reverse: $this->reverse,
+ sort_by: $this->sort_by,
+ sort_order: constant($this->sort_order),
);
}
# For GET requests on many Endpoints, obtain all objects from the assigned Model.
@@ -928,8 +1097,16 @@ class Endpoint {
protected function post(): Model|ModelSet {
# POST request cannot include an ID, strip the ID if present
unset($this->request_data['id']);
+
+ # Construct the model from representation using the client's request data
$this->model->from_representation(data: $this->request_data);
$this->model->placement = $this->request_data['placement'] ?? null;
+
+ # Allow the endpoint/client to override the Model's sort_by and sort_order properties
+ $this->model->sort_by = $this->sort_by ?? $this->model->sort_by;
+ $this->model->sort_order = constant($this->sort_order) ?? $this->model->sort_order;
+
+ # Create the object and return it
return $this->model->create(apply: $this->request_data['apply'] === true);
}
@@ -944,8 +1121,15 @@ class Endpoint {
* Updates an existing object for the assigned Model using the data submitted in a PATCH request.
*/
protected function patch(): Model|ModelSet {
+ # Construct the model from representation using the client's request data
$this->model->from_representation(data: $this->request_data);
$this->model->placement = $this->request_data['placement'] ?? null;
+
+ # Allow the endpoint/client to override the Model's sort_by and sort_order properties
+ $this->model->sort_by = $this->sort_by ?? $this->model->sort_by;
+ $this->model->sort_order = constant($this->sort_order) ?? $this->model->sort_order;
+
+ # Update the object and return it
return $this->model->update(
apply: $this->request_data['apply'] === true,
append: $this->append,
@@ -986,6 +1170,17 @@ class Endpoint {
header('Allow: ' . implode(', ', $this->request_method_options));
}
+ /**
+ * Defines a middleware method that can be used to handle the call's Response object before it is sent back to the
+ * client. This can be used by child Endpoints to extend or override the default Response handling behavior.
+ * @param Response $response The Response object handle before it is sent back to the client by process_request()
+ * @returns Response The Response object to send back to the client.
+ */
+ public function response_handler(Response $response): Response {
+ # Simply return the Response object as-is by default
+ return $response;
+ }
+
/**
* Obtains the absolute filepath to the PHP file generated for this Endpoint.
* @return string The absolute filepath to this Endpoint's PHP file within the pfSense webroot.
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc
index 314463be..39219fee 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc
@@ -22,6 +22,7 @@ use RESTAPI\Responses\ConflictError;
use RESTAPI\Responses\ForbiddenError;
use RESTAPI\Responses\NotFoundError;
use RESTAPI\Responses\ServerError;
+use RESTAPI\Responses\ServiceUnavailableError;
use RESTAPI\Responses\ValidationError;
use function RESTAPI\Core\Tools\get_classes_from_namespace;
@@ -37,6 +38,11 @@ const DEFAULT_CLIENT_IP_ADDRESS = '127.0.0.1';
class Model {
use BaseTraits;
+ /**
+ * @const string $READ_LOCK_FILE
+ */
+ const WRITE_LOCK_FILE = '/tmp/.RESTAPI.write_config.lock';
+
/**
* @var string|null $config_path
* The configuration path where this Model's object(s) are internally stored. This property is mandatory unless
@@ -75,6 +81,14 @@ class Model {
*/
public mixed $id = null;
+ /**
+ * @var string $id_type
+ * The data type of the ID field for this Model. In most cases, this should be set to 'integer'. In some cases,
+ * the ID field may be a 'string'. This field should be set accordingly to ensure documentation and validation
+ * processes are accurate. This applies exclusively to Models with $many enabled.
+ */
+ public string $id_type = 'integer';
+
/**
* @var mixed $parent_id
* For Models acting as children to a different Model class, this property will contain the ID of the parent model
@@ -82,6 +96,14 @@ class Model {
*/
public mixed $parent_id = null;
+ /**
+ * @var string $parent_id_type
+ * The data type of the parent ID field for this Model. In most cases, this should be set to 'integer'. In some cases,
+ * the parent ID field may be a 'string'. This field should be set accordingly to ensure documentation and validation
+ * processes are accurate. This applies exclusively to Models with a $parent_model_class assigned.
+ */
+ public string $parent_id_type = 'integer';
+
/**
* @var string $parent_model_class
* The class name of the parent Model class. This is necessary only if this Model is nested under another Model.
@@ -192,21 +214,21 @@ class Model {
public int|null $placement = null;
/**
- * @var int|null $sort_option
+ * @var int|null $sort_order
* For $many enabled Models, this property can be used to set the PHP sort option used when writing Model objects to
* config. This property is only applicable if the $sort_by_field property is also defined. This property only applies
* to Models with a $config_path defined. For valid value options for this property, refer to:
* https://www.php.net/manual/en/function.array-multisort.php
*/
- public int|null $sort_option = null;
+ public int|null $sort_order = null;
/**
- * @var string|null $sort_by_field
- * Sets the field name this Model will use when sorting objects written to the pfSense configuration. This field must
+ * @var array|null $sort_by
+ * Sets the field names this Model will use when sorting objects written to the pfSense configuration. These fields must
* be set to an existing property name from this Model, and that property must be assigned a Field class. Leave empty
* to disable sorting for this Model. In most cases, sorting should not be used.
*/
- public string|null $sort_by_field = null;
+ public array|null $sort_by = null;
/**
* @var string $subsystem
@@ -615,17 +637,15 @@ class Model {
/**
* Write configuration changes to the config file
* @param string $change_note The message to write to the change log.
+ * @param int $attempts The number of write attempts to make before giving up in the event that a lock is present.
*/
- final protected function write_config(string $change_note) {
- # Local variables
- $lock_file = '/tmp/.RESTAPI.write_config.lock';
-
+ final public function write_config(string $change_note, int $attempts = 60): void {
# Ensure there is not an API config lock, loop until the lock is released
- foreach (range(0, 60) as $attempt) {
+ foreach (range(0, $attempts) as $attempt) {
# Only write to the config if there is not an active lock
- if (!file_exists($lock_file)) {
+ if (!file_exists(self::WRITE_LOCK_FILE)) {
# Create a lock on API config writes while we write changes
- file_put_contents($lock_file, microtime());
+ file_put_contents(self::WRITE_LOCK_FILE, microtime());
# Start a temporary session to write the config that contains the user's username in the config history
session_start();
@@ -640,20 +660,26 @@ class Model {
}
# Remove the temporary session and write lock
- unlink($lock_file);
+ unlink(self::WRITE_LOCK_FILE);
return;
}
# Delay attempts by 1 second
sleep(1);
}
+
+ # If the lock was not released after the attempts, throw an error
+ throw new ServiceUnavailableError(
+ message: 'Failed to write to the configuration file after multiple attempts.',
+ response_id: 'MODEL_WRITE_CONFIG_LOCKED',
+ );
}
/**
* Reloads the configuration to include any changes that may have occurred since the last config reload
* @param bool $force_parse Force an entire reparse of the config.xml file instead of the cached config.
*/
- public function reload_config(bool $force_parse = false): void {
+ public static function reload_config(bool $force_parse = false): void {
global $config;
$config = parse_config(parse: $force_parse);
}
@@ -894,6 +920,9 @@ class Model {
final public function to_representation(): array {
# Variables
$representation = [];
+ $pkg_config = RESTAPI\Models\RESTAPISettings::get_pkg_config();
+ $hateos_enabled = $pkg_config['hateoas'] === 'enabled';
+ $expose_sensitive_fields = $pkg_config['expose_sensitive_fields'] === 'enabled';
# Embed this object's parent ID if set
if (isset($this->parent_id)) {
@@ -907,14 +936,22 @@ class Model {
# Loop through each of this Model's Fields and add its value to a serializable array
foreach ($this->get_fields() as $field) {
- # Only add this field if it is not a `write_only` field.
- if (!$this->$field->write_only) {
- $representation[$field] = $this->$field->value;
+ # Skip adding this field if it's a write only field
+ if ($this->$field->write_only) {
+ continue;
+ }
+
+ # Skip adding this field if it is sensitive and sensitive fields are not exposed
+ if ($this->$field->sensitive and !$expose_sensitive_fields) {
+ continue;
}
+
+ # Add this field's representation value to the representation array
+ $representation[$field] = $this->$field->value;
}
# Append any related HATEOAS links if HATEOAS is enabled
- if (RESTAPI\Models\RESTAPISettings::get_pkg_config()['hateoas'] === 'enabled') {
+ if ($hateos_enabled) {
$representation += $this->get_related_links()->to_representation();
}
@@ -1026,6 +1063,28 @@ class Model {
return null;
}
+ /**
+ * Determines if this Model applies changes immediately or if they must applied manually after changes are made.
+ * This is intended to be used to more accurately determine the 'Applies immediately' value in the API documentation
+ * to prevent misleading information.
+ * @return bool|null Returns true if this Model applies changes immediately, false if changes must be applied
+ * manually, or null if this does not apply to this Model.
+ */
+ public function does_apply_immediately(): ?bool {
+ # If this Model does not have a `config_path` set, this is not relevant. Return null.
+ if (!$this->config_path) {
+ return null;
+ }
+
+ # If this Model has the `always_apply` property set, changes always apply immediately. Return true.
+ if ($this->always_apply) {
+ return true;
+ }
+
+ # Otherwise, changes must be applied manually. Return false.
+ return false;
+ }
+
/**
* Populates an array of field names for this model.
* @param bool $required_only Only obtain required Fields
@@ -1575,9 +1634,9 @@ class Model {
* Sorts `many` Model entries internally before writing the changes to config. This is useful for Model's whose
* internal objects must be written in a specific order.
*/
- protected function sort() {
- # Do not sort if there is no `sort_option` or `sort_by_field` set
- if (!$this->sort_option or !$this->sort_by_field) {
+ protected function sort(): void {
+ # Do not sort if there is no `sort_order` or `sort_by` set
+ if (!$this->sort_order or !$this->sort_by) {
return;
}
@@ -1589,43 +1648,25 @@ class Model {
);
}
- $internal_objects = $this->get_config($this->get_config_path(), []);
- $criteria = [];
-
- # Loop through each rule and map its sort field value to our sort criteria array
- foreach ($internal_objects as $id => $internal_object) {
- # Store the internal object's existing ID so we can locate new IDs after sorting
- $internal_objects[$id]['original_id'] = $id;
-
- # Use the `internal_name` of the assigned `sort_by_field` since we are dealing with internal objects
- $sort_by_field_internal_name = $this->{$this->sort_by_field}->internal_name;
-
- # Map the real field if it's not empty, otherwise assume an empty string
- if (!empty($internal_object[$sort_by_field_internal_name])) {
- $criteria[$id] = $internal_object[$sort_by_field_internal_name];
- } else {
- $criteria[$id] = '';
- }
- }
-
- # Sort the internal objects using the previously determined criteria
- array_multisort($criteria, $this->sort_option, $internal_objects);
+ # Obtain all Model objects for this Model and sort them by the requested criteria
+ $modelset = $this->query(parent_id: $this->parent_id, sort_by: $this->sort_by, sort_order: $this->sort_order);
- # Loop through the sorted internal objects and find $this object's new ID
+ # Loop through the sorted object and assign it's internal value
+ $internal_objects = [];
$id_found = false;
- foreach ($internal_objects as $new_id => $sorted_internal_object) {
- # Check if this sorted internal object contains $this objects original ID
- if (!$id_found and $this->id === $sorted_internal_object['original_id']) {
+ foreach ($modelset->model_objects as $id => $sorted_model_object) {
+ # Assign the internal object to the internal objects array
+ $internal_objects[] = $this->get_config($sorted_model_object->get_config_path())[$sorted_model_object->id];
+
+ # If the sorted object's ID matches this object's ID, update this object's ID.
+ if (!$id_found and $this->id === $sorted_model_object->id) {
+ $this->id = $id;
$id_found = true;
- $this->id = $new_id;
}
-
- # Remove the `original_id` value so we don't save it to config
- unset($internal_objects[$new_id]['original_id']);
}
# Sets the sorted internal objects to the pfSense config
- $this->set_config($this->get_config_path(), array_values($internal_objects));
+ $this->set_config($this->get_config_path(), $internal_objects);
}
/**
@@ -1818,19 +1859,24 @@ class Model {
int $limit = 0,
int $offset = 0,
bool $reverse = false,
+ ?array $sort_by = null,
+ int $sort_order = SORT_ASC,
...$vl_query_params,
): ModelSet {
# Merge the $query_params and any provided variable-length arguments into a single variable
$query_params = array_merge($query_params, $vl_query_params);
- # If no query parameters were provided, just run read_all() with pagination for optimal performance
- if (!$query_params) {
+ # If no query or sort parameters were provided, just run read_all() with pagination for optimal performance
+ if (!$query_params and $sort_by === null) {
return self::read_all(parent_id: $parent_id, limit: $limit, offset: $offset, reverse: $reverse);
}
# Perform the query against all Model objects for this Model first
$modelset = self::read_all(parent_id: $parent_id)->query(query_params: $query_params, excluded: $excluded);
+ # Sort the set if a sort field was provided
+ $modelset = $sort_by ? $modelset->sort(fields: $sort_by, order: $sort_order, retain_ids: true) : $modelset;
+
# Apply pagination to limit the number of objects returned and/or reverse the order if requested
$modelset->model_objects = self::paginate($modelset->model_objects, $limit, $offset);
return $reverse ? $modelset->reverse() : $modelset;
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc
index 17845efc..ff824c07 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/ModelSet.inc
@@ -79,6 +79,46 @@ class ModelSet {
return new ModelSet(array_reverse($this->model_objects));
}
+ /**
+ * Sorts the Model objects in this ModelSet by a specific field and order.
+ * @param string|array $fields The field(s) to sort the Model objects by.
+ * @param int $order The order to sort the Model objects by. This must be a PHP sort order constant.
+ * @param bool $retain_ids Retain the original Model object IDs when sorting.
+ * @return ModelSet A new ModelSet object with the Model objects sorted by the specified field and order.
+ */
+ public function sort(string|array $fields, int $order = SORT_ASC, bool $retain_ids = false): ModelSet {
+ # Variables
+ $model_objects = $this->model_objects;
+ $fields = is_array($fields) ? $fields : [$fields];
+ $sort_criteria = [];
+
+ # Loop through each Model object and add the field values to the sort criteria
+ foreach ($this->model_objects as $model_object) {
+ # Loop variables
+ $sort_values = [];
+
+ # Loop through each field to sort by and extract it's value
+ foreach ($fields as $field) {
+ $sort_values[] = $field === 'id' ? $model_object->id : $model_object->$field->value;
+ }
+
+ # Add the sort values to the sort criteria
+ $sort_criteria[] = $sort_values;
+ }
+
+ # Sort using array_multisort
+ array_multisort($sort_criteria, $order, $model_objects);
+
+ # Re-assign the model object IDs if they should not be retained
+ if (!$retain_ids) {
+ foreach ($model_objects as $id => $model_object) {
+ $model_object->id = $id;
+ }
+ }
+
+ return new ModelSet($model_objects);
+ }
+
/**
* Runs the to_representation() method for each model object within the model set and returns them all in an array.
* @return array A serializable representation of all model objects within this model set.
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Response.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Response.inc
index c64fec5a..f00fc663 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Response.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Response.inc
@@ -19,7 +19,6 @@ require_once 'RESTAPI/autoloader.inc';
use Error;
use RESTAPI\Models\RESTAPISettings;
-use function RESTAPI\Core\Tools\get_classes_from_namespace;
const HTTP_STATUS_MESSAGES = [
100 => 'continue',
@@ -131,7 +130,7 @@ class Response extends Error {
* Converts this Response object to an array representation of its contents.
* @return array An array containing the response details.
*/
- final public function to_representation(): array {
+ public function to_representation(): array {
# Set the standard representation for the Response
$representation = [
'code' => $this->code,
@@ -165,50 +164,23 @@ class Response extends Error {
}
}
- /**
- * Represents this Response object as an OpenAPI response component. This component details which content-types
- * are allowed by the Accept header.
- * @return array This Response object as an OpenAPI response component
- */
- final public function to_openapi_component(): array {
- # Variables
- $component = ['description' => $this->help_text, 'content' => []];
- $content_handler_classes = get_classes_from_namespace('\\RESTAPI\\ContentHandlers\\');
-
- # Default to application/json
- $component['content']['application/json'] = ['schema' => ['$ref' => '#/components/schemas/Response']];
-
- # Populate a component content schema for each ContentHandler that can encode content
- foreach ($content_handler_classes as $content_handler_class) {
- # Create the content handler object
- $content_handler = new $content_handler_class();
-
- # Only include this content if it can encode responses
- if ($content_handler->can_encode()) {
- $component['content'][$content_handler->mime_type] = [
- 'schema' => ['$ref' => '#/components/schemas/Response'],
- ];
- }
- }
-
- return $component;
- }
-
/**
* Represents the Response object as an OpenAPI schema.
* @return array This Response object as an OpenAPI schema.
*/
- final public static function to_openapi_schema(): array {
+ public function to_openapi_schema(): array {
return [
'type' => 'object',
'properties' => [
'code' => [
'description' => 'The HTTP status code that corresponds with the API response.',
'type' => 'integer',
+ 'default' => $this->code,
],
'status' => [
'description' => 'The HTTP status message that corresponds with the HTTP status code.',
'type' => 'string',
+ 'default' => $this->status,
],
'response_id' => [
'description' =>
@@ -225,7 +197,6 @@ class Response extends Error {
'The data requested from the API. In the event that many objects have' .
'been requested, this field will be an array of objects. Otherwise, it will only return' .
'the single object requested.',
- 'example' => [],
'oneOf' => [['type' => 'array', 'items' => ['type' => 'object']], ['type' => 'object']],
],
'_links' => [
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Schema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Schema.inc
new file mode 100644
index 00000000..ed046a97
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Schema.inc
@@ -0,0 +1,84 @@
+file_path = self::SCHEMA_DIR . $this->file_name;
+ }
+
+ /**
+ * Obtains the schema as a string. This method must be implemented by the child class and is used to generate
+ * the schema file for this class.
+ * @return string The schema as a string.
+ */
+ abstract public function get_schema_str(): string;
+
+ /**
+ * Builds a unique endpoint URL for this schema in the pfSense webroot directory.
+ */
+ public function build_schema_url(): void {
+ $name = pathinfo($this->file_name, PATHINFO_FILENAME);
+
+ # Specify the PHP code to write to the Endpoints index.php file
+ $code =
+ "content_type');\n" .
+ "header('Referer: no-referrer');\n" .
+ "echo (new {$this->get_class_fqn()}())->read_schema_from_file();\n" .
+ "exit();\n";
+
+ # Write the code to the schema's index.php file
+ mkdir("/usr/local/www/api/v2/schema/$name", 0755, true);
+ file_put_contents("/usr/local/www/api/v2/schema/$name/index.php", $code);
+ }
+
+ /**
+ * Saves the schema to file.
+ */
+ public function save_schema(): void {
+ file_put_contents($this->file_path, $this->get_schema_str());
+ }
+
+ /**
+ * Reads the schema from file.
+ * @return string The schema as a string.
+ */
+ public function read_schema_from_file(): string {
+ return file_get_contents($this->file_path);
+ }
+}
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 5aeb3987..b9a2c97e 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
@@ -100,6 +100,7 @@ class TestCase {
public function install_packages(): void {
foreach ($this->required_packages as $package) {
new Command("pkg install -y $package");
+ Model::reload_config(true);
}
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc
index 97ff18b6..dfede704 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc
@@ -15,10 +15,7 @@
namespace RESTAPI\Core\Tools;
-use RESTAPI\Core\Response;
-use RESTAPI\Models\RESTAPIVersion;
use RESTAPI\Responses\ServerError;
-use stdClass;
require_once 'RESTAPI/autoloader.inc';
@@ -45,6 +42,20 @@ function is_assoc_array(array $array): bool {
return false;
}
+/**
+ * Converts a string to UpperCamelCase.
+ * @param string $string The string to convert to UpperCamelCase.
+ * @return string The string converted to UpperCamelCase.
+ */
+function to_upper_camel_case(string $string): string {
+ # Remove non-alphanumeric characters and convert to lowercase
+ $string = preg_replace('/[^a-zA-Z0-9]+/', ' ', $string);
+ $string = trim($string);
+
+ # Capitalize the first letter of each word and remove whitespace
+ return str_replace(' ', '', ucwords($string));
+}
+
/**
* Conditionally prints a message to the console. The specified $message will only be printed if $condition is true.
* @param string $message The message to be printed if the $condition is true.
@@ -157,346 +168,3 @@ function get_classes_from_namespace(string $namespace, bool $shortnames = false)
return $classes;
}
-
-/**
- * Auto-generates OpenAPI documentation for all Endpoints and their associated Models.
- */
-function generate_documentation(): bool {
- # Variables
- $endpoint_classes = get_classes_from_namespace('\\RESTAPI\\Endpoints\\');
- $response_classes = get_classes_from_namespace('\\RESTAPI\\Responses\\');
- $auth_classes = get_classes_from_namespace('\\RESTAPI\\Auth\\');
- $auth_classes_short = get_classes_from_namespace('\\RESTAPI\\Auth\\', shortnames: true);
- $content_handler_classes = get_classes_from_namespace('\\RESTAPI\\ContentHandlers\\');
- $restapi_version = new RESTAPIVersion();
- $assigned_tags = [];
-
- # Set static openapi details
- $openapi_config = [
- 'openapi' => '3.0.0',
- 'servers' => [['url' => '/', 'description' => 'This firewall']],
- 'info' => [
- 'title' => 'pfSense REST API Documentation',
- 'version' => $restapi_version->current_version->value,
- 'contact' => [
- 'name' => 'GitHub',
- 'url' => 'https://github.com/jaredhendrickson13/pfsense-api',
- ],
- 'license' => [
- 'name' => 'Apache 2.0',
- 'url' => 'https://raw.githubusercontent.com/jaredhendrickson13/pfsense-api/master/LICENSE',
- ],
- 'description' =>
- '### Getting Started' .
- '
' .
- '- [Authentication and Authorization](https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
' .
- '- [Working with Object IDs](https://pfrest.org/WORKING_WITH_OBJECT_IDS/)
' .
- '- [Queries and Filters](https://pfrest.org/QUERIES_AND_FILTERS/)
' .
- '- [Common Control Parameters](https://pfrest.org/COMMON_CONTROL_PARAMETERS/)
' .
- '- [Working with HATEOAS](https://pfrest.org/WORKING_WITH_HATEOAS/)
',
- ],
- 'components' => [
- 'responses' => [],
- 'schemas' => ['Response' => Response::to_openapi_schema()],
- 'securitySchemes' => [],
- ],
- 'security' => [],
- 'paths' => [],
- 'tags' => [],
- ];
-
- # Add security and securitySchemes definitions for each Auth class
- foreach ($auth_classes as $auth_class) {
- # Create an object for this Auth class so we can obtain class information
- $auth_method = new $auth_class();
- $auth_shortname = $auth_method->get_class_shortname();
-
- # Add global security definitions.
- $openapi_config['security'][] = [$auth_shortname => []];
-
- # Add securitySchemes for each \RESTAPI\Auth class
- $openapi_config['components']['securitySchemes'][$auth_shortname] = $auth_method->security_scheme;
- }
-
- # Add Response components for each Response class in RESTAPI\Responses
- foreach ($response_classes as $response_class) {
- # Create the Response object
- $response = new $response_class(message: '', response_id: '');
- $resp_shortname = $response->get_class_shortname();
- $openapi_config['components']['responses'][$resp_shortname] = $response->to_openapi_component();
- }
-
- # Import each Endpoint class and assign openapi for the endpoint according to the options and Model assigned.
- foreach ($endpoint_classes as $endpoint_class) {
- # Create the Response object
- $endpoint = new $endpoint_class();
-
- # Add this Endpoint's URL to the OpenAPI `paths`
- $openapi_config['paths'][$endpoint->url] = [];
-
- # Initialize this endpoint's OpenAPI tag
- if (!in_array($endpoint->tag, $assigned_tags)) {
- $openapi_config['tags'][] = ['name' => $endpoint->tag];
- $assigned_tags[] = $endpoint->tag;
- }
-
- # Obtain the Model assigned to the Endpoint and create any assigned parent Model's
- $model = "\\RESTAPI\\Models\\$endpoint->model_name";
- $model = new $model();
- $model->get_parent_model();
-
- # Obtain the OpenAPI schema for this Model.
- $openapi_config['components']['schemas'][$endpoint->model_name] = $model->to_openapi_schema();
-
- # Assign shared values to each request method defined in this path
- foreach ($endpoint->request_method_options as $request_method) {
- # Convert the request method to lower case so it matches the OpenAPI config
- $request_method = strtolower($request_method);
-
- # Obtain the privileges and help text associated with this request method
- $privilege_property = $request_method . '_privileges';
- $help_text_property = $request_method . '_help_text';
-
- # Assign endpoint details to variables
- $help_text = $endpoint->$help_text_property;
- $endpoint_type = $endpoint->many ? 'Plural' : 'Singular';
- $parent_model_class = $model->parent_model_class ?: 'None';
- $priv_options_str = implode(', ', $endpoint->$privilege_property);
- $required_packages_str = $model->packages ? implode(', ', $model->packages) : 'None';
- $requires_auth_str = $endpoint->requires_auth ? 'Yes' : 'No';
- $auth_method_str = implode(', ', $endpoint->auth_methods ?: $auth_classes_short);
- $applies_immediately = $model->always_apply ? 'Yes' : 'No';
- $cache_class = $model->cache_class ?: 'None';
- $operation_id = "$request_method{$endpoint->get_class_shortname()}";
-
- # Add openapi for all requests at this path
- $openapi_config['paths'][$endpoint->url][$request_method] = [
- 'responses' => [],
- 'operationId' => $operation_id,
- 'deprecated' => $endpoint->deprecated,
- 'description' =>
- 'Description:
' .
- "$help_text
" .
- 'Details:
' .
- "**Endpoint type**: $endpoint_type
" .
- "**Associated model**: $endpoint->model_name
" .
- "**Parent model**: $parent_model_class
" .
- "**Requires authentication**: $requires_auth_str
" .
- "**Supported authentication modes:** [ $auth_method_str ]
" .
- "**Allowed privileges**: [ $priv_options_str ]
" .
- "**Required packages**: [ $required_packages_str ]
" .
- "**Applies immediately**: $applies_immediately
" .
- "**Utilizes cache**: $cache_class",
- ];
-
- # Nest this endpoint under its assigned or assumed tag
- $openapi_config['paths'][$endpoint->url][$request_method]['tags'] = [$endpoint->tag];
-
- # Ensure the security mode is enforced for this path if the Endpoint has `auth_methods` set
- if ($endpoint->auth_methods) {
- foreach ($endpoint->auth_methods as $auth_method) {
- $openapi_config['paths'][$endpoint->url][$request_method]['security'][] = [$auth_method => []];
- }
- }
-
- # Assign request body definitions for POST, PUT and PATCH requests
- if (in_array($request_method, ['post', 'put', 'patch'])) {
- # Only include required fields in the $allOf schema if there are required fields for this Model
- $allof_schema = ['type' => 'object'];
- $required_fields = $model->get_fields(required_only: true);
- if ($required_fields) {
- $allof_schema['required'] = $required_fields;
- }
-
- # For non `many` Endpoints with `many` Models, add the ID to the schema and make it required
- if (!$endpoint->many and $model->many and $request_method !== 'post') {
- $schema = [
- 'schema' => [
- 'allOf' => [
- [
- 'type' => 'object',
- 'required' => ['id'],
- 'properties' => [
- 'id' => [
- 'type' => 'integer',
- 'description' => 'The ID of the object or resource to interact with.',
- ],
- ],
- ],
- ['$ref' => "#/components/schemas/$endpoint->model_name"],
- ],
- ],
- ];
- }
- # For `many` Endpoints with `many` Models, accept arrays of many schema objects
- elseif ($endpoint->many and $model->many) {
- # Write the schema objects with any required fields
- $schema = [
- 'schema' => [
- 'type' => 'array',
- 'items' => [
- 'allOf' => [['$ref' => "#/components/schemas/$endpoint->model_name"], $allof_schema],
- ],
- ],
- ];
- }
- # Otherwise, just assign the schema with all required Fields included
- else {
- $schema = [
- 'schema' => [
- 'allOf' => [['$ref' => "#/components/schemas/$endpoint->model_name"], $allof_schema],
- ],
- ];
- }
-
- # Add the `parent_id` field to Models with a `many` parent
- if ($model->parent_model_class and $model->parent_model->many) {
- array_unshift($schema['schema']['allOf'], [
- 'type' => 'object',
- 'required' => ['parent_id'],
- 'properties' => [
- 'parent_id' => [
- 'type' => 'integer',
- 'description' => 'The ID of the parent this object is nested under.',
- ],
- ],
- ]);
- }
-
- # Populate OpenAPI 'content' definitions for each ContentHandler capable of decoding request bodies.
- $contents = [];
- foreach ($content_handler_classes as $content_handler_class) {
- # Create an object for this content handler so we can extract handler info
- $content_handler = new $content_handler_class();
-
- # Only include this ContentHandler as a valid content definition if it can decode data
- if ($content_handler->can_decode()) {
- $contents[$content_handler->mime_type] = $schema;
- }
- }
- $openapi_config['paths'][$endpoint->url][$request_method]['requestBody']['content'] = $contents;
- }
-
- # Assign the ID query parameter for GET and DELETE requests to non `many` Endpoints with a `many` Model assigned
- if (!$endpoint->many and $model->many and in_array($request_method, ['get', 'delete'])) {
- $openapi_config['paths'][$endpoint->url][$request_method]['parameters'] = [
- [
- 'in' => 'query',
- 'name' => 'id',
- 'description' => 'The ID of the object to target.',
- 'required' => true,
- 'schema' => [
- 'oneOf' => [['type' => 'integer'], ['type' => 'string']],
- ],
- ],
- ];
-
- # Add the `parent_id` parameter if this model has a parent model assigned
- if ($model->parent_model_class and $model->parent_model->many) {
- array_unshift($openapi_config['paths'][$endpoint->url][$request_method]['parameters'], [
- 'in' => 'query',
- 'name' => 'parent_id',
- 'description' => 'The ID of the parent this object is nested under.',
- 'required' => true,
- 'schema' => [
- 'oneOf' => [['type' => 'integer'], ['type' => 'string']],
- ],
- ]);
- }
-
- # Add the `apply` parameter if this is a DELETE request
- if ($request_method == 'delete' and $model->subsystem) {
- $openapi_config['paths'][$endpoint->url][$request_method]['parameters'][] = [
- 'in' => 'query',
- 'name' => 'apply',
- 'description' => 'Apply this deletion immediately.',
- 'required' => false,
- 'schema' => [
- 'type' => 'boolean',
- 'default' => false,
- ],
- ];
- }
- }
-
- # Assign the limit and offset to GET endpoints with $many enabled
- if ($endpoint->many and $request_method === 'get') {
- $openapi_config['paths'][$endpoint->url][$request_method]['parameters'] = [
- [
- 'in' => 'query',
- 'name' => 'limit',
- 'description' => 'The number of objects to obtain at once. Set to 0 for no limit.',
- 'schema' => ['type' => 'integer', 'default' => $endpoint->limit],
- ],
- [
- 'in' => 'query',
- 'name' => 'offset',
- 'description' => 'The starting point in the dataset to begin fetching objects.',
- 'schema' => ['type' => 'integer', 'default' => $endpoint->offset],
- ],
- [
- 'in' => 'query',
- 'name' => 'query',
- 'style' => 'form',
- 'explode' => true,
- 'description' =>
- 'The arbitrary query parameters to include in the request.
' .
- 'Note: This does not define an real parameter, rather it allows for any arbitrary query ' .
- 'parameters to be included in the request.',
- 'schema' => [
- 'type' => 'object',
- 'default' => new stdClass(),
- 'additionalProperties' => ['type' => 'string'],
- ],
- ],
- ];
- }
-
- # Assign the limit and offset to DELETE endpoints with $many enabled
- if ($endpoint->many and $request_method === 'delete') {
- $openapi_config['paths'][$endpoint->url][$request_method]['parameters'] = [
- [
- 'in' => 'query',
- 'name' => 'limit',
- 'description' => 'The maximum number of objects to delete at once. Set to 0 for no limit.',
- 'schema' => ['type' => 'integer', 'default' => $endpoint->limit],
- ],
- [
- 'in' => 'query',
- 'name' => 'offset',
- 'description' => 'The starting point in the dataset to begin fetching objects.',
- 'schema' => ['type' => 'integer', 'default' => $endpoint->offset],
- ],
- [
- 'in' => 'query',
- 'name' => 'query',
- 'style' => 'form',
- 'explode' => true,
- 'description' =>
- 'The arbitrary query parameters to include in the request.
' .
- 'Note: This does not define an actual parameter, rather it allows for any arbitrary query ' .
- 'parameters to be included in the request.',
- 'schema' => [
- 'type' => 'object',
- 'default' => new stdClass(),
- 'additionalProperties' => ['type' => 'string'],
- ],
- ],
- ];
- }
-
- # Assign this request method Responses for each Response class defined.
- foreach ($response_classes as $response_class) {
- $response = new $response_class(message: '', response_id: '');
- $openapi_config['paths'][$endpoint->url][$request_method]['responses'][$response->code] = [
- '$ref' => '#/components/responses/' . $response->get_class_shortname(),
- ];
- }
- }
- }
-
- return (bool) file_put_contents(
- filename: '/usr/local/pkg/RESTAPI/.resources/schema.json',
- data: json_encode($openapi_config) . PHP_EOL,
- );
-}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Dispatchers/BINDApplyDispatcher.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Dispatchers/BINDApplyDispatcher.inc
new file mode 100644
index 00000000..e2fd4e4a
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Dispatchers/BINDApplyDispatcher.inc
@@ -0,0 +1,22 @@
+url = '/api/v2/graphql';
+ $this->model_name = 'GraphQL';
+ $this->request_method_options = ['POST'];
+ $this->response_types = ['GraphQLResponse'];
+
+ # Set help text for this Endpoint
+ $this->post_help_text =
+ 'Execute a GraphQL query. For more information on utilizing the GraphQL API, please ' .
+ 'refer to https://pfrest.org/graphql.';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+
+ /**
+ * A custom handler used to convert the incoming request data into a GraphQL response.
+ * @param Response $response The incoming response object.
+ * @return Response The response object to return to the client.
+ */
+ public function response_handler(Response $response): Response {
+ # For non-successful responses, convert the error response into a GraphQL response.
+ if ($response->code !== 200) {
+ return GraphQLResponse::to_graphql_response($response);
+ }
+
+ # Otherwise, create a response using the GraphQL model's ExecutionResult object.
+ return new GraphQLResponse(data: $this->model);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEndpoint.inc
new file mode 100644
index 00000000..aa3e4357
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/access_list';
+ $this->model_name = 'BINDAccessList';
+ $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/ServicesBINDAccessListEntryEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEntryEndpoint.inc
new file mode 100644
index 00000000..2da6e250
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListEntryEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/access_list/entry';
+ $this->model_name = 'BINDAccessListEntry';
+ $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/ServicesBINDAccessListsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListsEndpoint.inc
new file mode 100644
index 00000000..ce3a5bfe
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDAccessListsEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/services/bind/access_lists';
+ $this->model_name = 'BINDAccessList';
+ $this->request_method_options = ['GET', 'PUT', 'DELETE'];
+ $this->many = true;
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSettingsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSettingsEndpoint.inc
new file mode 100644
index 00000000..0cdbcad0
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSettingsEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/settings';
+ $this->model_name = 'BINDSettings';
+ $this->request_method_options = ['GET', 'PATCH'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncRemoteHostEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncRemoteHostEndpoint.inc
new file mode 100644
index 00000000..4acc2614
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncRemoteHostEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/sync/remote_host';
+ $this->model_name = 'BINDSyncRemoteHost';
+ $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/ServicesBINDSyncRemoteHostsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncRemoteHostsEndpoint.inc
new file mode 100644
index 00000000..1399c688
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncRemoteHostsEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/services/bind/sync/remote_hosts';
+ $this->model_name = 'BINDSyncRemoteHost';
+ $this->request_method_options = ['GET', 'PUT', 'DELETE'];
+ $this->many = true;
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncSettingsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncSettingsEndpoint.inc
new file mode 100644
index 00000000..19cb2567
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDSyncSettingsEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/sync/settings';
+ $this->model_name = 'BINDSyncSettings';
+ $this->request_method_options = ['GET', 'PATCH'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDViewEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDViewEndpoint.inc
new file mode 100644
index 00000000..994ad509
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDViewEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/view';
+ $this->model_name = 'BINDView';
+ $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/ServicesBINDViewsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDViewsEndpoint.inc
new file mode 100644
index 00000000..f9542998
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDViewsEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/services/bind/views';
+ $this->model_name = 'BINDView';
+ $this->request_method_options = ['GET', 'PUT', 'DELETE'];
+ $this->many = true;
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZoneEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZoneEndpoint.inc
new file mode 100644
index 00000000..1d605b14
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZoneEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/zone';
+ $this->model_name = 'BINDZone';
+ $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/ServicesBINDZoneRecordEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZoneRecordEndpoint.inc
new file mode 100644
index 00000000..756bfde3
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZoneRecordEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/services/bind/zone/record';
+ $this->model_name = 'BINDZoneRecord';
+ $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/ServicesBINDZonesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZonesEndpoint.inc
new file mode 100644
index 00000000..ffbe5360
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesBINDZonesEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/services/bind/zones';
+ $this->model_name = 'BINDZone';
+ $this->request_method_options = ['GET', 'PUT', 'DELETE'];
+ $this->many = true;
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusDHCPServerLeasesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusDHCPServerLeasesEndpoint.inc
index ac26a481..287485c3 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusDHCPServerLeasesEndpoint.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusDHCPServerLeasesEndpoint.inc
@@ -16,7 +16,7 @@ class StatusDHCPServerLeasesEndpoint extends Endpoint {
$this->url = '/api/v2/status/dhcp_server/leases';
$this->model_name = 'DHCPServerLease';
$this->many = true;
- $this->request_method_options = ['GET'];
+ $this->request_method_options = ['GET', 'DELETE'];
# Construct the parent Endpoint object
parent::__construct();
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAEndpoint.inc
new file mode 100644
index 00000000..38bc656a
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecChildSAEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/status/ipsec/child_sa';
+ $this->model_name = 'IPsecChildSAStatus';
+ $this->request_method_options = ['GET'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecSAEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecSAEndpoint.inc
new file mode 100644
index 00000000..d941058b
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecSAEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/status/ipsec/sas';
+ $this->model_name = 'IPsecSAStatus';
+ $this->request_method_options = ['GET'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecSAsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecSAsEndpoint.inc
new file mode 100644
index 00000000..27a9756d
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusIPsecSAsEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/status/ipsec/sas';
+ $this->model_name = 'IPsecSAStatus';
+ $this->many = true;
+ $this->request_method_options = ['GET'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsSettingsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsSettingsEndpoint.inc
new file mode 100644
index 00000000..d33117ac
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusLogsSettingsEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/status/logs/settings';
+ $this->model_name = 'LogSettings';
+ $this->request_method_options = ['GET', 'PATCH'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCRLEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCRLEndpoint.inc
index 90c54ea7..3c4b93f6 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCRLEndpoint.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCRLEndpoint.inc
@@ -15,7 +15,7 @@ class SystemCRLEndpoint extends Endpoint {
# Set Endpoint attributes
$this->url = '/api/v2/system/crl';
$this->model_name = 'CertificateRevocationList';
- $this->request_method_options = ['GET', 'POST', 'DELETE'];
+ $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/SystemCRLRevokedCertificateEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCRLRevokedCertificateEndpoint.inc
new file mode 100644
index 00000000..3ed907f0
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCRLRevokedCertificateEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/system/crl/revoked_certificate';
+ $this->model_name = 'CertificateRevocationListRevokedCertificate';
+ $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/SystemCertificateAuthorityGenerateEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateAuthorityGenerateEndpoint.inc
new file mode 100644
index 00000000..19bea132
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateAuthorityGenerateEndpoint.inc
@@ -0,0 +1,26 @@
+url = '/api/v2/system/certificate_authority/generate';
+ $this->model_name = 'CertificateAuthorityGenerate';
+ $this->request_method_options = ['POST'];
+
+ # Set help text
+ $this->post_help_text = 'Generate a new internal or intermediate certificate.';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateAuthorityRenewEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateAuthorityRenewEndpoint.inc
new file mode 100644
index 00000000..3c21c1a2
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateAuthorityRenewEndpoint.inc
@@ -0,0 +1,26 @@
+url = '/api/v2/system/certificate_authority/renew';
+ $this->model_name = 'CertificateAuthorityRenew';
+ $this->request_method_options = ['POST'];
+
+ # Set help text
+ $this->post_help_text = 'Renews an existing Certificate Authority.';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateGenerateEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateGenerateEndpoint.inc
new file mode 100644
index 00000000..5674e293
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateGenerateEndpoint.inc
@@ -0,0 +1,26 @@
+url = '/api/v2/system/certificate/generate';
+ $this->model_name = 'CertificateGenerate';
+ $this->request_method_options = ['POST'];
+
+ # Set help text
+ $this->post_help_text = 'Generate a new internal certificate.';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificatePKCS12ExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificatePKCS12ExportEndpoint.inc
new file mode 100644
index 00000000..3c12445e
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificatePKCS12ExportEndpoint.inc
@@ -0,0 +1,28 @@
+url = '/api/v2/system/certificate/pkcs12/export';
+ $this->model_name = 'CertificatePKCS12Export';
+ $this->request_method_options = ['POST'];
+ $this->encode_content_handlers = ['BinaryContentHandler']; # Only allow binary DLs (application/octet-stream)
+ # Set help text
+ $this->post_help_text =
+ 'Exports an existing certificate as a PKCS#12 archive. Please note this endpoint will ' .
+ 'return the PKCS#12 archive as a binary download.';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateRenewEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateRenewEndpoint.inc
new file mode 100644
index 00000000..b73d67ef
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateRenewEndpoint.inc
@@ -0,0 +1,26 @@
+url = '/api/v2/system/certificate/renew';
+ $this->model_name = 'CertificateRenew';
+ $this->request_method_options = ['POST'];
+
+ # Set help text
+ $this->post_help_text = 'Renews an existing Certificate.';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestEndpoint.inc
new file mode 100644
index 00000000..355f3cd3
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/system/certificate/signing_request';
+ $this->model_name = 'CertificateSigningRequest';
+ $this->request_method_options = ['POST'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestSignEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestSignEndpoint.inc
new file mode 100644
index 00000000..ab162785
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemCertificateSigningRequestSignEndpoint.inc
@@ -0,0 +1,27 @@
+url = '/api/v2/system/certificate/signing_request/sign';
+ $this->model_name = 'CertificateSigningRequestSign';
+ $this->request_method_options = ['POST'];
+
+ # Set help text
+ $this->post_help_text =
+ 'Signs an existing Certificate Signing Request (CSR) with an ' . 'existing Certificate Authority (CA).';
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/NestedModelField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/NestedModelField.inc
index bc454df6..af5c213c 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/NestedModelField.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/NestedModelField.inc
@@ -16,7 +16,7 @@ class NestedModelField extends Field {
public string $model_class;
private string $model_class_short;
public Model $model;
- private ModelSet $validation_modelset;
+ private ModelSet $modelset;
/**
* @param string $model_class The Model class that is nested within this Field.
@@ -33,6 +33,10 @@ class NestedModelField extends Field {
* @param bool $write_only Set to `true` to make this field write-only. This will prevent the field's current value
* from being displayed in the representation data. This is ideal for potentially sensitive Fields like passwords,
* keys, and hashes.
+ * @param string $internal_name The name of the field as it is represented in the pfSense configuration. This name
+ * should match the name used in the pfSense configuration.
+ * @param string $internal_namespace The namespace of the field as it is represented in the pfSense configuration.
+ * This namespace should match the namespace used in the pfSense configuration.
* @param string $verbose_name The detailed name for this Field. This name will be used in non-programmatic areas
* like web pages and help text. This Field will default to property name assigned to the parent Model with
* underscores converted to spaces.
@@ -54,6 +58,8 @@ class NestedModelField extends Field {
bool $editable = true,
bool $read_only = false,
bool $write_only = false,
+ string $internal_name = '',
+ string $internal_namespace = '',
string $verbose_name = '',
string $verbose_name_plural = '',
array $conditions = [],
@@ -62,12 +68,12 @@ class NestedModelField extends Field {
# Assign attributes specific to this Field
$this->model_class = "\\RESTAPI\\Models\\$model_class";
$this->model_class_short = $model_class;
- $this->model = new $this->model_class();
+ $this->model = new $this->model_class(skip_init: true);
$config_path = explode('/', $this->model->config_path);
# Initialize the validation ModelSet. This is used to keep track of nested Model objects that have already
# been validated.
- $this->validation_modelset = new ModelSet([]);
+ $this->modelset = new ModelSet([]);
# Construct the parent Field object with desired parameters.
parent::__construct(
@@ -84,8 +90,8 @@ class NestedModelField extends Field {
many_maximum: $this->model->many_maximum,
verbose_name: $verbose_name,
verbose_name_plural: $verbose_name_plural,
- internal_name: array_pop($config_path),
- internal_namespace: implode('/', $config_path),
+ internal_name: $internal_name ?: array_pop($config_path),
+ internal_namespace: $internal_namespace ?: implode('/', $config_path),
conditions: $conditions,
help_text: $help_text,
);
@@ -106,12 +112,12 @@ class NestedModelField extends Field {
# Assign the IDs and parent manually using this Field's context in case it does not yet exist in config
$model->parent_id = $this->context->id;
$model->parent_model = $this->context;
- $model->id = $this->validation_modelset->count();
+ $model->id = $this->modelset->count();
# Try to validate the nested Model object
try {
- $model->validate(skip_parent: true, modelset: $this->validation_modelset);
- $this->validation_modelset->model_objects[] = $model;
+ $model->validate(skip_parent: true, modelset: $this->modelset);
+ $this->modelset->model_objects[] = $model;
} catch (Response $resp) {
throw new ValidationError(
message: "Field `$this->name` encountered a nested validation error: $resp->message",
@@ -126,14 +132,23 @@ class NestedModelField extends Field {
* @return bool Returns true if the validation was successful.
*/
public function validate(?ModelSet $modelset = null): bool {
- # Empty any existing ModelSet entries in the event this is a subsequent `validate()` call.
- $this->validation_modelset->model_objects = [];
- $return_value = parent::validate();
+ # Variables
+ $return_value = true;
+
+ # Only validate if the current value is different than our ModelSet's values, or the value is empty
+ if (!$this->value or $this->value !== $this->modelset->to_representation()) {
+ # Empty any existing ModelSet entries in the event this is a subsequent `validate()` call.
+ $this->modelset->model_objects = [];
+ $return_value = parent::validate();
+ }
+
+ # Sort the nested Model objects before re-assigning the value
+ $this->sort();
# Overwrite the existing `value` with the representation of the validated model object
$this->value = $this->many
- ? $this->validation_modelset->to_representation()
- : $this->validation_modelset->first()->to_representation();
+ ? $this->modelset->to_representation()
+ : $this->modelset->first()->to_representation();
return $return_value;
}
@@ -147,20 +162,11 @@ class NestedModelField extends Field {
# For `many` Models, loop through each value and convert it to its internal value
if ($this->model->many) {
- $internal_objects = [];
- foreach ($this->value as $representation_data) {
- # Remove any assigned IDs
- unset($representation_data['id']);
- unset($representation_data['parent_id']);
- $representation_object = new $this->model(data: $representation_data);
- $internal_objects[] = $representation_object->to_internal();
- }
- return $internal_objects;
+ return $this->modelset->to_internal();
}
# Otherwise, just convert the one object
else {
- $representation_object = new $this->model(data: $this->value);
- return $representation_object->to_internal();
+ return $this->modelset->first()->to_internal();
}
}
@@ -169,18 +175,20 @@ class NestedModelField extends Field {
* @param string|null $internal_value The raw internal config value to convert to a representation value.
*/
public function from_internal(mixed $internal_value): void {
- # Create the nested Model object
- $model = new $this->model_class();
+ # Variables
+ $representations = [];
+ $model = new $this->model_class(skip_init: true);
+ $this->modelset->model_objects = [];
# For `many` Models, loop through each value and convert it to its representation value
if ($model->many) {
- $representations = [];
foreach ($internal_value as $id => $internal_data) {
$object = new $this->model_class();
$object->id = $id;
$object->parent_id = $this->context->id;
$object->parent_model = $this->context;
$object->from_internal_object($internal_data);
+ $this->modelset->model_objects[] = $object;
$representation = $object->to_representation();
$representations[] = $representation;
}
@@ -188,37 +196,28 @@ class NestedModelField extends Field {
}
# Otherwise, just convert the one object
else {
- $object = new $this->model_class();
+ $object = new $this->model_class(skip_init: true);
$this->value = $object->to_representation();
}
}
/**
- * Sorts the nested Model data use the nested Model's `sort_option` and `sort_by_field` setting. This method
+ * Sorts the nested Model data use the nested Model's `sort_order` and `sort_by` setting. This method
* closely resembles the Model->sort() method, but does not require values to be set in config beforehand.
*/
public function sort(): void {
# Only sort this Field's values if this is a `many` field and it has a value
if ($this->many and $this->value) {
- # Do not sort if there is no `sort_option` or `sort_by_field` set for this nested Model
- if (!$this->model->sort_option or !$this->model->sort_by_field) {
+ # Do not sort if there is no `sort_order` or `sort_by` set for this nested Model
+ if (!$this->model->sort_order or !$this->model->sort_by) {
return;
}
- # Loop through each rule and map its sort field value to our sort criteria array
- foreach ($this->value as $id => $object) {
- # Map the real field if it's not empty, otherwise assume an empty string
- if (!empty($object[$this->model->sort_by_field])) {
- $criteria[$id] = $object[$this->model->sort_by_field];
- } else {
- $criteria[$id] = '';
- }
- }
-
- # Sort the internal objects using the previously determined criteria
- array_multisort($criteria, $this->model->sort_option, $this->value);
+ # Sort the nested Model objects by the specified field
+ $this->modelset = $this->modelset->sort(fields: $this->model->sort_by, order: $this->model->sort_order);
- $this->value = array_values($this->value);
+ # Re-assign the sorted values to this Field
+ $this->value = $this->modelset->to_representation();
}
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc
new file mode 100644
index 00000000..8b323da0
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Fields/ObjectField.inc
@@ -0,0 +1,167 @@
+ "type1"] to this parameter.
+ * @param array $validators An array of Validator objects to run against this field.
+ * @param string $help_text Set a description for this field. This description will be used in API documentation.
+ */
+ public function __construct(
+ bool $required = false,
+ bool $unique = false,
+ mixed $default = null,
+ string $default_callable = '',
+ array $choices = [],
+ string $choices_callable = '',
+ bool $allow_empty = false,
+ bool $allow_null = false,
+ bool $editable = true,
+ bool $read_only = false,
+ bool $write_only = false,
+ bool $sensitive = false,
+ bool $representation_only = false,
+ bool $many = false,
+ int $many_minimum = 0,
+ int $many_maximum = 128,
+ public int $minimum_length = 0,
+ public int $maximum_length = 1024,
+ string|null $delimiter = ',',
+ string $verbose_name = '',
+ string $verbose_name_plural = '',
+ string $internal_name = '',
+ string $internal_namespace = '',
+ array $referenced_by = [],
+ array $conditions = [],
+ array $validators = [],
+ string $help_text = '',
+ ) {
+ parent::__construct(
+ type: 'array',
+ required: $required,
+ unique: $unique,
+ default: $default,
+ default_callable: $default_callable,
+ choices: $choices,
+ choices_callable: $choices_callable,
+ allow_empty: $allow_empty,
+ allow_null: $allow_null,
+ editable: $editable,
+ read_only: $read_only,
+ write_only: $write_only,
+ sensitive: $sensitive,
+ representation_only: $representation_only,
+ many: $many,
+ many_minimum: $many_minimum,
+ many_maximum: $many_maximum,
+ delimiter: $delimiter,
+ verbose_name: $verbose_name,
+ verbose_name_plural: $verbose_name_plural,
+ internal_name: $internal_name,
+ internal_namespace: $internal_namespace,
+ referenced_by: $referenced_by,
+ conditions: $conditions,
+ validators: $validators,
+ help_text: $help_text,
+ );
+ }
+
+ public function validate_extra(mixed $value): void {
+ # Ensure this value is an associative array
+ if (array_keys($value) === range(0, count($value) - 1)) {
+ throw new ValidationError(
+ message: "Field `$this->name` value must be an associative array of key-value pairs.",
+ response_id: 'OBJECT_FIELD_INVALID_VALUE',
+ );
+ }
+ }
+
+ /**
+ * Converts the field value to its representation form from it's internal pfSense configuration value. This field
+ * can only be used if the value is an arbitrary associative array of key-value pairs.
+ * @param mixed $internal_value The internal value from the pfSense configuration.
+ * @return array The field value in its representation form.
+ */
+ protected function _from_internal(mixed $internal_value): array {
+ return $internal_value;
+ }
+
+ /**
+ * Converts this field value into an OpenAPI property definition.
+ * @return array The OpenAPI property definition.
+ */
+ public function to_openapi_property(): array {
+ # Temporarily change the type to 'object' to generate the property definition
+ $this->type = 'object';
+ $property = parent::to_openapi_property();
+ $this->type = 'array';
+ return $property;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc
index d3a6d468..1822774d 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Forms/SystemRESTAPISettingsForm.inc
@@ -33,6 +33,7 @@ class SystemRESTAPISettingsForm extends Form {
'jwt_exp',
'login_protection',
'log_successful_auth',
+ 'expose_sensitive_fields',
],
],
'Advanced Settings' => [
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/GraphQL/QueryParamsScalarType.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/GraphQL/QueryParamsScalarType.inc
new file mode 100644
index 00000000..c972be39
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/GraphQL/QueryParamsScalarType.inc
@@ -0,0 +1,62 @@
+ 'QueryParams',
+ 'description' =>
+ 'The `QueryParam` scalar type represents arbitrary GraphQL object containing query parameters as key-value pairs.',
+ 'serialize' => function ($value) {
+ # In order to print the schema, this cannot be an object so just use an empty string
+ return '';
+ },
+ 'parseValue' => function ($value) {
+ # Only handle PHP arrays or objects directly
+ if (is_array($value) or is_object($value)) {
+ return (array) $value;
+ }
+ throw new Error('Cannot parse value as a QueryParams object.');
+ },
+ 'parseLiteral' => function ($ast) {
+ # Only handle GraphQL objects
+ if ($ast instanceof ObjectValueNode === false) {
+ throw new Error('Invalid QueryParams literal. Expected value to be an object.');
+ }
+
+ # Keep track of fields and values
+ $result = [];
+
+ # Iterate over each field in the object
+ foreach ($ast->fields as $field) {
+ $value = $field->value;
+
+ # Handle nested objects
+ if ($value instanceof ObjectValueNode) {
+ # Recursively handle any nested objects
+ $result[$field->name->value] = $this->parseLiteral($value);
+ }
+ # Handle primitive values
+ elseif ($value instanceof ValueNode) {
+ $result[$field->name->value] = $value->value;
+ }
+ }
+ return $result;
+ },
+ ]);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/GraphQL/Resolver.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/GraphQL/Resolver.inc
new file mode 100644
index 00000000..2fcbc6be
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/GraphQL/Resolver.inc
@@ -0,0 +1,246 @@
+check_privs(resolver: 'query', auth: $context['auth']);
+
+ # Execute the query
+ $query = $this->model->query(
+ query_params: $args['query_params'],
+ limit: $args['limit'],
+ offset: $args['offset'],
+ reverse: $args['reverse'],
+ sort_by: $args['sort_by'],
+ sort_order: $args['sort_order'] ?? SORT_ASC,
+ );
+
+ # Return the query results in an array representation
+ return $query->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL query to the Model's 'read' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function read($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'read', auth: $context['auth']);
+
+ # Read the requested object using the Model's 'read' method (which is called on construction)
+ $model = new $this->model(id: $args['id'] ?? null, parent_id: $args['parent_id'] ?? null);
+
+ # Return the read results in an array representation
+ return $model->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL mutation to the Model's 'create' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function create($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'create', auth: $context['auth']);
+
+ # Create a new Model object with the given data
+ $model = new $this->model(data: $args);
+
+ # Return the created object in an array representation
+ return $model->create()->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL mutation to the Model's 'update' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function update($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'update', auth: $context['auth']);
+
+ # Load the existing Model object with the given data
+ $model = new $this->model(data: $args);
+
+ # Return the update object in an array representation
+ return $model->update()->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL mutation to the Model's 'delete' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function delete($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'delete', auth: $context['auth']);
+
+ # Load the existing Model object with the given data
+ $model = new $this->model(data: $args);
+
+ # Return the deleted object in an array representation
+ return $model->delete()->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL mutation to the Model's 'replace_all' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function replace_all($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'replace_all', auth: $context['auth']);
+
+ # Load the existing Model object with the given data
+ $model = new $this->model();
+
+ # Return the replaced object in an array representation
+ return $model->replace_all(data: $args['objects'])->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL mutation to the Model's 'delete_many' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function delete_many($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'delete_many', auth: $context['auth']);
+
+ # Load the existing Model object with the given data
+ $model = new $this->model();
+
+ # Delete the objects that match our query
+ $deleted_objects = $model->delete_many(
+ query_params: $args['query_params'],
+ limit: $args['limit'],
+ offset: $args['offset'],
+ );
+
+ # Return the deleted objects in an array representation
+ return $deleted_objects->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * A resolver that maps a GraphQL mutation to the Model's 'delete_all' method.
+ * @link https://webonyx.github.io/graphql-php/schema-definition-language/#defining-resolvers
+ */
+ public function delete_all($root, $args, $context, $info): array {
+ try {
+ # Ensure that the user has the required privileges to perform this action
+ $this->check_privs(resolver: 'delete_all', auth: $context['auth']);
+
+ # Load the existing Model object with the given data
+ $model = new $this->model();
+
+ # Delete all objects
+ $deleted_objects = $model->delete_all();
+
+ # Return the deleted objects in an array representation
+ return $deleted_objects->to_representation();
+ } catch (Response $e) {
+ throw new Error(message: $e->message, extensions: ['response_id' => $e->response_id]);
+ }
+ }
+
+ /**
+ * Checks if authenticated user has privileges to perform the requested action.
+ * @param string $resolver The resolver method to check permissions for.
+ * @param Auth $auth The Auth object to use for checking permissions.
+ * @throws ForbiddenError If the user does not have the required privileges.
+ */
+ public function check_privs(string $resolver, Auth $auth): void {
+ # Obtain the endpoint that corresponds with the Model and resolve method
+ switch ($resolver) {
+ case 'query':
+ $endpoint = $this->model->get_related_endpoint(many: true);
+ $auth->required_privileges = $endpoint->get_privileges;
+ break;
+ case 'read':
+ $endpoint = $this->model->get_related_endpoint(many: false);
+ $auth->required_privileges = $endpoint->get_privileges;
+ break;
+ case 'create':
+ $endpoint = $this->model->get_related_endpoint(many: false);
+ $auth->required_privileges = $endpoint->post_privileges;
+ break;
+ case 'update':
+ $endpoint = $this->model->get_related_endpoint(many: false);
+ $auth->required_privileges = $endpoint->patch_privileges;
+ break;
+ case 'delete':
+ $endpoint = $this->model->get_related_endpoint(many: false);
+ $auth->required_privileges = $endpoint->delete_privileges;
+ break;
+ case 'replace_all':
+ $endpoint = $this->model->get_related_endpoint(many: true);
+ $auth->required_privileges = $endpoint->put_privileges;
+ break;
+ case 'delete_all':
+ case 'delete_many':
+ $endpoint = $this->model->get_related_endpoint(many: true);
+ $auth->required_privileges = $endpoint->delete_privileges;
+ break;
+ default:
+ throw new ForbiddenError(
+ message: 'Unknown GraphQL resolver action requested.',
+ response_id: 'GRAPHQL_RESOLVER_UNKNOWN_CHECK_PRIVS_ACTION',
+ );
+ }
+
+ # Throw an error if the user does not have the required privileges
+ if (!$auth->authorize()) {
+ throw new ForbiddenError(
+ message: 'Authorization failed. You do not have sufficient privileges to access this resource.',
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ );
+ }
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/CertificateModelTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/CertificateModelTraits.inc
new file mode 100644
index 00000000..b410cff6
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/CertificateModelTraits.inc
@@ -0,0 +1,85 @@
+ $model->dn_commonname->value, 'subjectAltName' => []];
+
+ # Add countryName if it was given
+ if ($model->dn_country->value) {
+ $dn['countryName'] = $model->dn_country->value;
+ }
+ # Add stateOrProvinceName if it was given
+ if ($model->dn_state->value) {
+ $dn['stateOrProvinceName'] = $model->dn_state->value;
+ }
+ # Add localityName if it was given
+ if ($model->dn_city->value) {
+ $dn['localityName'] = $model->dn_city->value;
+ }
+ # Add organizationName if it was given
+ if ($model->dn_organization->value) {
+ $dn['organizationName'] = $model->dn_organization->value;
+ }
+ # Add organizationalUnitName if it was given
+ if ($model->dn_organizationalunit->value) {
+ $dn['organizationalUnitName'] = $model->dn_organizationalunit->value;
+ }
+
+ # Loop through the SAN fields and add them to the subjectAltName array accordingly
+ foreach ($model->dn_dns_sans->value as $san) {
+ $dn['subjectAltName'][] = "DNS:$san";
+ }
+ foreach ($model->dn_email_sans->value as $san) {
+ $dn['subjectAltName'][] = "email:$san";
+ }
+ foreach ($model->dn_ip_sans->value as $san) {
+ $dn['subjectAltName'][] = "IP:$san";
+ }
+ foreach ($model->dn_uri_sans->value as $san) {
+ $dn['subjectAltName'][] = "URI:$san";
+ }
+
+ # Piece together the subjectAltName array into a comma-separated string
+ $dn['subjectAltName'] = implode(',', $dn['subjectAltName']);
+
+ return $dn;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMEAccountKey.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMEAccountKey.inc
index 1b145616..a0754966 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMEAccountKey.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMEAccountKey.inc
@@ -58,7 +58,7 @@ class ACMEAccountKey extends Model {
);
$this->accountkey = new Base64Field(
default_callable: 'generate_rsa_key',
- write_only: true,
+ sensitive: true,
validators: [new X509Validator(allow_crt: false, allow_prv: true, allow_rsa: true, allow_ecprv: true)],
help_text: 'The RSA private key for the ACME account key.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc
index 214b61c6..4604efb7 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/ACMECertificateDomain.inc
@@ -362,7 +362,7 @@ class ACMECertificateDomain extends Model {
$this->webrootftppassword = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
conditions: ['method' => 'webrootftp'],
help_text: 'Password to authenticate this user on the remote server',
);
@@ -435,7 +435,7 @@ class ACMECertificateDomain extends Model {
$this->one984hosting_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_1984hostingone984hosting_password',
conditions: ['method' => 'dns_1984hosting'],
help_text: '1984Hosting Password',
@@ -457,7 +457,7 @@ class ACMECertificateDomain extends Model {
$this->acmeproxy_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_acmeproxyacmeproxy_password',
conditions: ['method' => 'dns_acmeproxy'],
help_text: 'Acmeproxy Password',
@@ -472,7 +472,7 @@ class ACMECertificateDomain extends Model {
$this->acmedns_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_acmednsacmedns_password',
conditions: ['method' => 'dns_acmedns'],
help_text: 'acme-dns.io Password',
@@ -494,7 +494,7 @@ class ACMECertificateDomain extends Model {
$this->active24_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_active24active24_token',
conditions: ['method' => 'dns_active24'],
help_text: 'Active24 Token',
@@ -509,7 +509,7 @@ class ACMECertificateDomain extends Model {
$this->akamai_access_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_edgednsakamai_access_token',
conditions: ['method' => 'dns_edgedns'],
help_text: 'Access Token',
@@ -517,7 +517,7 @@ class ACMECertificateDomain extends Model {
$this->akamai_client_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_edgednsakamai_client_token',
conditions: ['method' => 'dns_edgedns'],
help_text: 'Client Token',
@@ -525,7 +525,7 @@ class ACMECertificateDomain extends Model {
$this->akamai_client_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_edgednsakamai_client_secret',
conditions: ['method' => 'dns_edgedns'],
help_text: 'Client Secret',
@@ -533,7 +533,7 @@ class ACMECertificateDomain extends Model {
$this->ali_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_aliali_key',
conditions: ['method' => 'dns_ali'],
help_text: 'API Key',
@@ -541,7 +541,7 @@ class ACMECertificateDomain extends Model {
$this->ali_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_aliali_secret',
conditions: ['method' => 'dns_ali'],
help_text: 'API Secret',
@@ -563,7 +563,7 @@ class ACMECertificateDomain extends Model {
$this->kas_authdata = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_kaskas_authdata',
conditions: ['method' => 'dns_kas'],
help_text: 'Auth data',
@@ -571,7 +571,7 @@ class ACMECertificateDomain extends Model {
$this->ad_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_adad_api_key',
conditions: ['method' => 'dns_ad'],
help_text: 'Alwaysdata API Key',
@@ -579,7 +579,7 @@ class ACMECertificateDomain extends Model {
$this->anx_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_anxanx_token',
conditions: ['method' => 'dns_anx'],
help_text: 'API Token',
@@ -594,7 +594,7 @@ class ACMECertificateDomain extends Model {
$this->af_api_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_artfilesaf_api_password',
conditions: ['method' => 'dns_artfiles'],
help_text: 'ArtFiles Password',
@@ -602,7 +602,7 @@ class ACMECertificateDomain extends Model {
$this->arvan_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_arvanarvan_token',
conditions: ['method' => 'dns_arvan'],
help_text: 'Arvan API Token',
@@ -610,7 +610,7 @@ class ACMECertificateDomain extends Model {
$this->aurora_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_auroraaurora_key',
conditions: ['method' => 'dns_aurora'],
help_text: 'API Key',
@@ -618,7 +618,7 @@ class ACMECertificateDomain extends Model {
$this->aurora_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_auroraaurora_secret',
conditions: ['method' => 'dns_aurora'],
help_text: 'API Secret. Obtain the key and secret from https://cp.pcextreme.nl/auroradns/users.',
@@ -633,7 +633,7 @@ class ACMECertificateDomain extends Model {
$this->autodns_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_autodnsautodns_password',
conditions: ['method' => 'dns_autodns'],
help_text: 'autoDNS Password',
@@ -655,7 +655,7 @@ class ACMECertificateDomain extends Model {
$this->aws_secret_access_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_awsaws_secret_access_key',
conditions: ['method' => 'dns_aws'],
help_text: 'AWS Secret Access / API Key',
@@ -677,7 +677,7 @@ class ACMECertificateDomain extends Model {
$this->azion_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_azionazion_password',
conditions: ['method' => 'dns_azion'],
help_text: 'Account password',
@@ -706,7 +706,7 @@ class ACMECertificateDomain extends Model {
$this->azuredns_clientsecret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_azureazuredns_clientsecret',
conditions: ['method' => 'dns_azure'],
help_text: 'Azure Client Secret',
@@ -721,7 +721,7 @@ class ACMECertificateDomain extends Model {
$this->bookmyname_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_bookmynamebookmyname_password',
conditions: ['method' => 'dns_bookmyname'],
help_text: 'BookMyName Password',
@@ -729,7 +729,7 @@ class ACMECertificateDomain extends Model {
$this->bunny_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_bunnybunny_api_key',
conditions: ['method' => 'dns_bunny'],
help_text: 'Bunny DNS API Key',
@@ -751,7 +751,7 @@ class ACMECertificateDomain extends Model {
$this->clouddns_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_clouddnsclouddns_password',
conditions: ['method' => 'dns_clouddns'],
help_text: 'CloudDNS Password',
@@ -773,7 +773,7 @@ class ACMECertificateDomain extends Model {
$this->cloudns_auth_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_cloudnscloudns_auth_password',
conditions: ['method' => 'dns_cloudns'],
help_text: 'ClouDNS Password',
@@ -781,7 +781,7 @@ class ACMECertificateDomain extends Model {
$this->cf_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_cfcf_key',
conditions: ['method' => 'dns_cf'],
help_text: 'Cloudflare API Key',
@@ -796,7 +796,7 @@ class ACMECertificateDomain extends Model {
$this->cf_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_cfcf_token',
conditions: ['method' => 'dns_cf'],
help_text: 'Cloudflare API Token',
@@ -825,7 +825,7 @@ class ACMECertificateDomain extends Model {
$this->conoha_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_conohaconoha_password',
conditions: ['method' => 'dns_conoha'],
help_text: 'Conoha Password',
@@ -847,7 +847,7 @@ class ACMECertificateDomain extends Model {
$this->constellix_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_constellixconstellix_key',
conditions: ['method' => 'dns_constellix'],
help_text: 'Constellix Key',
@@ -855,7 +855,7 @@ class ACMECertificateDomain extends Model {
$this->constellix_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_constellixconstellix_secret',
conditions: ['method' => 'dns_constellix'],
help_text: 'Constellix Secret',
@@ -870,7 +870,7 @@ class ACMECertificateDomain extends Model {
$this->cpanel_apitoken = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_cpanelcpanel_apitoken',
conditions: ['method' => 'dns_cpanel'],
help_text: 'cPanel API token',
@@ -892,7 +892,7 @@ class ACMECertificateDomain extends Model {
$this->cn_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_cncn_password',
conditions: ['method' => 'dns_cn'],
help_text: 'Core Networks Password',
@@ -907,7 +907,7 @@ class ACMECertificateDomain extends Model {
$this->curanet_authsecret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_curanetcuranet_authsecret',
conditions: ['method' => 'dns_curanet'],
help_text: 'Authentication Secret',
@@ -922,7 +922,7 @@ class ACMECertificateDomain extends Model {
$this->cy_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_cyoncy_password',
conditions: ['method' => 'dns_cyon'],
help_text: 'CY Password',
@@ -930,7 +930,7 @@ class ACMECertificateDomain extends Model {
$this->ddnss_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_ddnssddnss_token',
conditions: ['method' => 'dns_ddnss'],
help_text: 'API Token (e.g. aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee)',
@@ -938,7 +938,7 @@ class ACMECertificateDomain extends Model {
$this->dedyn_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_desecdedyn_token',
conditions: ['method' => 'dns_desec'],
help_text: 'deSEC.io API Token',
@@ -953,7 +953,7 @@ class ACMECertificateDomain extends Model {
$this->do_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dgondo_api_key',
conditions: ['method' => 'dns_dgon'],
help_text: 'DigitalOcean API Key',
@@ -975,7 +975,7 @@ class ACMECertificateDomain extends Model {
$this->dnsexit_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dnsexitdnsexit_api_key',
conditions: ['method' => 'dns_dnsexit'],
help_text: 'DNSExit API Key',
@@ -990,7 +990,7 @@ class ACMECertificateDomain extends Model {
$this->dnsexit_auth_pass = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dnsexitdnsexit_auth_pass',
conditions: ['method' => 'dns_dnsexit'],
help_text: 'DNSExit Password',
@@ -1005,7 +1005,7 @@ class ACMECertificateDomain extends Model {
$this->dnshome_subdomainpassword = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dnshomednshome_subdomainpassword',
conditions: ['method' => 'dns_dnshome'],
help_text: 'Subdomain Password',
@@ -1020,7 +1020,7 @@ class ACMECertificateDomain extends Model {
$this->me_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_meme_key',
conditions: ['method' => 'dns_me'],
help_text: 'DNSMadeEasy API Key',
@@ -1028,7 +1028,7 @@ class ACMECertificateDomain extends Model {
$this->me_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_meme_secret',
conditions: ['method' => 'dns_me'],
help_text: 'DNSMadeEasy API Secret',
@@ -1043,7 +1043,7 @@ class ACMECertificateDomain extends Model {
$this->dnsservices_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dnsservicesdnsservices_password',
conditions: ['method' => 'dns_dnsservices'],
help_text: 'dns.services Password',
@@ -1051,7 +1051,7 @@ class ACMECertificateDomain extends Model {
$this->do_letoken = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_doapido_letoken',
conditions: ['method' => 'dns_doapi'],
help_text: 'DO.de API Token',
@@ -1066,7 +1066,7 @@ class ACMECertificateDomain extends Model {
$this->do_pw = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dodo_pw',
conditions: ['method' => 'dns_do'],
help_text: 'DO Password',
@@ -1074,7 +1074,7 @@ class ACMECertificateDomain extends Model {
$this->domeneshop_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_domeneshopdomeneshop_token',
conditions: ['method' => 'dns_domeneshop'],
help_text: 'Domeneshop API Token',
@@ -1082,7 +1082,7 @@ class ACMECertificateDomain extends Model {
$this->domeneshop_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_domeneshopdomeneshop_secret',
conditions: ['method' => 'dns_domeneshop'],
help_text: 'Domeneshop API Secret',
@@ -1097,7 +1097,7 @@ class ACMECertificateDomain extends Model {
$this->dp_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dpdp_key',
conditions: ['method' => 'dns_dp'],
help_text: 'Dnspod API Key',
@@ -1112,7 +1112,7 @@ class ACMECertificateDomain extends Model {
$this->dpi_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dpidpi_key',
conditions: ['method' => 'dns_dpi'],
help_text: 'Dnspod API Key',
@@ -1120,7 +1120,7 @@ class ACMECertificateDomain extends Model {
$this->dh_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dreamhostdh_api_key',
conditions: ['method' => 'dns_dreamhost'],
help_text: 'Dreamhost API Token',
@@ -1128,7 +1128,7 @@ class ACMECertificateDomain extends Model {
$this->duckdns_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_duckdnsduckdns_token',
conditions: ['method' => 'dns_duckdns'],
help_text: 'DuckDNS API Token (Look in DuckDNS account settings)',
@@ -1143,7 +1143,7 @@ class ACMECertificateDomain extends Model {
$this->dd_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_durablednsdd_api_key',
conditions: ['method' => 'dns_durabledns'],
help_text: 'DurableDNS API Key',
@@ -1165,7 +1165,7 @@ class ACMECertificateDomain extends Model {
$this->dyn_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dyndyn_password',
conditions: ['method' => 'dns_dyn'],
help_text: 'dyn.com Password',
@@ -1180,7 +1180,7 @@ class ACMECertificateDomain extends Model {
$this->df_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dfdf_password',
conditions: ['method' => 'dns_df'],
help_text: 'dyndnsfree.de Password',
@@ -1195,7 +1195,7 @@ class ACMECertificateDomain extends Model {
$this->dynu_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_dynudynu_secret',
conditions: ['method' => 'dns_dynu'],
help_text: 'Dynu API Secret',
@@ -1203,7 +1203,7 @@ class ACMECertificateDomain extends Model {
$this->easydns_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_easydnseasydns_key',
conditions: ['method' => 'dns_easydns'],
help_text: 'easyDNS API Key. Sign up for a key at https://cp.easydns.com/manage/security/api/signup.php',
@@ -1211,7 +1211,7 @@ class ACMECertificateDomain extends Model {
$this->easydns_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_easydnseasydns_token',
conditions: ['method' => 'dns_easydns'],
help_text: 'easyDNS API Token',
@@ -1226,7 +1226,7 @@ class ACMECertificateDomain extends Model {
$this->euserv_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_euserveuserv_password',
conditions: ['method' => 'dns_euserv'],
help_text: 'Euserv.eu Password',
@@ -1234,7 +1234,7 @@ class ACMECertificateDomain extends Model {
$this->exoscale_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_exoscaleexoscale_api_key',
conditions: ['method' => 'dns_exoscale'],
help_text: 'Exoscale API Key',
@@ -1242,7 +1242,7 @@ class ACMECertificateDomain extends Model {
$this->exoscale_secret_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_exoscaleexoscale_secret_key',
conditions: ['method' => 'dns_exoscale'],
help_text: 'Exoscale Secret Key',
@@ -1250,7 +1250,7 @@ class ACMECertificateDomain extends Model {
$this->fornex_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_fornexfornex_api_key',
conditions: ['method' => 'dns_fornex'],
help_text: 'Fornex API Key',
@@ -1265,7 +1265,7 @@ class ACMECertificateDomain extends Model {
$this->freedns_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_freednsfreedns_password',
conditions: ['method' => 'dns_freedns'],
help_text: 'FreeDNS Password',
@@ -1273,7 +1273,7 @@ class ACMECertificateDomain extends Model {
$this->gandi_livedns_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_gandi_livednsgandi_livedns_key',
conditions: ['method' => 'dns_gandi_livedns'],
help_text: 'Gandi LiveDNS API Key, retrieved from https://account.gandi.net',
@@ -1281,7 +1281,7 @@ class ACMECertificateDomain extends Model {
$this->gcore_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_gcoregcore_key',
conditions: ['method' => 'dns_gcore'],
help_text: 'Gcore API Key',
@@ -1296,7 +1296,7 @@ class ACMECertificateDomain extends Model {
$this->geoscaling_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_geoscalinggeoscaling_password',
conditions: ['method' => 'dns_geoscaling'],
help_text: 'Password',
@@ -1311,7 +1311,7 @@ class ACMECertificateDomain extends Model {
$this->gd_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_gdgd_secret',
conditions: ['method' => 'dns_gd'],
help_text: 'GoDaddy API Secret',
@@ -1319,7 +1319,7 @@ class ACMECertificateDomain extends Model {
$this->googledomains_access_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_googledomainsgoogledomains_access_token',
conditions: ['method' => 'dns_googledomains'],
help_text: 'Google Domains API Access Token',
@@ -1334,7 +1334,7 @@ class ACMECertificateDomain extends Model {
$this->hetzner_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_hetznerhetzner_token',
conditions: ['method' => 'dns_hetzner'],
help_text: 'Hetzner API Token. Visit https://dns.hetzner.com/settings/api-token to retrieve.',
@@ -1349,7 +1349,7 @@ class ACMECertificateDomain extends Model {
$this->hexonet_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_hexonethexonet_password',
conditions: ['method' => 'dns_hexonet'],
help_text: 'Hexonet Password',
@@ -1364,7 +1364,7 @@ class ACMECertificateDomain extends Model {
$this->huaweicloud_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_huaweicloudhuaweicloud_password',
conditions: ['method' => 'dns_huaweicloud'],
help_text: 'Password',
@@ -1386,7 +1386,7 @@ class ACMECertificateDomain extends Model {
$this->he_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_hehe_password',
conditions: ['method' => 'dns_he'],
help_text: 'Hurricane Electric password',
@@ -1394,7 +1394,7 @@ class ACMECertificateDomain extends Model {
$this->hostingde_apikey = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_hostingdehostingde_apikey',
conditions: ['method' => 'dns_hostingde'],
help_text: 'Hosting.de API Key',
@@ -1409,7 +1409,7 @@ class ACMECertificateDomain extends Model {
$this->infoblox_creds = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_infobloxinfoblox_creds',
conditions: ['method' => 'dns_infoblox'],
help_text: 'Infoblox credentials in username:password format',
@@ -1431,7 +1431,7 @@ class ACMECertificateDomain extends Model {
$this->infomaniak_api_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_infomaniakinfomaniak_api_token',
conditions: ['method' => 'dns_infomaniak'],
help_text: 'Infomaniak API token. Visit https://manager.infomaniak.com/v3/<account_id>/api/dashboard and generate a token with the scope Domain.',
@@ -1460,7 +1460,7 @@ class ACMECertificateDomain extends Model {
$this->ionos_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_ionosionos_secret',
conditions: ['method' => 'dns_ionos'],
help_text: 'Secret. Read https://beta.developer.hosting.ionos.de/docs/getstarted to learn how to get a prefix and secret.',
@@ -1468,7 +1468,7 @@ class ACMECertificateDomain extends Model {
$this->ipv64_token = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_ipv64ipv64_token',
conditions: ['method' => 'dns_ipv64'],
help_text: 'IPv64.net Access Token',
@@ -1476,7 +1476,7 @@ class ACMECertificateDomain extends Model {
$this->internetbs_api_key = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_internetbsinternetbs_api_key',
conditions: ['method' => 'dns_internetbs'],
help_text: 'Internet.BS API Key',
@@ -1484,7 +1484,7 @@ class ACMECertificateDomain extends Model {
$this->internetbs_api_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_internetbsinternetbs_api_password',
conditions: ['method' => 'dns_internetbs'],
help_text: 'Internet.BS API Password',
@@ -1499,7 +1499,7 @@ class ACMECertificateDomain extends Model {
$this->inwx_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_inwxinwx_password',
conditions: ['method' => 'dns_inwx'],
help_text: 'INWX.de password',
@@ -1507,7 +1507,7 @@ class ACMECertificateDomain extends Model {
$this->inwx_shared_secret = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_inwxinwx_shared_secret',
conditions: ['method' => 'dns_inwx'],
help_text: 'INWX.de shared secret',
@@ -1522,7 +1522,7 @@ class ACMECertificateDomain extends Model {
$this->ispc_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
internal_name: 'dns_ispconfigispc_password',
conditions: ['method' => 'dns_ispconfig'],
help_text: 'ISPConfig remotePassword',
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/AuthServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/AuthServer.inc
index 8ed6e3e4..bd947095 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/AuthServer.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/AuthServer.inc
@@ -152,7 +152,7 @@ class AuthServer extends Model {
);
$this->ldap_bindpw = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
conditions: ['type' => 'ldap', '!ldap_binddn' => null],
help_text: 'The password to use when binding to this authentication server.',
);
@@ -216,7 +216,7 @@ class AuthServer extends Model {
);
$this->radius_secret = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
conditions: ['type' => 'radius'],
help_text: 'The shared secret to use when authenticating to this RADIUS server.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDAccessList.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDAccessList.inc
new file mode 100644
index 00000000..2fb97a01
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDAccessList.inc
@@ -0,0 +1,48 @@
+config_path = 'installedpackages/bindacls/config';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->name = new StringField(required: true, unique: true, help_text: 'The name of the access list.');
+ $this->description = new StringField(
+ default: '',
+ allow_empty: true,
+ help_text: 'A description for the access list.',
+ );
+ $this->entries = new NestedModelField(
+ model_class: 'BINDAccessListEntry',
+ required: true,
+ help_text: 'The network entries for this access list.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND access lists
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDAccessListEntry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDAccessListEntry.inc
new file mode 100644
index 00000000..22b5bcb8
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDAccessListEntry.inc
@@ -0,0 +1,48 @@
+parent_model_class = 'BINDAccessList';
+ $this->config_path = 'row';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->always_apply = true;
+ $this->many = true;
+ $this->many_minimum = 1;
+
+ # Set model fields
+ $this->value = new StringField(
+ required: true,
+ validators: [new SubnetValidator()],
+ help_text: 'The network CIDR to allow.',
+ );
+ $this->description = new StringField(
+ default: '',
+ allow_empty: true,
+ help_text: 'A description of the access list entry.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND access list entries
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSettings.inc
new file mode 100644
index 00000000..4ce2212b
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSettings.inc
@@ -0,0 +1,184 @@
+config_path = 'installedpackages/bind/config/0';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = false;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->enable_bind = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ indicates_false: '',
+ help_text: 'Enables the BIND service.',
+ );
+ $this->bind_ip_version = new StringField(
+ default: '',
+ choices: ['', '-4', '-6'],
+ allow_empty: true,
+ help_text: 'The IP version to use for the BIND service. Leave empty to use both IPv4 and IPv6.',
+ );
+ $this->listenon = new InterfaceField(
+ default: ['All'],
+ allow_localhost_interface: true,
+ allow_custom: ['All'],
+ many: true,
+ help_text: 'The interfaces to listen on for DNS requests.',
+ );
+ $this->bind_notify = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ indicates_false: '',
+ help_text: 'Notify slave server after any update on master.',
+ );
+ $this->bind_hide_version = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ indicates_false: '',
+ help_text: 'Hide the BIND version in responses.',
+ );
+ $this->bind_ram_limit = new StringField(
+ default: '256M',
+ help_text: 'The maximum amount of RAM to use for the BIND service.',
+ );
+ $this->bind_logging = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ indicates_false: '',
+ help_text: 'Enable logging for the BIND service.',
+ );
+ $this->log_severity = new StringField(
+ default: 'critical',
+ choices: ['critical', 'error', 'warning', 'notice', 'info', 'debug 1', 'debug 3', 'debug 5', 'dynamic'],
+ help_text: 'The minimum severity of events to log.',
+ );
+ $this->log_options = new StringField(
+ default: ['default'],
+ choices: [
+ 'default',
+ 'general',
+ 'database',
+ 'security',
+ 'config',
+ 'resolver',
+ 'xfer-in',
+ 'xfer-out',
+ 'notify',
+ 'client',
+ 'unmatched',
+ 'queries',
+ 'network',
+ 'update',
+ 'dispatch',
+ 'dnssec',
+ 'lame-servers',
+ ],
+ many: true,
+ help_text: 'The categories to log.',
+ );
+ $this->rate_enabled = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ indicates_false: '',
+ help_text: 'Enable rate limiting for the BIND service.',
+ );
+ $this->log_only = new BooleanField(
+ default: false,
+ indicates_true: 'yes',
+ indicates_false: 'no',
+ help_text: 'When rate limiting, only log that the query limit has been exceeded. If disabled, the query ' .
+ 'will be dropped instead.',
+ );
+ $this->rate_limit = new IntegerField(
+ default: 15,
+ conditions: ['rate_enabled' => true],
+ help_text: 'The maximum number of queries per second to allow.',
+ );
+ $this->bind_forwarder = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ indicates_false: '',
+ help_text: 'Enable forwarding queries to other DNS servers listed below rather than this server ' .
+ 'performing its own recursion.',
+ );
+ $this->bind_forwarder_ips = new StringField(
+ required: true,
+ many: true,
+ delimiter: ';',
+ conditions: ['bind_forwarder' => true],
+ validators: [new IPAddressValidator()],
+ help_text: 'The IP addresses of the DNS servers to forward queries to.',
+ );
+ $this->bind_dnssec_validation = new StringField(
+ default: 'auto',
+ choices: ['auto', 'on', 'off'],
+ help_text: 'Enable DNSSEC validation when BIND is acting as a recursive resolver.',
+ );
+ $this->listenport = new PortField(
+ default: '53',
+ help_text: 'The TCP and UDP port to listen on for DNS requests.',
+ );
+ $this->controlport = new PortField(
+ default: '953',
+ help_text: 'The TCP port to listen on for control requests (localhost only).',
+ );
+ $this->bind_custom_options = new Base64Field(
+ default: '',
+ allow_empty: true,
+ help_text: 'Custom BIND options to include in the configuration file.',
+ );
+ $this->bind_global_settings = new Base64Field(
+ default: '',
+ allow_empty: true,
+ help_text: 'Global BIND settings to include in the configuration file.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND settings.
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSyncRemoteHost.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSyncRemoteHost.inc
new file mode 100644
index 00000000..62149474
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSyncRemoteHost.inc
@@ -0,0 +1,67 @@
+config_path = 'installedpackages/bindsync/config/0/row';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->syncdestinenable = new BooleanField(
+ default: false,
+ indicates_true: 'ON',
+ help_text: 'Enable this remote host for syncing.',
+ );
+ $this->syncprotocol = new StringField(
+ required: true,
+ choices: ['http', 'https'],
+ help_text: 'The protocol to use for syncing.',
+ );
+ $this->ipaddress = new StringField(
+ required: true,
+ validators: [new IPAddressValidator(allow_fqdn: true)],
+ help_text: 'The IP address/hostname of the remote host.',
+ );
+ $this->syncport = new PortField(required: true, help_text: 'The remote host port to use for syncing.');
+ $this->username = new StringField(
+ required: true,
+ help_text: 'The username to use to authenticate when syncing.',
+ );
+ $this->password = new StringField(
+ required: true,
+ sensitive: true,
+ help_text: 'The password to use to authenticate when syncing.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND sync settings.
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSyncSettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSyncSettings.inc
new file mode 100644
index 00000000..7ca9415f
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDSyncSettings.inc
@@ -0,0 +1,53 @@
+config_path = 'installedpackages/bindsync/config/0';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = false;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->synconchanges = new StringField(
+ required: true,
+ choices: ['disabled', 'manual', 'auto'],
+ help_text: 'The sync mode to use.',
+ );
+ $this->synctimeout = new IntegerField(
+ default: 30,
+ choices: [30, 60, 90, 120, 150, 250],
+ help_text: 'The timeout for the sync process.',
+ );
+ $this->masterip = new StringField(
+ required: true,
+ validators: [new IPAddressValidator(allow_fqdn: true)],
+ help_text: 'The IP address of the master BIND server.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND sync settings.
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDView.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDView.inc
new file mode 100644
index 00000000..2de83a6a
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDView.inc
@@ -0,0 +1,73 @@
+config_path = 'installedpackages/bindviews/config';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->name = new StringField(required: true, unique: true, help_text: 'The name of the view.');
+ $this->descr = new StringField(default: '', allow_empty: true, help_text: 'A description for the view.');
+ $this->recursion = new BooleanField(
+ default: false,
+ indicates_true: 'yes',
+ indicates_false: 'no',
+ help_text: 'Enables or disables recursion for the view.',
+ );
+ $this->match_clients = new ForeignModelField(
+ model_name: 'BINDAccessList',
+ model_field: 'name',
+ default: [],
+ allow_empty: true,
+ many: true,
+ internal_name: 'match-clients',
+ help_text: 'The access lists to match clients against.',
+ );
+ $this->allow_recursion = new ForeignModelField(
+ model_name: 'BINDAccessList',
+ model_field: 'name',
+ default: [],
+ allow_empty: true,
+ many: true,
+ internal_name: 'allow-recursion',
+ help_text: 'The access lists to allow recursion for.',
+ );
+ $this->bind_custom_options = new Base64Field(
+ default: '',
+ allow_empty: true,
+ help_text: 'Custom BIND options for the view.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND views.
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDZone.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDZone.inc
new file mode 100644
index 00000000..42921e8e
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDZone.inc
@@ -0,0 +1,254 @@
+config_path = 'installedpackages/bindzone/config';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->disabled = new BooleanField(default: false, indicates_true: 'on', help_text: 'Disable this BIND zone.');
+ $this->name = new StringField(required: true, unique: true, help_text: 'The name of this BIND zone.');
+ $this->description = new StringField(
+ default: '',
+ allow_empty: true,
+ help_text: 'A description for this BIND zone.',
+ );
+ $this->type = new StringField(
+ default: 'master',
+ choices: ['master', 'slave', 'forward', 'redirect'],
+ help_text: 'The type of this BIND zone.',
+ );
+ $this->view = new ForeignModelField(
+ model_name: 'BINDView',
+ model_field: 'name',
+ default: [],
+ allow_empty: true,
+ many: true,
+ help_text: 'The views this BIND zone belongs to.',
+ );
+ $this->reversev4 = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ internal_name: 'reverso',
+ conditions: ['type' => ['master', 'slave']],
+ help_text: 'Enable reverse DNS for this BIND zone.',
+ );
+ $this->reversev6 = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ internal_name: 'reversv6o',
+ conditions: ['type' => ['master', 'slave']],
+ help_text: 'Enable reverse IPv6 DNS for this BIND zone.',
+ );
+ $this->rpz = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ conditions: ['type' => ['master', 'slave']],
+ help_text: 'Enable this zone as part of a response policy.',
+ );
+ $this->custom = new Base64Field(
+ default: '',
+ allow_empty: true,
+ help_text: 'Custom BIND options for this BIND zone.',
+ );
+ $this->dnssec = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ conditions: ['type' => ['master', 'slave']],
+ help_text: 'Enable DNSSEC for this BIND zone.',
+ );
+ $this->backupkeys = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ conditions: ['dnssec' => true],
+ help_text: 'Enable backing up DNSSEC keys in the XML configuration for this BIND zone.',
+ );
+ $this->slaveip = new StringField(
+ default: '',
+ allow_empty: true,
+ conditions: ['type' => 'slave'],
+ validators: [new IPAddressValidator()],
+ help_text: 'The IP address of the slave server for this BIND zone.',
+ );
+ $this->forwarders = new StringField(
+ required: true,
+ many: true,
+ delimiter: ';',
+ conditions: ['type' => 'forward'],
+ validators: [new IPAddressValidator()],
+ help_text: 'The forwarders for this BIND zone.',
+ );
+ $this->ttl = new IntegerField(
+ default: null,
+ allow_null: true,
+ internal_name: 'tll',
+ conditions: ['type' => 'master'],
+ help_text: 'The default TTL interval (in seconds) for records within this BIND zone without a specific TTL.',
+ );
+ $this->baseip = new StringField(
+ required: true,
+ internal_name: 'ipns',
+ conditions: ['type' => 'master'],
+ validators: [new IPAddressValidator()],
+ help_text: 'The IP address of the base domain for this zone. This sets an A record for the base domain.',
+ );
+ $this->nameserver = new StringField(
+ required: true,
+ conditions: ['type' => ['master', 'redirect']],
+ validators: [new IPAddressValidator(allow_fqdn: true)],
+ help_text: 'The SOA nameserver for this zone.',
+ );
+ $this->mail = new StringField(
+ required: true,
+ conditions: ['type' => ['master', 'redirect']],
+ validators: [new HostnameValidator(allow_fqdn: true)],
+ help_text: 'The SOA email address (RNAME) for this zone. This must be in an FQDN format.',
+ );
+ $this->serial = new IntegerField(
+ required: true,
+ unique: true,
+ conditions: ['type' => ['master', 'redirect']],
+ help_text: 'The SOA serial number for this zone.',
+ );
+ $this->refresh = new IntegerField(
+ default: null,
+ allow_null: true,
+ conditions: ['type' => ['master', 'redirect']],
+ help_text: 'The SOA refresh interval (in seconds) for this zone.',
+ );
+ $this->retry = new IntegerField(
+ default: null,
+ allow_null: true,
+ conditions: ['type' => ['master', 'redirect']],
+ help_text: 'The SOA retry interval (in seconds) for this zone.',
+ );
+ $this->expire = new IntegerField(
+ default: null,
+ allow_null: true,
+ conditions: ['type' => ['master', 'redirect']],
+ help_text: 'The SOA expiry interval (in seconds) for this zone.',
+ );
+ $this->minimum = new IntegerField(
+ default: null,
+ allow_null: true,
+ conditions: ['type' => ['master', 'redirect']],
+ help_text: 'The SOA minimum TTL interval (in seconds) for this zone. This is also referred to as the ' .
+ 'negative TTL.',
+ );
+ $this->enable_updatepolicy = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ conditions: ['type' => 'master'],
+ help_text: 'Enable a specific dynamic update policy for this BIND zone.',
+ );
+ $this->updatepolicy = new StringField(
+ default: '',
+ allow_empty: true,
+ conditions: ['type' => 'master', 'enable_updatepolicy' => true],
+ help_text: 'The update policy for this BIND zone.',
+ );
+ $this->allowupdate = new ForeignModelField(
+ model_name: 'BINDAccessList',
+ model_field: 'name',
+ default: [],
+ allow_empty: true,
+ many: true,
+ conditions: ['type' => 'master', 'enable_updatepolicy' => false],
+ help_text: "The access lists that are allowed to submit dynamic updates for 'master' " .
+ 'zones (e.g. dynamic DNS).',
+ );
+ $this->allowtransfer = new ForeignModelField(
+ model_name: 'BINDAccessList',
+ model_field: 'name',
+ default: [],
+ allow_empty: true,
+ many: true,
+ conditions: ['type' => 'master'],
+ help_text: 'The access lists that are allowed to transfer this BIND zone.',
+ );
+ $this->allowquery = new ForeignModelField(
+ model_name: 'BINDAccessList',
+ model_field: 'name',
+ default: [],
+ allow_empty: true,
+ many: true,
+ help_text: 'The access lists that are allowed to query this BIND zone.',
+ );
+ $this->regdhcpstatic = new BooleanField(
+ default: false,
+ indicates_true: 'on',
+ help_text: 'Register DHCP static mappings as records in this BIND zone.',
+ );
+ $this->customzonerecords = new Base64Field(
+ default: '',
+ allow_empty: true,
+ help_text: 'Custom records for this BIND zone.',
+ );
+ $this->records = new NestedModelField(
+ model_class: 'BINDZoneRecord',
+ default: [],
+ allow_empty: true,
+ help_text: 'The records for this BIND zone.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the BIND zones.
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDZoneRecord.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDZoneRecord.inc
new file mode 100644
index 00000000..ca1d7b18
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/BINDZoneRecord.inc
@@ -0,0 +1,81 @@
+parent_model_class = 'BINDZone';
+ $this->config_path = 'row';
+ $this->packages = ['pfSense-pkg-bind'];
+ $this->package_includes = ['bind.inc'];
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->name = new StringField(
+ required: true,
+ internal_name: 'hostname',
+ help_text: 'The domain name for this record.',
+ );
+ $this->type = new StringField(
+ required: true,
+ choices: ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'LOC', 'PTR', 'SRV', 'TXT', 'SPF'],
+ internal_name: 'hosttype',
+ help_text: 'The type of record.',
+ );
+ $this->rdata = new StringField(
+ required: true,
+ allow_empty: true,
+ internal_name: 'hostdst',
+ help_text: 'The data for this record. This can be an IP address, domain name, or other data depending on the record type.',
+ );
+ $this->priority = new IntegerField(
+ required: true,
+ internal_name: 'hostvalue',
+ conditions: ['type' => ['MX', 'SRV']],
+ help_text: 'The priority for this record.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Adds extra validation to the 'type' field.
+ * @param string $type The incoming value being validated.
+ * @return string The validated value to be assigned.
+ * @throws ValidationError When the parent zone is a reverse zone and the record type is not NS or PTR.
+ */
+ public function validate_type(string $type): string {
+ # Ensure reverse zones only have NS or PTR records
+ if ($this->parent_model->reversev4->value and !in_array($type, ['NS', 'PTR'])) {
+ throw new ValidationError(
+ message: 'Field `type` must be `NS` or `PTR` when the parent zone is a reverse zone.',
+ response_id: 'BIND_ZONE_RECORD_INVALID_REVERSE_TYPE',
+ );
+ }
+
+ return $type;
+ }
+
+ /**
+ * Applies changes to the BIND zone records
+ */
+ public function apply(): void {
+ (new BINDApplyDispatcher(async: $this->async))->spawn_process();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc
index ab3581f3..4cbdac41 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc
@@ -18,6 +18,7 @@ class Certificate extends Model {
public StringField $descr;
public UIDField $refid;
public StringField $type;
+ public Base64Field $csr;
public Base64Field $crt;
public Base64Field $prv;
@@ -43,6 +44,12 @@ class Certificate extends Model {
'services on this system. Use `user` when this certificate is intended to be assigned to a user for ' .
'authentication purposes.',
);
+ $this->csr = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The X509 certificate signing request string if this certificate is pending an external signature.',
+ );
$this->crt = new Base64Field(
required: true,
validators: [new X509Validator(allow_crt: true)],
@@ -50,7 +57,7 @@ class Certificate extends Model {
);
$this->prv = new Base64Field(
required: true,
- write_only: true,
+ sensitive: true,
validators: [new X509Validator(allow_prv: true, allow_ecprv: true)],
help_text: 'The X509 private key string.',
);
@@ -76,6 +83,15 @@ class Certificate extends Model {
return $prv;
}
+ /**
+ * Extends the default _update() method to ensure any `csr` value is removed before updating a Certificate.
+ */
+ public function _update(): void {
+ # Remove the `csr` field value before updating the Certificate.
+ $this->csr->value = null;
+ parent::_update();
+ }
+
/**
* Deletes this Certificate object from configuration.
* @throws ForbiddenError When the Certificate cannot be deleted because it is in use.
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc
index f3a27fd2..1ebe10cb 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc
@@ -64,7 +64,7 @@ class CertificateAuthority extends Model {
);
$this->prv = new Base64Field(
required: true,
- write_only: true,
+ sensitive: true,
validators: [new X509Validator(allow_prv: true, allow_ecprv: true)],
help_text: 'The X509 private key string.',
);
@@ -121,6 +121,24 @@ class CertificateAuthority extends Model {
);
}
+ # Remove references to this CA from any existing certificates
+ $cert_q = Certificate::query(reverse: true, caref: $this->refid->value);
+ foreach ($cert_q->model_objects as $cert) {
+ $this->del_config("cert/$cert->id/caref");
+ }
+
+ # Remove any CRLs associated with this CA
+ $crl_q = CertificateRevocationList::query(reverse: true, caref: $this->refid->value);
+ foreach ($crl_q->model_objects as $crl) {
+ $this->del_config("crl/$crl->id");
+ }
+
+ # Delete an intermediate CAs signed by this CA
+ $ca_q = CertificateAuthority::query(reverse: true, caref: $this->refid->value, id__except: $this->id);
+ foreach ($ca_q->model_objects as $ca) {
+ $this->del_config("ca/$ca->id");
+ }
+
parent::_delete();
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityGenerate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityGenerate.inc
new file mode 100644
index 00000000..d2860f03
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityGenerate.inc
@@ -0,0 +1,276 @@
+config_path = 'ca';
+ $this->verbose_name = 'Certificate Authority (Generated)';
+ $this->verbose_name_plural = 'Certificate Authorities (Generated)';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->descr = new StringField(
+ required: true,
+ validators: [new RegexValidator(pattern: "/[\?\>\<\&\/\\\"\']/", invert: true)],
+ help_text: 'The descriptive name for this certificate authority.',
+ );
+ $this->refid = new UIDField(
+ help_text: 'The unique ID assigned to this certificate authority for internal system use. This value is ' .
+ 'generated by this system and cannot be changed.',
+ );
+ $this->trust = new BooleanField(
+ default: false,
+ indicates_true: 'enabled',
+ help_text: "Adds or removes this certificate authority from the operating system's trust stored.",
+ );
+ $this->randomserial = new BooleanField(
+ default: false,
+ indicates_true: 'enabled',
+ help_text: 'Enables or disables the randomization of serial numbers for certificates signed by this CA.',
+ );
+ $this->serial = new IntegerField(
+ default: 1,
+ read_only: true,
+ minimum: 0,
+ help_text: 'The decimal number to be used as a sequential serial number for the next certificate to be ' .
+ 'signed by this CA. This value is ignored when Randomize Serial is checked.',
+ );
+ $this->is_intermediate = new BooleanField(
+ default: false,
+ representation_only: true,
+ help_text: 'Indicates if this certificate authority is an intermediate certificate authority.',
+ );
+ $this->caref = new ForeignModelField(
+ model_name: 'CertificateAuthority',
+ model_field: 'refid',
+ required: true,
+ conditions: ['is_intermediate' => true],
+ help_text: 'The certificate authority to use as the parent for this intermediate certificate authority.',
+ );
+ $this->keytype = new StringField(
+ required: true,
+ choices: ['RSA', 'ECDSA'],
+ representation_only: true,
+ help_text: 'The type of key pair to generate.',
+ );
+ $this->keylen = new IntegerField(
+ required: true,
+ choices: [1024, 2048, 3072, 4096, 6144, 7680, 8192, 15360, 16384],
+ representation_only: true,
+ conditions: ['keytype' => 'RSA'],
+ help_text: 'The length of the RSA key pair to generate.',
+ );
+ $this->ecname = new StringField(
+ required: true,
+ choices_callable: 'get_ecname_choices',
+ representation_only: true,
+ conditions: ['keytype' => 'ECDSA'],
+ help_text: 'The name of the elliptic curve to use for the ECDSA key pair.',
+ );
+ $this->digest_alg = new StringField(
+ required: true,
+ choices_callable: 'get_digest_alg_choices',
+ representation_only: true,
+ help_text: 'The digest algorithm to use when signing certificates.',
+ );
+ $this->lifetime = new IntegerField(
+ default: 3650,
+ representation_only: true,
+ minimum: 1,
+ maximum: 12000,
+ help_text: 'The number of days the certificate authority is valid for.',
+ );
+ $this->dn_commonname = new StringField(
+ default: 'internal-ca',
+ representation_only: true,
+ help_text: 'The common name for the certificate authority.',
+ );
+ $this->dn_country = new StringField(
+ default: '',
+ choices_callable: 'get_country_choices',
+ allow_empty: true,
+ representation_only: true,
+ help_text: 'The country for the certificate authority.',
+ );
+ $this->dn_state = new StringField(
+ default: '',
+ allow_empty: true,
+ representation_only: true,
+ help_text: 'The state for the certificate authority.',
+ );
+ $this->dn_city = new StringField(
+ default: '',
+ allow_empty: true,
+ representation_only: true,
+ help_text: 'The city for the certificate authority.',
+ );
+ $this->dn_organization = new StringField(
+ default: '',
+ allow_empty: true,
+ representation_only: true,
+ help_text: 'The organization for the certificate authority.',
+ );
+ $this->dn_organizationalunit = new StringField(
+ default: '',
+ allow_empty: true,
+ representation_only: true,
+ help_text: 'The organizational unit for the certificate authority.',
+ );
+ $this->crt = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The X509 certificate string.',
+ );
+ $this->prv = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ sensitive: true,
+ help_text: 'The X509 private key string.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Extends the default _create method to ensure the certificate is generated before it is written to config.
+ */
+ protected function _create(): void {
+ # Generate the certificate authority
+ $this->is_intermediate->value ? $this->generate_intermediate_ca() : $this->generate_ca();
+
+ # Call the parent _create method to write the certificate authority to config
+ parent::_create();
+ }
+
+ /**
+ * Apply CA changes to the OS trust store.
+ */
+ public function apply(): void {
+ ca_setup_trust_store();
+ }
+
+ /**
+ * Converts this CertificateAuthority object's DN values into a X509 DN array.
+ * @returns array The X509 DN array.
+ */
+ private function to_x509_dn(): array {
+ return [
+ 'commonName' => $this->dn_commonname->value,
+ 'countryName' => $this->dn_country->value,
+ 'stateOrProvinceName' => $this->dn_state->value,
+ 'localityName' => $this->dn_city->value,
+ 'organizationName' => $this->dn_organization->value,
+ 'organizationalUnitName' => $this->dn_organizationalunit->value,
+ ];
+ }
+
+ /**
+ * Generates a new CA certificate and key pair using the requested parameters. This populate the `crt` and `prv` fields.
+ * @throws ServerError When the CA certificate and key pair fails to be generated.
+ */
+ private function generate_ca(): void {
+ # Define a placeholder for create_ca() to populate
+ $ca = [];
+
+ # Generate the CA certificate and key pair
+ $success = ca_create(
+ ca: $ca,
+ lifetime: $this->lifetime->value,
+ dn: $this->to_x509_dn(),
+ digest_alg: $this->digest_alg->value,
+ keytype: $this->keytype->value,
+ keylen: $this->keylen->value,
+ ecname: $this->ecname->value,
+ );
+
+ # Throw a server error if the CA certificate and key pair fails to be generated
+ if (!$success) {
+ throw new ServerError(
+ message: 'Failed to generate the certificate authority for unknown reason.',
+ response_id: 'CERTIFICATE_AUTHORITY_GENERATE_FAILED',
+ );
+ }
+
+ # Populate the `crt` and `prv` fields with the generated values
+ $this->crt->from_internal($ca['crt']);
+ $this->prv->from_internal($ca['prv']);
+ $this->serial->value = $ca['serial'];
+ }
+
+ /**
+ * Generates a new CA certificate and key pair using the requested parameters. This populate the `crt` and `prv` fields.
+ * @throws ServerError When the CA certificate and key pair fails to be generated.
+ */
+ private function generate_intermediate_ca(): void {
+ # Define a placeholder for ca_inter_create() to populate
+ $ca = [];
+
+ # Generate the intermediate CA certificate and key pair
+ # Note: This also bumps the serial number of the parent CA by 1
+ $success = ca_inter_create(
+ ca: $ca,
+ caref: $this->caref->value,
+ lifetime: $this->lifetime->value,
+ dn: $this->to_x509_dn(),
+ digest_alg: $this->digest_alg->value,
+ keytype: $this->keytype->value,
+ keylen: $this->keylen->value,
+ ecname: $this->ecname->value,
+ );
+
+ # Throw a server error if the CA certificate and key pair fails to be generated
+ if (!$success) {
+ throw new ServerError(
+ message: 'Failed to generate the intermediate certificate authority for unknown reason.',
+ response_id: 'CERTIFICATE_AUTHORITY_GENERATE_INTERMEDIATE_FAILED',
+ );
+ }
+
+ # Populate the `crt` and `prv` fields with the generated values
+ $this->crt->from_internal($ca['crt']);
+ $this->prv->from_internal($ca['prv']);
+ $this->serial->value = $ca['serial'];
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc
new file mode 100644
index 00000000..98f0d72c
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthorityRenew.inc
@@ -0,0 +1,131 @@
+internal_callable = 'get_internal';
+ $this->many = false;
+
+ # Set model fields
+ $this->caref = new ForeignModelField(
+ model_name: 'CertificateAuthority',
+ model_field: 'refid',
+ required: true,
+ help_text: 'The Certificate Authority to renew.',
+ );
+ $this->reusekey = new BooleanField(
+ default: true,
+ indicates_true: true,
+ help_text: 'Reuses the existing private key when renewing the certificate authority.',
+ );
+ $this->reuseserial = new BooleanField(
+ default: true,
+ indicates_true: true,
+ help_text: 'Reuses the existing serial number when renewing the certificate authority.',
+ );
+ $this->strictsecurity = new BooleanField(
+ default: false,
+ indicates_true: true,
+ help_text: 'Enforces strict security measures when renewing the certificate authority.',
+ );
+ $this->oldserial = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The old serial number of the Certificate Authority before the renewal.',
+ );
+ $this->newserial = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The new serial number of the Certificate Authority after the renewal.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Returns the internal data for this model.
+ * @return array The internal data for this model.
+ */
+ public function get_internal(): array {
+ # Ensure 'reusekey' and 'reuseserial' are set to true by default
+ return ['reusekey' => true, 'reuseserial' => true];
+ }
+
+ /**
+ * Adds extra validation to he 'caref' field.
+ * @param string $caref The incoming value to validate.
+ * @return string The validated value to assign.
+ */
+ public function validate_caref(string $caref): string {
+ # Ensure the Certificate Authority is capable of being renewed.
+ if (!is_cert_locally_renewable($this->caref->get_related_model()->to_internal())) {
+ throw new NotAcceptableError(
+ message: "Certificate Authority with refid `$caref` is not capable of being renewed.",
+ response_id: 'CERTIFICATE_AUTHORITY_RENEW_UNAVAILABLE',
+ );
+ }
+
+ return $caref;
+ }
+
+ /**
+ * Renews the specified Certificate Authority.
+ */
+ public function _create(): void {
+ # Extract details from the Certificate Authority to renew
+ $ca_config = &lookup_ca($this->caref->value);
+ $this->oldserial->value = cert_get_serial($ca_config['crt']);
+
+ # Renew the cert using pfSense's built in cert_renew function
+ $renewed = cert_renew(
+ $ca_config,
+ reusekey: $this->reusekey->value,
+ strictsecurity: $this->strictsecurity->value,
+ reuseserial: $this->reuseserial->value,
+ );
+
+ # Throw an error if the renewal failed
+ if (!$renewed) {
+ throw new ServerError(
+ message: 'Failed to renew the Certificate Authority for unknown reason.',
+ response_id: 'CERTIFICATE_AUTHORITY_RENEW_FAILED',
+ );
+ }
+
+ # Otherwise, continue with the renewal
+ $this->newserial->value = cert_get_serial($ca_config['crt']);
+ $msg = "Renewed CA {$ca_config['descr']} ({$ca_config['refid']}) - Serial {$this->oldserial->value} -> {$this->newserial->value}";
+ $this->log_error($msg);
+ $this->write_config($msg);
+ }
+
+ /**
+ * Apply changes to this CA to backend services
+ */
+ public function apply(): void {
+ # Reconfigure the OS truststore and restart services that use the CA
+ ca_setup_trust_store();
+ cert_restart_services(ca_get_all_services($this->caref->value));
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc
new file mode 100644
index 00000000..5dd3c35e
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateGenerate.inc
@@ -0,0 +1,238 @@
+config_path = 'cert';
+ $this->verbose_name = 'Certificate (Generated)';
+ $this->verbose_name_plural = 'Certificate (Generated)';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->descr = new StringField(
+ required: true,
+ validators: [new RegexValidator(pattern: "/[\?\>\<\&\/\\\"\']/", invert: true)],
+ help_text: 'The descriptive name for this certificate.',
+ );
+ $this->refid = new UIDField(
+ help_text: 'The unique ID assigned to this certificate for internal system use. This value is ' .
+ 'generated by this system and cannot be changed.',
+ );
+ $this->caref = new ForeignModelField(
+ model_name: 'CertificateAuthority',
+ model_field: 'refid',
+ required: true,
+ help_text: 'The certificate authority to use as the parent for this certificate.',
+ );
+ $this->keytype = new StringField(
+ required: true,
+ choices: ['RSA', 'ECDSA'],
+ representation_only: true,
+ help_text: 'The type of key pair to generate.',
+ );
+ $this->keylen = new IntegerField(
+ required: true,
+ choices: [1024, 2048, 3072, 4096, 6144, 7680, 8192, 15360, 16384],
+ representation_only: true,
+ conditions: ['keytype' => 'RSA'],
+ help_text: 'The length of the RSA key pair to generate.',
+ );
+ $this->ecname = new StringField(
+ required: true,
+ choices_callable: 'get_ecname_choices',
+ representation_only: true,
+ conditions: ['keytype' => 'ECDSA'],
+ help_text: 'The name of the elliptic curve to use for the ECDSA key pair.',
+ );
+ $this->digest_alg = new StringField(
+ required: true,
+ choices_callable: 'get_digest_alg_choices',
+ representation_only: true,
+ help_text: 'The digest method used when the certificate is signed.',
+ );
+ $this->lifetime = new IntegerField(
+ default: 3650,
+ representation_only: true,
+ minimum: 1,
+ maximum: 12000,
+ help_text: 'The number of days the certificate is valid for.',
+ );
+ $this->dn_commonname = new StringField(
+ required: true,
+ representation_only: true,
+ help_text: 'The common name of the certificate.',
+ );
+ $this->dn_country = new StringField(
+ default: null,
+ choices_callable: 'get_country_choices',
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The country of the certificate.',
+ );
+ $this->dn_state = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The state/province of the certificate.',
+ );
+ $this->dn_city = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The city of the certificate.',
+ );
+ $this->dn_organization = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The organization of the certificate.',
+ );
+ $this->dn_organizationalunit = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The organizational unit of the certificate.',
+ );
+ $this->type = new StringField(
+ default: 'user',
+ choices: ['server', 'user'],
+ help_text: 'The type of certificate to generate.',
+ );
+ $this->dn_dns_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)],
+ help_text: 'The DNS Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_email_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new EmailAddressValidator()],
+ help_text: 'The Email Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_ip_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)],
+ help_text: 'The IP Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_uri_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new URLValidator()],
+ help_text: 'The URI Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->crt = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The X509 certificate string.',
+ );
+ $this->prv = new Base64Field(
+ default: null,
+ allow_null: true,
+ sensitive: true,
+ help_text: 'The X509 private key string.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Extends the default _create method to ensure the certificate is generated before it is written to config.
+ */
+ protected function _create(): void {
+ # Generate the certificate
+ $this->generate_cert();
+
+ # Call the parent _create method to write the certificate to config
+ parent::_create();
+ }
+
+ /**
+ * Generates a new certificate and key pair using the requested parameters. This populate the `crt` and `prv` fields.
+ * @throws ServerError When the cert certificate and key pair fails to be generated.
+ */
+ private function generate_cert(): void {
+ # Define a placeholder for create_cert() to populate
+ $cert = [];
+
+ # Generate the certificate and key pair
+ $success = cert_create(
+ cert: $cert,
+ caref: $this->caref->value,
+ lifetime: $this->lifetime->value,
+ dn: $this->to_x509_dn($this),
+ type: $this->type->value,
+ digest_alg: $this->digest_alg->value,
+ keytype: $this->keytype->value,
+ keylen: $this->keylen->value,
+ ecname: $this->ecname->value,
+ );
+
+ # Throw a server error if the certificate and key pair fails to be generated
+ if (!$success) {
+ throw new ServerError(
+ message: 'Failed to generate the certificate for unknown reason.',
+ response_id: 'CERTIFICATE_GENERATE_FAILED',
+ );
+ }
+
+ # Populate the `crt` and `prv` fields with the generated values
+ $this->crt->from_internal($cert['crt']);
+ $this->prv->from_internal($cert['prv']);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificatePKCS12Export.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificatePKCS12Export.inc
new file mode 100644
index 00000000..50abc448
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificatePKCS12Export.inc
@@ -0,0 +1,91 @@
+internal_callable = 'get_internal';
+ $this->verbose_name = 'Certificate PKCS#12 Export';
+
+ # Define Model Fields
+ $this->certref = new ForeignModelField(
+ model_name: 'Certificate',
+ model_field: 'refid',
+ required: true,
+ help_text: 'The Certificate to export as a PKCS12 file.',
+ );
+ $this->encryption = new StringField(
+ default: 'high',
+ choices: ['high', 'low', 'legacy'],
+ help_text: 'The level of encryption to use when exporting the PKCS#12 archive.',
+ );
+ $this->passphrase = new StringField(
+ default: '',
+ allow_empty: true,
+ help_text: 'The passphrase to use when exporting the PKCS#12 archive. Leave empty for no passphrase.',
+ );
+ $this->filename = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The filename used when exporting the PKCS#12 archive. This value cannot be changed and will ' .
+ 'always be certificate refid with the .p12 extension.',
+ );
+ $this->binary_data = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The PKCS#12 archive binary data. This value cannot be changed.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Psuedo-method to return the internal data of the Model. This Model has no internal data but must use an internal
+ * callable.
+ * @return array An empty array.
+ */
+ public function get_internal(): array {
+ return [];
+ }
+
+ /**
+ * Creates the PKCS#12 archive file based on the Model data.
+ */
+ public function _create(): void {
+ # Set the filename. This will be used by the BinaryContentHandler to set the filename of the download.
+ $this->filename->value = $this->certref->value . '.p12';
+
+ # Create the PKCS#12 archive
+ $this->binary_data->value = cert_pkcs12_export(
+ cert: $this->certref->get_related_model()->to_internal(),
+ encryption: $this->encryption->value,
+ passphrase: $this->passphrase->value,
+ delivery: 'data',
+ );
+
+ # Throw an error if the PKCS#12 archive could not be created
+ if (!$this->binary_data->value) {
+ throw new ServerError(
+ message: 'The PKCS#12 archive could not be created for unknown reasons.',
+ response_id: 'CERTIFICATE_PKCS12_EXPORT_CREATION_FAILED',
+ );
+ }
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc
new file mode 100644
index 00000000..dcfa69fb
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRenew.inc
@@ -0,0 +1,131 @@
+internal_callable = 'get_internal';
+ $this->many = false;
+
+ # Set model fields
+ $this->certref = new ForeignModelField(
+ model_name: 'Certificate',
+ model_field: 'refid',
+ required: true,
+ help_text: 'The `refid` of the Certificate to renew.',
+ );
+ $this->reusekey = new BooleanField(
+ default: true,
+ indicates_true: true,
+ help_text: 'Reuses the existing private key when renewing the certificate.',
+ );
+ $this->reuseserial = new BooleanField(
+ default: true,
+ indicates_true: true,
+ help_text: 'Reuses the existing serial number when renewing the certificate.',
+ );
+ $this->strictsecurity = new BooleanField(
+ default: false,
+ indicates_true: true,
+ help_text: 'Enforces strict security measures when renewing the certificate.',
+ );
+ $this->oldserial = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The old serial number of the Certificate before the renewal.',
+ );
+ $this->newserial = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The new serial number of the Certificate after the renewal.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Returns the internal data for this model.
+ * @return array The internal data for this model.
+ */
+ public function get_internal(): array {
+ # Ensure 'reusekey' and 'reuseserial' are set to true by default
+ return ['reusekey' => true, 'reuseserial' => true];
+ }
+
+ /**
+ * Adds extra validation to he 'certref' field.
+ * @param string $certref The incoming value to validate.
+ * @return string The validated value to assign.
+ */
+ public function validate_certref(string $certref): string {
+ # Ensure the Certificate is capable of being renewed.
+ if (!is_cert_locally_renewable(lookup_cert($certref))) {
+ throw new NotAcceptableError(
+ message: "Certificate with refid `$certref` is not capable of being renewed.",
+ response_id: 'CERTIFICATE_RENEW_UNAVAILABLE',
+ );
+ }
+
+ return $certref;
+ }
+
+ /**
+ * Renews the specified Certificate.
+ */
+ public function _create(): void {
+ # Extract details from the Certificate to renew
+ $cert_config = &lookup_cert($this->certref->value);
+ $this->oldserial->value = cert_get_serial($cert_config['crt']);
+
+ # Renew the cert using pfSense's built in cert_renew function
+ $renewed = cert_renew(
+ $cert_config,
+ reusekey: $this->reusekey->value,
+ strictsecurity: $this->strictsecurity->value,
+ reuseserial: $this->reuseserial->value,
+ );
+
+ # Throw an error if the renewal failed
+ if (!$renewed) {
+ throw new ServerError(
+ message: 'Failed to renew the Certificate for unknown reason.',
+ response_id: 'CERTIFICATE_RENEW_FAILED',
+ );
+ }
+
+ # Otherwise, continue with the renewal
+ $this->newserial->value = cert_get_serial($cert_config['crt']);
+ $msg = "Renewed certificate {$cert_config['descr']} ({$cert_config['refid']}) - Serial {$this->oldserial->value} -> {$this->newserial->value}";
+ $this->log_error($msg);
+ $this->write_config($msg);
+ }
+
+ /**
+ * Apply changes to this certificate to backend services
+ */
+ public function apply(): void {
+ # Reconfigure the OS truststore and restart services that use the certificate
+ ca_setup_trust_store();
+ cert_restart_services(cert_get_all_services($this->certref->value));
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationList.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationList.inc
index 54b6461a..449f3652 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationList.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationList.inc
@@ -6,8 +6,11 @@ use RESTAPI\Core\Model;
use RESTAPI\Fields\Base64Field;
use RESTAPI\Fields\ForeignModelField;
use RESTAPI\Fields\IntegerField;
+use RESTAPI\Fields\NestedModelField;
use RESTAPI\Fields\StringField;
use RESTAPI\Fields\UIDField;
+use RESTAPI\Responses\ConflictError;
+use RESTAPI\Responses\ServerError;
use RESTAPI\Validators\RegexValidator;
/**
@@ -15,12 +18,13 @@ use RESTAPI\Validators\RegexValidator;
*/
class CertificateRevocationList extends Model {
public UIDField $refid;
- public StringField $descr;
public ForeignModelField $caref;
+ public StringField $descr;
public StringField $method;
public IntegerField $lifetime;
public IntegerField $serial;
public Base64Field $text;
+ public NestedModelField $cert;
public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], ...$options) {
# Set model attributes
@@ -73,14 +77,86 @@ class CertificateRevocationList extends Model {
conditions: ['method' => 'existing'],
help_text: 'The raw x509 CRL data.',
);
+ $this->cert = new NestedModelField(
+ model_class: 'CertificateRevocationListRevokedCertificate',
+ default: [],
+ allow_empty: true,
+ conditions: ['method' => 'internal'],
+ help_text: 'The list of revoked certificates in this CRL.',
+ );
parent::__construct($id, $parent_id, $data, ...$options);
}
+ /**
+ * Extend the default _create() method to generate the X509 CRL data before writing to config.
+ */
+ public function _create(): void {
+ # If this is an internal CRL, make the CRL first
+ if ($this->method->value === 'internal') {
+ $this->text->value = $this->to_x509_crl();
+ }
+
+ parent::_create();
+ }
+
+ /**
+ * Extend the default _update() method to regenerate the X509 CRL data before writing to config.
+ */
+ public function _update(): void {
+ # If this is an internal CRL, update the CRL first
+ if ($this->method->value === 'internal') {
+ $this->text->value = $this->to_x509_crl();
+ $this->serial->value++; # Bump the serial number
+ }
+
+ parent::_update();
+ }
+
+ /**
+ * Extends the default _delete() method to prevent deletion of the CRLs in use.
+ */
+ protected function _delete(): void {
+ # Don't allow this CRL to be deleted if it's in use.
+ if (crl_in_use($this->refid->value)) {
+ throw new ConflictError(
+ message: "CRL with refid '{$this->refid->value}' cannot be deletd because it is in use.",
+ response_id: 'CERTIFICATE_REVOCATION_LIST_CANNOT_DELETE_WHILE_IN_USE',
+ );
+ }
+
+ parent::_delete();
+ }
+
+ /**
+ * Converts this CertificateRevocationModel to an x509 CRL data.
+ * @returns string The x509 CRL data.
+ */
+ public function to_x509_crl(): string {
+ # Prep the CRL config for generation
+ $crl_config = $this->to_internal();
+ $crl_config['idx'] = $this->id;
+
+ # Attempt to update/generate the CRL
+ $crl_gen = crl_update($crl_config);
+
+ # If the CRL generation was successful, return the CRL data
+ if ($crl_gen) {
+ return base64_decode($crl_gen);
+ }
+
+ # Otherwise, throw an error
+ throw new ServerError(
+ message: 'Failed to generate the CRL data for unknown reason.',
+ response_id: 'CERTIFICATE_REVOCATION_LIST_FAILED_TO_GENERATE_CRL',
+ );
+ }
+
/**
* Applies the newly created CRL by reloading the OpenVPN and IPsec services.
*/
public function apply(): void {
+ # Restart OpenVPN and IPsec services
openvpn_refresh_crls();
ipsec_configure();
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationListRevokedCertificate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationListRevokedCertificate.inc
new file mode 100644
index 00000000..5694deed
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateRevocationListRevokedCertificate.inc
@@ -0,0 +1,133 @@
+parent_model_class = 'CertificateRevocationList';
+ $this->config_path = 'cert';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->certref = new ForeignModelField(
+ model_name: 'Certificate',
+ model_field: 'refid',
+ required: true,
+ editable: false,
+ internal_name: 'refid',
+ conditions: ['serial' => null],
+ help_text: 'The reference ID of the certificate to be revoked',
+ );
+ $this->serial = new StringField(
+ default: null,
+ allow_null: true,
+ help_text: 'The serial number of the certificate to be revoked.',
+ );
+ $this->reason = new IntegerField(
+ default: 0,
+ choices: [-1, 0, 1, 2, 3, 4, 5, 6, 9],
+ minimum: -1,
+ help_text: 'The CRL reason for revocation code.',
+ );
+ $this->revoke_time = new UnixTimeField(
+ required: true,
+ auto_add_now: true,
+ help_text: 'The unix timestamp of when the certificate was revoked.',
+ );
+ $this->descr = new StringField(
+ default: null,
+ allow_null: true,
+ write_only: true,
+ help_text: 'The unique name/description for this CRL.',
+ );
+ $this->caref = new StringField(
+ default: null,
+ allow_null: true,
+ write_only: true,
+ help_text: 'The unique ID of the CA that signed the revoked certificate.',
+ );
+ $this->type = new StringField(
+ default: null,
+ allow_null: true,
+ write_only: true,
+ help_text: 'The type of the certificate to be revoked.',
+ );
+ $this->crt = new Base64Field(
+ default: null,
+ allow_null: true,
+ write_only: true,
+ help_text: 'The X509 certificate string.',
+ );
+ $this->prv = new Base64Field(
+ default: null,
+ allow_null: true,
+ write_only: true,
+ help_text: 'The X509 private key string.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Adds extra validation for this entire Model
+ * @throws ConflictError if both a serial number and a certificate reference are set
+ */
+ public function validate_extra(): void {
+ # Do not allow the parent CRL to be used if it's not an internal CRL
+ if ($this->parent_model->method->value !== 'internal') {
+ throw new NotAcceptableError(
+ message: "Parent CRL with '$this->parent_id' can't revoke certificates because it's an external CRL.",
+ response_id: 'CERTIFICATE_REVOCATION_LIST_REVOKED_CERTIFICATE_PARENT_NOT_INTERNAL',
+ );
+ }
+
+ # If a `certref` is set, add attributes from the referenced certificate
+ if ($this->certref->value) {
+ $cert = $this->certref->get_related_model();
+ $this->descr->value = $cert->descr->value;
+ $this->serial->value = $cert->serial->value;
+ $this->caref->value = $cert->caref->value;
+ $this->type->value = $cert->type->value;
+ $this->crt->value = $cert->crt->value;
+ $this->prv->value = $cert->prv->value;
+ }
+ }
+
+ /**
+ * Applies the newly created CRL by reloading the OpenVPN and IPsec services.
+ */
+ public function apply(): void {
+ # Update the parent CRL. This ensures the CRL's X509 data is updated to reflect the changes.
+ $this->parent_model->client = $this->client;
+ $this->parent_model->from_internal();
+ $this->parent_model->update();
+
+ # Reload the OpenVPN and IPsec services
+ openvpn_refresh_crls();
+ ipsec_configure();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequest.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequest.inc
new file mode 100644
index 00000000..d38a4163
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequest.inc
@@ -0,0 +1,228 @@
+config_path = 'cert';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->descr = new StringField(
+ required: true,
+ validators: [new RegexValidator(pattern: "/[\?\>\<\&\/\\\"\']/", invert: true)],
+ help_text: 'The descriptive name for this certificate.',
+ );
+ $this->refid = new UIDField(
+ help_text: 'The unique ID assigned to this certificate for internal system use. This value is ' .
+ 'generated by this system and cannot be changed.',
+ );
+ $this->keytype = new StringField(
+ required: true,
+ choices: ['RSA', 'ECDSA'],
+ representation_only: true,
+ help_text: 'The type of key pair to generate.',
+ );
+ $this->keylen = new IntegerField(
+ required: true,
+ choices: [1024, 2048, 3072, 4096, 6144, 7680, 8192, 15360, 16384],
+ representation_only: true,
+ conditions: ['keytype' => 'RSA'],
+ help_text: 'The length of the RSA key pair to generate.',
+ );
+ $this->ecname = new StringField(
+ required: true,
+ choices_callable: 'get_ecname_choices',
+ representation_only: true,
+ conditions: ['keytype' => 'ECDSA'],
+ help_text: 'The name of the elliptic curve to use for the ECDSA key pair.',
+ );
+ $this->digest_alg = new StringField(
+ required: true,
+ choices_callable: 'get_digest_alg_choices',
+ representation_only: true,
+ help_text: 'The digest method used when the certificate is signed.',
+ );
+ $this->lifetime = new IntegerField(
+ default: 3650,
+ representation_only: true,
+ minimum: 1,
+ maximum: 12000,
+ help_text: 'The number of days the certificate is valid for.',
+ );
+ $this->dn_commonname = new StringField(
+ required: true,
+ representation_only: true,
+ help_text: 'The common name of the certificate.',
+ );
+ $this->dn_country = new StringField(
+ default: null,
+ choices_callable: 'get_country_choices',
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The country of the certificate.',
+ );
+ $this->dn_state = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The state/province of the certificate.',
+ );
+ $this->dn_city = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The city of the certificate.',
+ );
+ $this->dn_organization = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The organization of the certificate.',
+ );
+ $this->dn_organizationalunit = new StringField(
+ default: null,
+ allow_null: true,
+ representation_only: true,
+ help_text: 'The organizational unit of the certificate.',
+ );
+ $this->type = new StringField(
+ default: 'user',
+ choices: ['server', 'user'],
+ help_text: 'The type of certificate to generate.',
+ );
+ $this->dn_dns_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)],
+ help_text: 'The DNS Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_email_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new EmailAddressValidator()],
+ help_text: 'The Email Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_ip_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)],
+ help_text: 'The IP Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_uri_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new URLValidator()],
+ help_text: 'The URI Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->csr = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The X509 certificate signing request string. You will need to provide this to a ' .
+ 'certificate authority to sign the certificate.',
+ );
+ $this->prv = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ sensitive: true,
+ help_text: 'The X509 private key string.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Extends the default _create method to ensure the certificate is generated before it is written to config.
+ */
+ protected function _create(): void {
+ # Generate the certificate
+ $this->generate_csr();
+
+ # Call the parent _create method to write the certificate to config
+ parent::_create();
+ }
+
+ /**
+ * Generates a new CSR and key using the requested parameters. This populate the `csr` and `prv` fields.
+ * @throws ServerError When the CSR and key fails to be generated.
+ */
+ private function generate_csr(): void {
+ # Define a placeholder for csr_generate() to populate
+ $csr = [];
+
+ # Generate the CSR and key pair
+ $success = csr_generate(
+ cert: $csr,
+ keylen: $this->keylen->value,
+ dn: $this->to_x509_dn($this),
+ type: $this->type->value,
+ digest_alg: $this->digest_alg->value,
+ keytype: $this->keytype->value,
+ ecname: $this->ecname->value,
+ );
+
+ # Throw a server error if the CSR and key fails to be generated
+ if (!$success) {
+ throw new ServerError(
+ message: 'Failed to generate the certificate signing request for unknown reason.',
+ response_id: 'CERTIFICATE_SIGNING_REQUEST_GENERATE_FAILED',
+ );
+ }
+
+ # Populate the `csr` and `prv` fields with the generated values
+ $this->csr->from_internal($csr['csr']);
+ $this->prv->from_internal($csr['prv']);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequestSign.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequestSign.inc
new file mode 100644
index 00000000..6b98b095
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateSigningRequestSign.inc
@@ -0,0 +1,178 @@
+config_path = 'cert';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->descr = new StringField(
+ required: true,
+ validators: [new RegexValidator(pattern: "/[\?\>\<\&\/\\\"\']/", invert: true)],
+ help_text: 'The descriptive name for this certificate.',
+ );
+ $this->refid = new UIDField(
+ help_text: 'The unique ID assigned to this certificate for internal system use. This value is ' .
+ 'generated by this system and cannot be changed.',
+ );
+ $this->caref = new ForeignModelField(
+ model_name: 'CertificateAuthority',
+ model_field: 'refid',
+ required: true,
+ help_text: 'The certificate authority to sign the certificate with.',
+ );
+ $this->csr = new Base64Field(
+ required: true,
+ representation_only: true,
+ validators: [new X509Validator(allow_crt: false, allow_csr: true)],
+ help_text: 'The X509 certificate signing request to sign.',
+ );
+ $this->crt = new Base64Field(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The X509 certificate string.',
+ );
+ $this->prv = new Base64Field(
+ default: null,
+ allow_null: true,
+ sensitive: true,
+ help_text: 'The X509 private key string.',
+ );
+ $this->digest_alg = new StringField(
+ required: true,
+ choices_callable: 'get_digest_alg_choices',
+ representation_only: true,
+ help_text: 'The digest method used when the certificate is signed.',
+ );
+ $this->lifetime = new IntegerField(
+ default: 3650,
+ representation_only: true,
+ minimum: 1,
+ maximum: 12000,
+ help_text: 'The number of days the certificate is valid for.',
+ );
+ $this->type = new StringField(
+ default: 'user',
+ choices: ['server', 'user'],
+ help_text: 'The type of certificate to generate.',
+ );
+ $this->dn_dns_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)],
+ help_text: 'The DNS Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_email_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new EmailAddressValidator()],
+ help_text: 'The Email Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_ip_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)],
+ help_text: 'The IP Subject Alternative Names (SANs) for the certificate.',
+ );
+ $this->dn_uri_sans = new StringField(
+ default: [],
+ allow_empty: true,
+ representation_only: true,
+ many: true,
+ validators: [new URLValidator()],
+ help_text: 'The URI Subject Alternative Names (SANs) for the certificate.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Extends the default _create method to ensure the certificate is generated before it is written to config.
+ */
+ protected function _create(): void {
+ # Sign the CSR
+ $this->sign_csr();
+
+ # Call the parent _create method to write the certificate to config
+ parent::_create();
+ }
+
+ /**
+ * Signs a CSR using the requested parameters. This populates the `crt` fields
+ * @throws ServerError When the CSR fails to be signed.
+ */
+ private function sign_csr(): void {
+ global $config;
+
+ # Define a placeholder for sign_csr() to populate and obtain the CA config
+ $ca = $this->caref->get_related_model();
+ $ca_conf = $ca->to_internal();
+
+ # Generate the CSR and key pair
+ $cert = csr_sign(
+ csr: $this->csr->value,
+ ca: $ca_conf,
+ duration: $this->lifetime->value,
+ type: $this->type->value,
+ altnames: $this->to_x509_dn($this)['subjectAltName'],
+ digest_alg: $this->digest_alg->value,
+ );
+
+ # Throw a server error if the CSR fails to be signed
+ if (!$cert) {
+ throw new ServerError(
+ message: 'Failed to sign the certificate signing request for unknown reason.',
+ response_id: 'CERTIFICATE_SIGNING_REQUEST_SIGN_FAILED',
+ );
+ }
+
+ # Populate the `crt` fields with the generated values and assign changes to our CA
+ $this->crt->value = $cert;
+ $this->set_config("ca/$ca->id/serial", $ca->serial->value + 1);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
index e75fbfeb..39fc3a76 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
@@ -52,6 +52,7 @@ class DHCPServer extends Model {
public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) {
# Define Model attributes
$this->config_path = 'dhcpd';
+ $this->id_type = 'string';
$this->many = true;
$this->subsystem = 'dhcpd';
$this->update_strategy = 'merge';
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerLease.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerLease.inc
index fdc30783..c9d76aa4 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerLease.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServerLease.inc
@@ -3,13 +3,17 @@
namespace RESTAPI\Models;
use RESTAPI\Core\Model;
+use RESTAPI\Core\ModelSet;
use RESTAPI\Fields\InterfaceField;
use RESTAPI\Fields\StringField;
+use RESTAPI\Responses\ServerError;
/**
* Defines a model that represents a DHCP lease.
*/
class DHCPServerLease extends Model {
+ const ISC_LEASES_FILE = '/var/dhcpd/var/db/dhcpd.leases';
+
public StringField $ip;
public StringField $mac;
public StringField $hostname;
@@ -24,6 +28,7 @@ class DHCPServerLease extends Model {
# Set model attributes
$this->internal_callable = 'get_dhcp_leases';
$this->many = true;
+ $this->always_apply = true;
# Set model fields
$this->ip = new StringField(default: null, allow_null: true, help_text: 'The IP address of the lease.');
@@ -60,4 +65,93 @@ class DHCPServerLease extends Model {
protected function get_dhcp_leases(): array {
return system_get_dhcpleases()['lease'];
}
+
+ /**
+ * Deletes a DHCP lease from the system.
+ */
+ public function _delete(): void {
+ # Check the DHCP server backend used
+ $dhcp_backend = new DHCPServerBackend();
+
+ # Delete the DHCP lease via dhcpd
+ if ($dhcp_backend->dhcpbackend->value === 'isc') {
+ # Stop the DHCP server, remove the lease, and restart the DHCP server
+ killbyname('dhcpd');
+ $this->isc_remove_lease(file_get_contents(self::ISC_LEASES_FILE), $this->ip->value);
+ }
+ # Delete the DHCP lease via kea
+ elseif ($dhcp_backend->dhcpbackend->value === 'kea') {
+ system_del_kea4lease($this->ip->value);
+ }
+ # Otherwise, the DHCP server backend is not supported
+ else {
+ throw new ServerError(
+ message: "The DHCP server backend '{$dhcp_backend->dhcpbackend->value}' is not known.",
+ response_id: 'DHCP_SERVER_LEASE_UNKNOWN_DHCP_SERVER_BACKEND',
+ );
+ }
+ }
+
+ /**
+ * Clears all existing DHCP server leases
+ */
+ public static function delete_all(): ModelSet {
+ # Read all existing leases and determine the DHCP server backend
+ $leases = self::read_all();
+ $dhcp_backend = new DHCPServerBackend();
+
+ # Clear all leases
+ if ($dhcp_backend->dhcpbackend->value === 'isc') {
+ # Stop the DHCP server, wait a couple seconds, remove the leases, and restart the DHCP server
+ killbyname('dhcpd');
+ sleep(2);
+ unlink_if_exists(self::ISC_LEASES_FILE . '*');
+ services_dhcpd_configure();
+ } elseif ($dhcp_backend->dhcpbackend->value === 'kea') {
+ system_clear_all_kea4leases();
+ } else {
+ throw new ServerError(
+ message: "The DHCP server backend '{$dhcp_backend->dhcpbackend->value}' is not known.",
+ response_id: 'DHCP_SERVER_LEASE_UNKNOWN_DHCP_SERVER_BACKEND',
+ );
+ }
+
+ return $leases;
+ }
+
+ /**
+ * Reads an existing DHCP lease file and removes the lease with the specified IP address.
+ * @param string $leases_file_contents The raw contents of the DHCP leases file.
+ * @param string $ip The IP address of the lease to remove.
+ * @return string The new contents of the DHCP leases file.
+ */
+ public static function isc_remove_lease(string $leases_file_contents, string $ip): string {
+ # Split the leases file into lines and add variable to store new contents
+ $leases_lines = explode("\n", $leases_file_contents);
+ $new_leases_contents = [];
+
+ # Iterate over each line in the leases file
+ $in_declaration = false;
+ foreach ($leases_lines as $line) {
+ # Skip the current line if we are in the middle of a lease declaration
+ if ($in_declaration) {
+ # If the line ends the lease declaration, stop skipping lines
+ if (str_starts_with('}', trim($line))) {
+ $in_declaration = false;
+ }
+ continue;
+ }
+
+ # If the line starts the lease declaration we're looking for, start skipping lines
+ if (str_starts_with("lease $ip {", trim($line))) {
+ $in_declaration = true;
+ continue;
+ }
+
+ # Otherwise, add the line to the new leases contents
+ $new_leases_contents[] = $line;
+ }
+
+ return implode("\n", $new_leases_contents);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverride.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverride.inc
index d3604a3d..a60e1e8f 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverride.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverride.inc
@@ -25,8 +25,8 @@ class DNSResolverHostOverride extends Model {
$this->config_path = 'unbound/hosts';
$this->subsystem = 'unbound';
$this->many = true;
- $this->sort_option = SORT_ASC;
- $this->sort_by_field = 'host';
+ $this->sort_order = SORT_ASC;
+ $this->sort_by = ['host'];
$this->unique_together_fields = ['host', 'domain'];
# Set model Fields
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverrideAlias.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverrideAlias.inc
index a448354a..e5555594 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverrideAlias.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DNSResolverHostOverrideAlias.inc
@@ -22,8 +22,8 @@ class DNSResolverHostOverrideAlias extends Model {
$this->config_path = 'aliases/item';
$this->subsystem = 'unbound';
$this->many = true;
- $this->sort_option = SORT_ASC;
- $this->sort_by_field = 'host';
+ $this->sort_order = SORT_ASC;
+ $this->sort_by = ['host'];
$this->unique_together_fields = ['host', 'domain'];
# Set model Fields
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallSchedule.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallSchedule.inc
index 7acec005..07996055 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallSchedule.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallSchedule.inc
@@ -27,8 +27,8 @@ class FirewallSchedule extends Model {
$this->config_path = 'schedules/schedule';
$this->many = true;
$this->always_apply = true;
- $this->sort_by_field = 'name';
- $this->sort_option = SORT_ASC;
+ $this->sort_by = ['name'];
+ $this->sort_order = SORT_ASC;
# Set model fields
$this->schedlabel = new UIDField(help_text: 'A unique ID for this schedule used internally by the system.');
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc
new file mode 100644
index 00000000..1c08f146
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/GraphQL.inc
@@ -0,0 +1,86 @@
+internal_callable = 'get_internal';
+ $this->verbose_name = 'GraphQL';
+ $this->many = false;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->query = new StringField(
+ default: '',
+ allow_empty: true,
+ maximum_length: 9999999999,
+ help_text: 'The GraphQL query/mutation to execute.',
+ );
+ $this->variables = new ObjectField(
+ default: [],
+ allow_empty: true,
+ help_text: 'The variables to pass to the GraphQL query or mutation. In general, this will be an object ' .
+ 'containing the variables to pass to the query or mutation.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Pseudo-method that acts as an internal callable. Since this Model has no internal values, this method always
+ * returns an empty array.
+ * @return array An empty array.
+ */
+ public function get_internal(): array {
+ return [];
+ }
+
+ /**
+ * Executes the GraphQL query or mutation against the GraphQL schema.
+ */
+ public function _create(): void {
+ # Obtain the GraphQL schema
+ $schema = new GraphQLSchema();
+
+ # Execute the GraphQL query or mutation
+ $this->result = \GraphQL\GraphQL::executeQuery(
+ schema: $schema->get_schema(),
+ source: $this->query->value,
+ contextValue: ['auth' => $this->client],
+ variableValues: $this->variables->value,
+ );
+ }
+
+ /**
+ * Prevents the 'result' property from being included in serialization. This property is not serializable and will
+ * cause an error when setting the Model::$initial_data property.
+ */
+ public function __sleep(): array {
+ # Get the properties of this object
+ $properties = get_object_vars($this);
+
+ # Remove the 'result' property
+ unset($properties['result']);
+
+ # Return the remaining properties to serialize
+ return array_keys($properties);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyBackend.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyBackend.inc
index 1e311782..cbfda047 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyBackend.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HAProxyBackend.inc
@@ -386,7 +386,7 @@ class HAProxyBackend extends Model {
$this->stats_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
+ sensitive: true,
conditions: ['stats_enabled' => true],
help_text: 'The stats page password.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecChildSAStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecChildSAStatus.inc
new file mode 100644
index 00000000..647ff397
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecChildSAStatus.inc
@@ -0,0 +1,184 @@
+internal_callable = 'get_ipsec_child SA_statuses';
+ $this->parent_model_class = 'IPsecSAStatus';
+ $this->verbose_name = 'IPsec Child SA Status';
+ $this->many = true;
+
+ # Set model fields
+ $this->name = new StringField(
+ default: '',
+ read_only: true,
+ help_text: 'The name of the connection ID for the child SA.',
+ );
+ $this->uniqueid = new IntegerField(default: 0, read_only: true, help_text: 'The unique ID of the child SA.');
+ $this->reqid = new IntegerField(default: 0, read_only: true, help_text: 'The request ID of the child SA.');
+ $this->state = new StringField(default: '', read_only: true, help_text: 'The current state of the child SA.');
+ $this->mode = new StringField(default: '', read_only: true, help_text: 'The mode of the child SA.');
+ $this->protocol = new StringField(default: '', read_only: true, help_text: 'The protocol of the child SA.');
+ $this->encap = new BooleanField(
+ default: false,
+ read_only: true,
+ indicates_true: 'yes',
+ help_text: 'Whether encapsulation is used for the child SA.',
+ );
+ $this->spi_in = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'spi-in',
+ help_text: 'The incoming SPI of the child SA.',
+ );
+ $this->spi_out = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'spi-out',
+ help_text: 'The outgoing SPI of the child SA.',
+ );
+ $this->encr_alg = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'encr-alg',
+ help_text: 'The encryption algorithm used by the child SA.',
+ );
+ $this->encr_keysize = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'encr-keysize',
+ help_text: 'The encryption key size used by the child SA.',
+ );
+ $this->integ_alg = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'integ-alg',
+ help_text: 'The integrity algorithm used by the child SA.',
+ );
+ $this->dh_group = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'dh-group',
+ help_text: 'The Diffie-Hellman group used by the child SA.',
+ );
+ $this->bytes_in = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'bytes-in',
+ help_text: 'The number of bytes received by the child SA.',
+ );
+ $this->bytes_out = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'bytes-out',
+ help_text: 'The number of bytes sent by the child SA.',
+ );
+ $this->packets_in = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'packets-in',
+ help_text: 'The number of packets received by the child SA.',
+ );
+ $this->packets_out = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'packets-out',
+ help_text: 'The number of packets sent by the child SA.',
+ );
+ $this->use_in = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'use-in',
+ help_text: 'The number of times the child SA has been used to receive data.',
+ );
+ $this->use_out = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'use-out',
+ help_text: 'The number of times the child SA has been used to send data.',
+ );
+ $this->rekey_time = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'rekey-time',
+ help_text: 'The time at which the child SA will be rekeyed.',
+ );
+ $this->life_time = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'life-time',
+ help_text: 'The time at which the child SA will expire.',
+ );
+ $this->install_time = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'install-time',
+ help_text: 'The time at which the child SA was installed.',
+ );
+ $this->local_ts = new StringField(
+ default: '',
+ read_only: true,
+ many: true,
+ delimiter: null,
+ internal_name: 'local-ts',
+ help_text: 'The local traffic selector of the child SA.',
+ );
+ $this->remote_ts = new StringField(
+ default: '',
+ read_only: true,
+ many: true,
+ delimiter: null,
+ internal_name: 'remote-ts',
+ help_text: 'The remote traffic selector of the child SA.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Obtains the status of IPsec child SAs. This is the internal callable that is used to obtain the internal data for
+ * this model.
+ * @return array The status of IPsec child SAs where each item is an associative array containing the status of an
+ * IPsec child SA on the system.
+ */
+ public function get_ipsec_sa_statuses(): array {
+ return IPsecSAStatus::get_ipsec_sa_statuses()[$this->parent_id]['child-sas'];
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase1.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase1.inc
index 025b09a3..25b609bc 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase1.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecPhase1.inc
@@ -132,7 +132,7 @@ class IPsecPhase1 extends Model {
);
$this->pre_shared_key = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
internal_name: 'pre-shared-key',
conditions: ['authentication_method' => 'pre_shared_key'],
help_text: 'The Pre-Shared Key (PSK) value. This key must match on both peers and should be long and ' .
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecSAStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecSAStatus.inc
new file mode 100644
index 00000000..da4c5ec3
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/IPsecSAStatus.inc
@@ -0,0 +1,203 @@
+internal_callable = 'get_ipsec_sa_statuses';
+ $this->verbose_name = 'IPsec SA Status';
+ $this->many = true;
+
+ # Set model fields
+ $this->con_id = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'con-id',
+ help_text: 'The connection ID of the tunnel.',
+ );
+ $this->uniqueid = new IntegerField(default: 0, read_only: true, help_text: 'The unique ID of the tunnel.');
+ $this->version = new IntegerField(
+ default: 0,
+ read_only: true,
+ help_text: 'The IKE version used by the tunnel.',
+ );
+ $this->state = new StringField(default: '', read_only: true, help_text: 'The current state of the tunnel.');
+ $this->local_host = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'local-host',
+ help_text: 'The local host for the tunnel.',
+ );
+ $this->local_port = new PortField(
+ default: '',
+ read_only: true,
+ internal_name: 'local-port',
+ help_text: 'The local port for the tunnel.',
+ );
+ $this->local_id = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'local-id',
+ help_text: 'The local ID for the tunnel.',
+ );
+ $this->remote_host = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'remote-host',
+ help_text: 'The remote host for the tunnel.',
+ );
+ $this->remote_port = new PortField(
+ default: '',
+ read_only: true,
+ internal_name: 'remote-port',
+ help_text: 'The remote port for the tunnel.',
+ );
+ $this->remote_id = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'remote-id',
+ help_text: 'The remote ID for the tunnel.',
+ );
+ $this->initiator_spi = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'initiator-spi',
+ help_text: 'The initiator SPI for the tunnel.',
+ );
+ $this->responder_spi = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'responder-spi',
+ help_text: 'The responder SPI for the tunnel.',
+ );
+ $this->nat_remote = new BooleanField(
+ default: false,
+ read_only: true,
+ indicates_true: 'yes',
+ internal_name: 'nat-remote',
+ help_text: 'Whether the remote host is behind NAT.',
+ );
+ $this->nat_any = new BooleanField(
+ default: false,
+ read_only: true,
+ indicates_true: 'yes',
+ internal_name: 'nat-any',
+ help_text: 'Whether the remote host is behind any NAT.',
+ );
+ $this->encr_alg = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'encr-alg',
+ help_text: 'The encryption algorithm used by the tunnel.',
+ );
+ $this->encr_keysize = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'encr-keysize',
+ help_text: 'The encryption key size used by the tunnel.',
+ );
+ $this->integ_alg = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'integ-alg',
+ help_text: 'The integrity algorithm used by the tunnel.',
+ );
+ $this->prf_alg = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'prf-alg',
+ help_text: 'The pseudo-random function algorithm used by the tunnel.',
+ );
+ $this->dh_group = new StringField(
+ default: '',
+ read_only: true,
+ internal_name: 'dh-group',
+ help_text: 'The Diffie-Hellman group used by the tunnel.',
+ );
+ $this->established = new IntegerField(
+ default: 0,
+ read_only: true,
+ help_text: 'The amount of time the tunnel has been established.',
+ );
+ $this->rekey_time = new IntegerField(
+ default: 0,
+ read_only: true,
+ internal_name: 'rekey-time',
+ help_text: 'The amount of time until the tunnel rekeys.',
+ );
+ $this->child_sas = new NestedModelField(
+ model_class: 'IPsecChildSAStatus',
+ read_only: true,
+ internal_name: 'child-sas',
+ help_text: 'The child SAs of the tunnel.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Obtains the status of IPsec SAs. This is the internal callable that is used to obtain the internal data for
+ * this model.
+ * @return array The status of IPsec tunnels where each item is an associative array containing the status of an
+ * IPsec tunnel on the system.
+ */
+ public static function get_ipsec_sa_statuses(): array {
+ # Variables
+ $ipsec_statuses = [];
+ $ipsec_statuses_raw = ipsec_list_sa();
+
+ # Loop through each tunnel status and reform the data
+ foreach ($ipsec_statuses_raw as $id => $ipsec_status) {
+ # Variables
+ $ipsec_statuses[] = [];
+
+ # Loop through each status item
+ foreach ($ipsec_status as $key => $value) {
+ # For the 'child-sas' key, we need to convert the associative array to a list of associative arrays
+ if ($key === 'child-sas') {
+ $ipsec_statuses[$id][$key] = array_values($value);
+ continue;
+ }
+
+ # Otherwise just set the value
+ $ipsec_statuses[$id][$key] = $value;
+ }
+ }
+
+ return $ipsec_statuses;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/LogSettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/LogSettings.inc
new file mode 100644
index 00000000..19cdb7fb
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/LogSettings.inc
@@ -0,0 +1,272 @@
+config_path = 'syslog';
+ $this->many = false;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->format = new StringField(
+ default: 'rfc3164',
+ choices: ['rfc3164', 'rfc5424'],
+ help_text: 'The format of the log entries.',
+ );
+ $this->reverseorder = new BooleanField(
+ default: false,
+ internal_name: 'reverse', # We can't use 'reverse' as the field name because it is a reserved field name
+ help_text: 'Reverse the order of log entries.',
+ );
+ $this->nentries = new IntegerField(
+ default: 500,
+ minimum: 5,
+ maximum: 20000,
+ help_text: 'The number of log entries to display in the UI.',
+ );
+ $this->nologdefaultblock = new BooleanField(
+ default: false,
+ help_text: 'Do not log packets that are blocked by the implicit default block rule.',
+ );
+ $this->nologdefaultpass = new BooleanField(
+ default: false,
+ help_text: 'Do not log packets that are allowed by the implicit default pass rule.',
+ );
+ $this->nologbogons = new BooleanField(
+ default: false,
+ help_text: 'Log packets blocked by Block Bogon Networks rules.',
+ );
+ $this->nologprivatenets = new BooleanField(
+ default: false,
+ help_text: 'Log packets blocked by Block Private Networks rules.',
+ );
+ $this->nolognginx = new BooleanField(
+ default: false,
+ help_text: 'Do not Log errors from the web server process.',
+ );
+ $this->rawfilter = new BooleanField(
+ default: false,
+ help_text: 'Display logs in the UI as they are generated by the packet filter, without any formatting.',
+ );
+ $this->disablelocallogging = new BooleanField(
+ default: false,
+ help_text: 'Disable writing log entries to the local disk. WARNING: This will also disable Login Protection!',
+ );
+ $this->logconfigchanges = new BooleanField(
+ default: false,
+ help_text: 'Log changes made to the pfSense configuration.',
+ );
+ $this->filterdescriptions = new IntegerField(
+ default: 0,
+ choices: [0, 1, 2],
+ help_text: 'Display filter descriptions in the log entries Use `0` to not load descriptions, `1` to ' .
+ 'display descriptions in their own column, or `2` to display the description in a second row.',
+ );
+ $this->logfilesize = new IntegerField(
+ default: 512000,
+ minimum: 100000,
+ maximum: 2147483648,
+ help_text: 'The maximum size of the log file in kilobytes.',
+ );
+ $this->rotatecount = new IntegerField(
+ default: 5,
+ minimum: 0,
+ maximum: 99,
+ help_text: 'The number of log file rotations to keep.',
+ );
+ $this->logcompressiontype = new StringField(
+ default: 'bzip2',
+ choices: ['bzip2', 'gzip', 'xz', 'zstd', 'none'],
+ help_text: 'The type of compression to use for log files.',
+ );
+ $this->enableremotelogging = new BooleanField(
+ default: false,
+ internal_name: 'enable',
+ help_text: 'Enable remote logging.',
+ );
+ $this->ipprotocol = new StringField(
+ default: 'ipv4',
+ choices: ['ipv4', 'ipv6'],
+ conditions: ['enableremotelogging' => true],
+ help_text: 'The IP protocol to use for remote logging.',
+ );
+ $this->sourceip = new InterfaceField(
+ default: '',
+ allow_localhost_interface: true,
+ allow_custom: [''],
+ allow_empty: true,
+ conditions: ['enableremotelogging' => true],
+ help_text: 'The interface to use as the source IP address for remote logging.',
+ );
+ $this->remoteserver = new StringField(
+ default: null,
+ allow_null: true,
+ conditions: ['enableremotelogging' => true],
+ validators: [new IPAddressValidator(allow_fqdn: true, allow_port: true)],
+ help_text: 'The first remote syslog server to send log entries to.',
+ );
+ $this->remoteserver2 = new StringField(
+ default: null,
+ allow_null: true,
+ conditions: ['enableremotelogging' => true, '!remoteserver' => null],
+ validators: [new IPAddressValidator(allow_fqdn: true, allow_port: true)],
+ help_text: 'The second remote syslog server to send log entries to.',
+ );
+ $this->remoteserver3 = new StringField(
+ default: null,
+ allow_null: true,
+ conditions: ['enableremotelogging' => true, '!remoteserver' => null, '!remoteserver2' => null],
+ validators: [new IPAddressValidator(allow_fqdn: true, allow_port: true)],
+ help_text: 'The third remote syslog server to send log entries to.',
+ );
+ $this->logall = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true],
+ help_text: 'Log everything to the remote syslog server(s).',
+ );
+ $this->filter = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log filter events to the remote syslog server(s).',
+ );
+ $this->dhcp = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log DHCP events to the remote syslog server(s).',
+ );
+ $this->auth = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log authentication events to the remote syslog server(s).',
+ );
+ $this->portalauth = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log captive portal authentication events to the remote syslog server(s).',
+ );
+ $this->vpn = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log VPN events to the remote syslog server(s).',
+ );
+ $this->dpinger = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log gateway monitoring events to the remote syslog server(s).',
+ );
+ $this->hostapd = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log wireless authentication events to the remote syslog server(s).',
+ );
+ $this->system = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log system events to the remote syslog server(s).',
+ );
+ $this->resolver = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log DNS resolver events to the remote syslog server(s).',
+ );
+ $this->ppp = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log PPP events to the remote syslog server(s).',
+ );
+ $this->routing = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log routing events to the remote syslog server(s).',
+ );
+ $this->ntpd = new BooleanField(
+ default: false,
+ conditions: ['enableremotelogging' => true, '!logall' => true],
+ help_text: 'Log NTP events to the remote syslog server(s).',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Applies changes to the log settings.
+ */
+ public function apply(): void {
+ # Remove old log rotations if the compression type has changed
+ if ($this->logcompressiontype->value !== $this->initial_object->logcompressiontype->value) {
+ foreach (system_syslogd_get_all_logfilenames() as $log_file) {
+ unlink_if_exists("/var/log/$log_file.log.*");
+ }
+ }
+
+ # Restart syslogd
+ system_syslogd_start();
+
+ # Restart the webConfigurator if nginx logging settings were changed
+ if ($this->nolognginx->value !== $this->initial_object->nolognginx->value) {
+ # Always do this asynchronously to prevent killing the current request
+ (new WebGUIRestartDispatcher(async: true))->spawn_process();
+ }
+
+ # Reload the firewall filter filter logging changes were made
+ if ($this->has_filter_log_changed()) {
+ (new FirewallApplyDispatcher(async: $this->async))->spawn_process();
+ }
+ }
+
+ /**
+ * Checks if log settings related to the filter were changed
+ */
+ private function has_filter_log_changed(): bool {
+ return $this->nologdefaultblock->value !== $this->initial_object->nologdefaultblock->value or
+ $this->nologdefaultpass->value !== $this->initial_object->nologdefaultpass->value or
+ $this->nologbogons->value !== $this->initial_object->nologbogons->value or
+ $this->nologprivatenets->value !== $this->initial_object->nologprivatenets->value;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NTPSettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NTPSettings.inc
index 364d1185..344bb944 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NTPSettings.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NTPSettings.inc
@@ -130,7 +130,7 @@ class NTPSettings extends Model {
);
$this->serverauthkey = new Base64Field(
required: true,
- write_only: true,
+ sensitive: true,
conditions: ['serverauth' => true],
validators: [new LengthValidator(minimum: 1, maximum: 64)],
help_text: 'The NTP server authentication key.',
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NetworkInterface.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NetworkInterface.inc
index 9835065f..664fedb3 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NetworkInterface.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/NetworkInterface.inc
@@ -72,6 +72,7 @@ class NetworkInterface extends Model {
$this->many = true;
$this->subsystem = 'interfaces';
$this->update_strategy = 'replace';
+ $this->id_type = 'string';
# Model Fields
$this->if = new StringField(
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClient.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClient.inc
index 2ea00665..d5fe148b 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClient.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClient.inc
@@ -160,7 +160,7 @@ class OpenVPNClient extends Model {
);
$this->proxy_passwd = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
conditions: ['!proxy_authtype' => 'none'],
help_text: 'The username to use for authentication to the remote proxy.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc
index c861f7e5..d6618336 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNServer.inc
@@ -34,6 +34,7 @@ class OpenVPNServer extends Model {
public StringField $protocol;
public InterfaceField $interface;
public PortField $local_port;
+ public BooleanField $use_tls;
public Base64Field $tls;
public StringField $tls_type;
public StringField $tlsauth_keydir;
@@ -169,16 +170,24 @@ class OpenVPNServer extends Model {
allow_range: false,
help_text: 'The port used by OpenVPN to receive client connections.',
);
+ $this->use_tls = new BooleanField(
+ default: false,
+ representation_only: true,
+ indicates_true: true,
+ indicates_false: false,
+ help_text: 'Enables or disables the use of a TLS key for this OpenVPN server.',
+ );
$this->tls = new Base64Field(
- default: null,
- allow_null: true,
+ default_callable: 'generate_tls_key',
+ sensitive: true,
+ conditions: ['use_tls' => true],
help_text: 'The TLS key this OpenVPN server will use to sign control channel packets with an HMAC ' .
'signature for authentication when establishing the tunnel.',
);
$this->tls_type = new StringField(
required: true,
choices: ['auth', 'crypt'],
- conditions: ['!tls' => null],
+ conditions: ['use_tls' => true],
help_text: 'The TLS key usage type. In `auth` mode, the TLS key is used only as HMAC authentication for ' .
'the control channel, protecting the peers from unauthorized connections. The `crypt` mode encrypts ' .
'the control channel communication in addition to providing authentication, providing more privacy ' .
@@ -187,7 +196,7 @@ class OpenVPNServer extends Model {
$this->tlsauth_keydir = new StringField(
default: 'default',
choices: ['default', '0', '1', '2'],
- conditions: ['!tls' => null],
+ conditions: ['use_tls' => true],
help_text: 'The TLS key direction. This must be set to complementary values on the client and server. ' .
'For example, if the server is set to 0, the client must be set to 1. Both may be set to omit the ' .
'direction, in which case the TLS Key will be used bidirectionally.',
@@ -648,6 +657,23 @@ class OpenVPNServer extends Model {
return self::INTERFACE_PREFIX . $this->vpnid->value;
}
+ /**
+ * Populates the internal value of the `use_tls` field since this is not a stored value.
+ * @return bool The `use_tls` value's internal value
+ */
+ public function from_internal_use_tls(): bool {
+ # We know TLS is enabled if we have a TLS key stored
+ return (bool) $this->tls->value;
+ }
+
+ /**
+ * Generates a new OpenVPN TLS key.
+ * @return string The generated TLS key
+ */
+ public static function generate_tls_key(): string {
+ return openvpn_create_key();
+ }
+
/**
* Adds extra validation to the `disable` field.
* @param bool $disable The incoming value to validate.
@@ -707,7 +733,7 @@ class OpenVPNServer extends Model {
*/
public function validate_tls(string $tls): string {
# Ensure this TLS key begins with the OpenVPN key prefix and suffix
- if (!str_contains($tls, self::STATIC_KEY_PREFIX) and !str_contains($tls, self::STATIC_KEY_SUFFIX)) {
+ if ($tls and !str_contains($tls, self::STATIC_KEY_PREFIX) and !str_contains($tls, self::STATIC_KEY_SUFFIX)) {
throw new ValidationError(
message: 'Field `tls` must be a valid OpenVPN TLS key.',
response_id: 'OPENVPN_SERVER_TLS_INVALID_KEY',
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc
index b5a551f6..bb49d4d2 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/PortForward.inc
@@ -45,8 +45,8 @@ class PortForward extends Model {
$this->config_path = 'nat/rule';
$this->many = true;
$this->subsystem = 'natconf';
- $this->sort_by_field = 'interface';
- $this->sort_option = SORT_ASC;
+ $this->sort_by = ['interface'];
+ $this->sort_order = SORT_ASC;
$this->client = $options['client'] instanceof Auth ? $options['client'] : new Auth();
# Set model Fields
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc
index a14dd779..35b4f39d 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIAccessListEntry.inc
@@ -28,8 +28,8 @@ class RESTAPIAccessListEntry extends Model {
$this->many = true;
$this->many_minimum = 1;
$this->verbose_name_plural = 'REST API Access List Entries';
- $this->sort_option = SORT_ASC;
- $this->sort_by_field = 'weight';
+ $this->sort_order = SORT_ASC;
+ $this->sort_by = ['weight'];
# Define model fields
$this->type = new StringField(
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIKey.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIKey.inc
index faff3473..2e29d908 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIKey.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPIKey.inc
@@ -68,7 +68,7 @@ class RESTAPIKey extends Model {
);
$this->hash = new StringField(
allow_null: true,
- write_only: true, # Don't allow the hash to be read via API
+ sensitive: true, # Don't allow the hash to be read via API
help_text: 'The hash of the generated API key',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc
index 85abff8e..770905d3 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc
@@ -33,6 +33,7 @@ class RESTAPISettings extends Model {
public BooleanField $log_successful_auth;
public BooleanField $allow_pre_releases;
public BooleanField $hateoas;
+ public BooleanField $expose_sensitive_fields;
public InterfaceField $allowed_interfaces;
public StringField $represent_interfaces_as;
public StringField $auth_methods;
@@ -110,6 +111,14 @@ class RESTAPISettings extends Model {
'client scripts that integrate with HAL standards. Enabling HATEOAS may increase API response times, ' .
'especially on systems with large configurations.',
);
+ $this->expose_sensitive_fields = new BooleanField(
+ default: false,
+ indicates_true: 'enabled',
+ indicates_false: 'disabled',
+ verbose_name: 'expose sensitive fields',
+ help_text: 'Enables or disables exposing sensitive fields in API responses. When enabled, sensitive fields ' .
+ 'such as passwords, private keys, and other sensitive data will be included in API responses.',
+ );
$this->allowed_interfaces = new InterfaceField(
default: [],
allow_localhost_interface: true,
@@ -180,7 +189,6 @@ class RESTAPISettings extends Model {
$this->ha_sync_password = new StringField(
default: '',
allow_empty: true,
- write_only: true,
sensitive: true,
verbose_name: 'HA sync password',
help_text: "Sets the password to use when authenticating for HA sync processes. This must be the password
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc
index c5332978..e74ea3d8 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc
@@ -26,6 +26,7 @@ class RESTAPISettingsSync extends Model {
$this->sync_data = new StringField(
required: true,
representation_only: true,
+ maximum_length: 65535,
help_text: 'The serialized REST API settings data to be synced.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/User.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/User.inc
index f32d8cca..8bca0919 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/User.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/User.inc
@@ -48,7 +48,7 @@ class User extends Model {
);
$this->password = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
internal_name: $this->get_config('system/webgui/pwhash', 'bcrypt') . '-hash',
help_text: 'The password of this local user.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc
index ab00db19..a2e867b1 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/UserGroup.inc
@@ -28,8 +28,8 @@ class UserGroup extends Model {
$this->config_path = 'system/group';
$this->many = true;
$this->always_apply = true;
- $this->sort_option = SORT_ASC;
- $this->sort_by_field = 'name';
+ $this->sort_order = SORT_ASC;
+ $this->sort_by = ['name'];
$this->protected_model_query = ['scope' => 'system'];
# Set model Fields
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc
index cde51de4..a4e402de 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc
@@ -109,7 +109,7 @@ class VirtualIP extends Model {
);
$this->password = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
conditions: ['mode' => 'carp'],
help_text: 'The VHID group password shared by all CARP members.',
);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeer.inc
index 68940a1e..53308381 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeer.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeer.inc
@@ -94,7 +94,7 @@ class WireGuardPeer extends Model {
*/
public function validate_presharedkey(string $presharedkey): string {
# Throw an error if this value is not a valid WireGuard key
- if (!wg_is_valid_key($presharedkey)) {
+ if ($presharedkey and !wg_is_valid_key($presharedkey)) {
throw new ValidationError(
message: 'Field `presharedkey` must be a valid WireGuard pre-shared key.',
response_id: 'WIREGUARD_PEER_PRESHAREDKEY_INVALID',
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnel.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnel.inc
index 05d7bdf5..721d6c95 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnel.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnel.inc
@@ -66,7 +66,7 @@ class WireGuardTunnel extends Model {
);
$this->privatekey = new StringField(
required: true,
- write_only: true,
+ sensitive: true,
help_text: 'The private key for this tunnel.',
);
$this->mtu = new IntegerField(
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/GraphQLResponse.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/GraphQLResponse.inc
new file mode 100644
index 00000000..c5350c85
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Responses/GraphQLResponse.inc
@@ -0,0 +1,157 @@
+execution_result = $data->result ?? new ExecutionResult();
+ parent::__construct($message, $response_id, $data, $_links);
+ }
+
+ /**
+ * Converts this GraphQLResponse object to an array representation of its contents.
+ * @return array An array containing the response details.
+ */
+ public function to_representation(): array {
+ return $this->data->result->toArray();
+ }
+
+ /**
+ * Converts a standard Response object into a GraphQLResponse object. This is useful for converting standard REST API
+ * responses into responses that better adhere to the GraphQL specification.
+ * @param Response $response The Response object to convert.
+ * @return GraphQLResponse The converted GraphQLResponse object.
+ */
+ public static function to_graphql_response(Response $response): GraphQLResponse {
+ # Variables
+ $is_success = false;
+
+ # Check if the Response object is a successful response.
+ if ($response->code === 200) {
+ $is_success = true;
+ }
+
+ # Create a new GraphQL Model object to store the ExecutionResult object.
+ $graphql_model = new GraphQL();
+
+ # Create an ExecutionResult object with the error details from the Response object.
+ $graphql_model->result = new ExecutionResult(
+ data: $is_success ? $response->data : null,
+ errors: $is_success
+ ? null
+ : [new Error(message: $response->message, extensions: ['response_id' => $response->response_id])],
+ );
+
+ return new GraphQLResponse(data: $graphql_model);
+ }
+
+ /**
+ * Obtains the OpenAPI schema for this GraphQLResponse object.
+ */
+ public function to_openapi_schema(): array {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'data' => ['description' => 'The GraphQL response data.', 'type' => 'object'],
+ 'errors' => [
+ 'description' => 'The GraphQL response errors.',
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'message' => [
+ 'description' => 'The error message.',
+ 'type' => 'string',
+ ],
+ 'extensions' => [
+ 'description' => 'The error extensions.',
+ 'type' => 'object',
+ 'properties' => [
+ 'response_id' => [
+ 'description' => 'The error response ID.',
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ 'locations' => [
+ 'description' => 'The error locations.',
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'line' => [
+ 'description' => 'The error line.',
+ 'type' => 'integer',
+ ],
+ 'column' => [
+ 'description' => 'The error column.',
+ 'type' => 'integer',
+ ],
+ ],
+ ],
+ ],
+ 'path' => [
+ 'description' => 'The error path.',
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc
new file mode 100644
index 00000000..dcdc35d4
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/GraphQLSchema.inc
@@ -0,0 +1,784 @@
+query_params_type = new QueryParamsScalarType();
+
+ parent::__construct();
+ }
+
+ /**
+ * Obtains the top-level GraphQL Schema object.
+ * @returns \GraphQL\Type\Schema The GraphQL Schema object for this API.
+ */
+ public function get_schema(): \GraphQL\Type\Schema {
+ return new \GraphQL\Type\Schema([
+ 'query' => new ObjectType([
+ 'name' => 'Query',
+ 'fields' => $this->get_endpoint_query_types(),
+ ]),
+ 'mutation' => new ObjectType([
+ 'name' => 'Mutation',
+ 'fields' => $this->get_endpoint_mutation_types(),
+ ]),
+ 'types' => [$this->query_params_type],
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get_schema_str(): string {
+ return SchemaPrinter::doPrint($this->get_schema());
+ }
+
+ /**
+ * Converts a Field object into a GraphQL schema field Type object.
+ * @link https://webonyx.github.io/graphql-php/type-definitions/
+ * @param Field $field The Field object to convert to a GraphQL schema field Type object.
+ * @param Type|null $type A nested GraphQL schema field Type object that represents this Field's Type.
+ * @param bool $ignore_required Whether this Field should be non-nullable or not.
+ * @return Type The GraphQL schema field Type object that represents this Field.
+ * @throws ServerError When this Field object has an unsupported type for GraphQL.
+ */
+ public function field_to_type(Field $field, ?Type $type = null, bool $ignore_required = false): Type {
+ # Obtain the GraphQL type that corresponds to this Field's type if one was not provided
+ $type =
+ $type ?:
+ match ($field->type) {
+ 'boolean' => Type::boolean(),
+ 'integer' => Type::int(),
+ 'double' => Type::float(),
+ 'string' => Type::string(),
+ 'array' => $this->query_params_type,
+ default => throw new ServerError(
+ message: "Field '$field->name' has an unsupported type '$field->type' for GraphQL.",
+ response_id: 'FIELD_UNSUPPORTED_TYPE_FOR_GRAPHQL',
+ ),
+ };
+
+ # Use an EnumType for Fields with choices
+ if ($field->choices) {
+ $type = $this->field_to_enum($field);
+ }
+
+ # Designate this field as a list if `many` is enabled
+ if ($field->many) {
+ $type = Type::listOf($type);
+ }
+
+ # Make this field non-nullable if it is required
+ if ($field->required and !$ignore_required) {
+ $type = Type::nonNull($type);
+ }
+
+ # Add a description to this field
+ $type->description = $field->help_text;
+
+ return $type;
+ }
+
+ /**
+ * Converts a Field object into a GraphQL EnumType object. This turns a Field objects 'choices' into an EnumType.
+ * @link https://webonyx.github.io/graphql-php/type-definitions/enums/
+ * @param Field $field The Field object to convert to a GraphQL EnumType.
+ * @return EnumType The GraphQL EnumType object that represents this Field.
+ * @throws ServerError When this Field object does not have choices defined.
+ */
+ public function field_to_enum(Field $field): EnumType {
+ # Variables
+ $enum_name = $field->context->get_class_shortname() . to_upper_camel_case($field->name) . 'Enum';
+
+ # If the Field has an EnumType already, return it
+ if (array_key_exists($enum_name, $this->field_enum_types)) {
+ # This value must be passed by reference to avoid duplicate/non-unique EnumTypes in the schema
+ $enum_type = &$this->field_enum_types[$enum_name];
+ return $enum_type;
+ }
+
+ # Define the enum values for this Field
+ $values = [];
+ foreach ($field->choices as $choice) {
+ # Convert the choice to a string for EnumType
+ $enum_key = strtoupper(strval($choice));
+
+ # For empty values, convert to string for EnumType
+ if ($choice === '') {
+ $enum_key = 'EMPTY';
+ }
+
+ # Remove all special characters from the Enum key
+ $enum_key = preg_replace('/[^A-Za-z0-9]/', '', $enum_key);
+
+ # Prefix 'VAL_' to the Enum key to avoid any potential conflicts
+ $enum_key = 'VAL_' . $enum_key;
+ $values[$enum_key] = ['value' => $choice];
+ }
+
+ # Create a new EnumType object for this Field
+ $this->field_enum_types[$enum_name] = new EnumType([
+ 'name' => $enum_name,
+ 'description' => $field->help_text,
+ 'values' => $values,
+ ]);
+
+ # This value must be passed by reference to avoid duplicate/non-unique EnumTypes in the schema
+ $enum_type = &$this->field_enum_types[$enum_name];
+ return $enum_type;
+ }
+
+ /**
+ * Converts a given Model object to a GraphQL ObjectType or InputObjectType.
+ * @param Model $model The Model object to convert to a GraphQL ObjectType or InputObjectType.
+ * @param bool $input Whether this ObjectType should be an InputObjectType or not.
+ * @return ObjectType|InputObjectType A GraphQL ObjectType definition for the given Model object.
+ *@link https://webonyx.github.io/graphql-php/type-definitions/object-types/
+ */
+ public function model_to_object_type(Model $model, bool $input = false): ObjectType|InputObjectType {
+ # Variables
+ $fields = [];
+ $model_sn = $model->get_class_shortname();
+ $model_vn = $model->verbose_name;
+
+ # If an ID is required, add it to the GraphQL schema
+ if ($model->many) {
+ $fields['id'] = $model->id_type === 'integer' ? Type::int() : Type::string();
+ $fields['id'] = $input ? $fields['id'] : Type::nonNull($fields['id']);
+ }
+
+ # If a parent ID is required, add it to the GraphQL schema
+ if ($model->parent_model_class) {
+ $fields['parent_id'] =
+ $model->parent_id_type === 'integer' ? Type::nonNull(Type::int()) : Type::nonNull(Type::string());
+ }
+
+ # Loop through each Field in this Model and obtain its GraphQL field definition
+ foreach ($model->get_fields() as $field) {
+ # For NestedModelFields, ensure we pass the Type for the nested Model
+ if ($model->$field instanceof NestedModelField) {
+ $nested_model_type = $this->get_model_object_type($model->$field->model_class);
+ $fields[$field]['type'] = $this->field_to_type(field: $model->$field, type: $nested_model_type);
+ $fields[$field]['description'] = $model->$field->help_text;
+ continue;
+ }
+
+ # Generate and add the GraphQL field from the Field object
+ $fields[$field]['type'] = $this->field_to_type(field: $model->$field);
+ $fields[$field]['description'] = $model->$field->help_text;
+ }
+
+ return $input
+ ? new InputObjectType(['name' => $model_sn . 'Input', 'description' => $model_vn, 'fields' => $fields])
+ : new ObjectType(['name' => $model_sn, 'description' => $model_vn, 'fields' => $fields]);
+ }
+
+ /**
+ * Obtains GraphQL ObjectType objects for all Models.
+ * @returns array An array containing all the GraphQL ObjectType objects for Models in this API where the array key
+ * is the Model's FQN and the value is the GraphQL ObjectType object. Note: This value can also be accessed via the
+ * $model_object_types property.
+ */
+ public function get_model_object_types(): array {
+ # Variables
+ $model_classes = get_classes_from_namespace('RESTAPI\Models');
+
+ # Reset any existing Types
+ $this->model_object_types = [];
+
+ # Loop through each Model and create a GraphQL Type object for each one.
+ foreach ($model_classes as $model_class) {
+ $this->model_object_types[$model_class] = $this->get_model_object_type($model_class);
+ }
+
+ return $this->model_object_types;
+ }
+
+ /**
+ * Obtains GraphQL InputObjectType objects for all Models.
+ * @returns array An array containing all the GraphQL InputObjectType objects for Models in this API where the array key
+ * is the Model's FQN and the value is the GraphQL InputObjectType object. Note: This value can also be accessed via the
+ * $model_input_object_types property.
+ */
+ public function get_model_input_object_types(): array {
+ # Variables
+ $model_classes = get_classes_from_namespace('RESTAPI\Models');
+
+ # Reset any existing Types
+ $this->model_input_object_types = [];
+
+ # Loop through each Model and create a GraphQL InputObjectType object for each one.
+ foreach ($model_classes as $model_class) {
+ $this->model_input_object_types[$model_class] = $this->get_model_input_object_type($model_class);
+ }
+
+ return $this->model_input_object_types;
+ }
+
+ /**
+ * Obtains the GraphQL ObjectType object for a given Model.
+ * @param string $model_name The FQN of the Model to obtain the GraphQL Type object for.
+ * @returns ObjectType The GraphQL Type object for the given Model.
+ */
+ public function get_model_object_type(string $model_name): ObjectType {
+ # Append the Model's namespace if it doesn't exist.
+ if (!str_starts_with($model_name, '\\RESTAPI\\Models\\')) {
+ $model_name = '\\RESTAPI\\Models\\' . $model_name;
+ }
+
+ # Just return the existing Type if it already exists.
+ if (array_key_exists($model_name, $this->model_object_types)) {
+ # This value must be passed by reference to avoid duplicate/non-unique Types in the schema
+ $type = &$this->model_object_types[$model_name];
+ return $type;
+ }
+
+ # Otherwise, create the new Type, assign it to the array, and return it.
+ $model = new $model_name(skip_init: true);
+ $this->model_object_types[$model_name] = $this->model_to_object_type($model);
+
+ # This value must be passed by reference to avoid duplicate/non-unique Types in the schema
+ $type = &$this->model_object_types[$model_name];
+ return $type;
+ }
+
+ /**
+ * Obtains the GraphQL InputObjectType for a given Model.
+ * @param string $model_name The FQN of the Model to obtain the GraphQL Type object for.
+ * @returns Type The GraphQL InputObjectType object for the given Model.
+ */
+ public function get_model_input_object_type(string $model_name): InputObjectType {
+ # Append the Model's namespace if it doesn't exist.
+ if (!str_starts_with($model_name, '\\RESTAPI\\Models\\')) {
+ $model_name = '\\RESTAPI\\Models\\' . $model_name;
+ }
+
+ # Just return the existing Type if it already exists.
+ if (array_key_exists($model_name, $this->model_input_object_types)) {
+ # This value must be passed by reference to avoid duplicate/non-unique Types in the schema
+ $type = &$this->model_input_object_types[$model_name];
+ return $type;
+ }
+
+ # Otherwise, create the new Type, assign it to the array, and return it.
+ $model = new $model_name(skip_init: true);
+ $this->model_input_object_types[$model_name] = $this->model_to_object_type($model, input: true);
+
+ # This value must be passed by reference to avoid duplicate/non-unique Types in the schema
+ $type = &$this->model_input_object_types[$model_name];
+ return $type;
+ }
+
+ /**
+ * Generates all GraphQL Query Types all Endpoint's with GET methods.
+ * @returns array An array containing all the GraphQL Query Types for all Endpoints with GET methods in this API.
+ */
+ public function get_endpoint_query_types(): array {
+ # Variables
+ $query_types = [];
+
+ # Loop through each Endpoint and create a Query Type for each one.
+ foreach (get_classes_from_namespace('\\RESTAPI\\Endpoints\\') as $endpoint) {
+ # Skip the GraphQLEndpoint
+ if ($endpoint === '\RESTAPI\Endpoints\GraphQLEndpoint') {
+ continue;
+ }
+
+ $endpoint = new $endpoint();
+ $query_types = array_merge($query_types, $this->endpoint_to_queries($endpoint));
+ }
+
+ return $query_types;
+ }
+
+ /**
+ * Generates all GraphQL Mutation Types all Endpoint's.
+ * @returns array An array containing all the GraphQL Mutation Types for all Endpoints.
+ */
+ public function get_endpoint_mutation_types(): array {
+ # Variables
+ $mutation_types = [];
+
+ # Loop through each Endpoint and create a Query Type for each one.
+ foreach (get_classes_from_namespace('\\RESTAPI\\Endpoints\\') as $endpoint) {
+ # Skip the GraphQLEndpoint
+ if ($endpoint === '\RESTAPI\Endpoints\GraphQLEndpoint') {
+ continue;
+ }
+
+ $endpoint = new $endpoint();
+ $mutation_types = array_merge($mutation_types, $this->endpoint_to_mutations($endpoint));
+ }
+
+ return $mutation_types;
+ }
+
+ /**
+ * Uses the URL from a given endpoint and converts it to a GraphQL query/mutation name with a given operation prefix.
+ * @param string $operation The operation type/prefix for the GraphQL query/mutation.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL query/mutation name.
+ * @returns array The GraphQL query field config for this Endpoint object.
+ */
+ public function endpoint_to_operation_name(string $operation, Endpoint $endpoint): string {
+ # Ensure the operation name is lowercase
+ $operation = lcfirst($operation);
+
+ # Use the URL but remove the /api/v2/ prefix, and replace all slashes with spaces
+ $name = str_replace('/api/v2/', '', $endpoint->url);
+ $name = str_replace('/', ' ', $name);
+
+ # Convert to camel case and add the operation prefix
+ return $operation . to_upper_camel_case($name);
+ }
+
+ /**
+ * Converts an Endpoint object into a GraphQL Query config. This method is responsible for defining the
+ * GraphQL query operations that are applicable/equivalent to the Endpoint
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL query operation.
+ * @returns array The GraphQL query field config for this Endpoint object.
+ */
+ public function endpoint_to_queries(Endpoint $endpoint): array {
+ # Do not generate GraphQL query operations for Endpoints that do not allow GET requests
+ if (!in_array('GET', $endpoint->request_method_options)) {
+ return [];
+ }
+
+ # Get the query operation for this Endpoint. 'many' endpoints will get a query op, non-many will get a read op
+ return $endpoint->many ? $this->get_query_query($endpoint) : $this->get_query_read($endpoint);
+ }
+
+ /**
+ * Obtains a GraphQL 'query' operation config for a given Endpoint object. This is the GraphQL equivalent to a REST
+ * API GET Endpoint that is 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL query operation.
+ * @return array The GraphQL 'query' operation config for this Endpoint object.
+ */
+ public function get_query_query(Endpoint $endpoint): array {
+ $resolver = new Resolver($endpoint->model);
+ return [
+ $this->endpoint_to_operation_name(operation: 'query', endpoint: $endpoint) => [
+ 'type' => Type::listOf($this->get_model_object_type($endpoint->model_name)),
+ 'resolve' => [$resolver, 'query'], // Use the GraphQLResolver::query method to resolve this query
+ 'args' => [
+ 'query_params' => [
+ 'type' => $this->query_params_type,
+ 'defaultValue' => [],
+ 'description' => 'An object containing the query parameters used to filter the results.',
+ ],
+ 'limit' => [
+ 'type' => Type::int(),
+ 'defaultValue' => 0,
+ 'description' => 'The maximum number of objects to return.',
+ ],
+ 'offset' => [
+ 'type' => Type::int(),
+ 'defaultValue' => 0,
+ 'description' => 'The offset to start returning objects from.',
+ ],
+ 'reverse' => [
+ 'type' => Type::boolean(),
+ 'defaultValue' => false,
+ 'description' => 'Reverse the order of the returned objects.',
+ ],
+ 'sort_by' => [
+ 'type' => Type::listOf(Type::string()),
+ 'defaultValue' => [],
+ 'description' => 'The fields to sort the returned objects by.',
+ ],
+ 'sort_order' => [
+ 'type' => Type::int(),
+ 'defaultValue' => SORT_ASC,
+ 'description' => 'The order to use when sorting.',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL 'read' operation config for a given Endpoint object. This is the GraphQL equivalent to a REST
+ * API GET Endpoint that is not 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL query operation.
+ * @return array The GraphQL 'read' operation config for this Endpoint object.
+ */
+ public function get_query_read(Endpoint $endpoint): array {
+ # Obtain our available resolvers and keep track arguments
+ $resolver = new Resolver($endpoint->model);
+ $args = [];
+
+ # Require an ID if the Model is a many Model
+ if ($endpoint->model->many) {
+ $args['id'] = [
+ 'type' => $this->get_model_object_type($endpoint->model_name)
+ ->getField('id')
+ ->getType(),
+ 'description' => 'The ID of the object to read.',
+ ];
+ }
+ # Require a parent ID if the Model has a parent Model
+ if ($endpoint->model->parent_model_class) {
+ $args['parent_id'] = [
+ 'type' => $this->get_model_object_type($endpoint->model_name)
+ ->getField('parent_id')
+ ->getType(),
+ 'description' => 'The parent ID of the object to read.',
+ ];
+ }
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'read', endpoint: $endpoint) => [
+ 'type' => $this->get_model_object_type($endpoint->model_name),
+ 'resolve' => [$resolver, 'read'], // Use the GraphQLResolver::read method to resolve this query
+ 'args' => $args,
+ ],
+ ];
+ }
+
+ /**
+ * Converts an Endpoint object into a GraphQL mutations config. This method is responsible for defining the
+ * GraphQL mutation operations that are equivalent to the HTTP request methods supported by the Endpoint.
+ * @param Endpoint $endpoint The Endpoint object to convert to GraphQL mutation operations.
+ * @returns array The GraphQL mutations field config for the Endpoint object.
+ */
+ public function endpoint_to_mutations(Endpoint $endpoint): array {
+ # Variables
+ $mutations = [];
+
+ # Include a mutation to create objects for non-many Endpoint's with POST request support
+ if (!$endpoint->many and in_array('POST', $endpoint->request_method_options)) {
+ $mutations = array_merge($mutations, $this->get_mutation_create($endpoint));
+ }
+ # Include a mutation to update objects for non-many Endpoint's with PATCH request support
+ if (!$endpoint->many and in_array('PATCH', $endpoint->request_method_options)) {
+ $mutations = array_merge($mutations, $this->get_mutation_update($endpoint));
+ }
+ # Include a mutation to delete objects for non-many Endpoint's with DELETE request support
+ if (!$endpoint->many and in_array('DELETE', $endpoint->request_method_options)) {
+ $mutations = array_merge($mutations, $this->get_mutation_delete($endpoint));
+ }
+ # Include a mutation to replace all objects for many Endpoint's with PUT request support
+ if ($endpoint->many and in_array('PUT', $endpoint->request_method_options)) {
+ $mutations = array_merge($mutations, $this->get_mutation_replace_all($endpoint));
+ }
+ # Include a mutation to delete many and/or all objects for many Endpoint's with DELETE request support
+ if ($endpoint->many and in_array('DELETE', $endpoint->request_method_options)) {
+ $mutations = array_merge($mutations, $this->get_mutation_delete_many($endpoint));
+ $mutations = array_merge($mutations, $this->get_mutation_delete_all($endpoint));
+ }
+
+ return $mutations;
+ }
+
+ /**
+ * Converts a Model object into a GraphQL mutation operation arguments config.
+ * @param Model $model The Model object to convert to a GraphQL mutation operation arguments config.
+ * @param bool $require_id Whether an ID is required for this mutation operation.
+ * @param bool $only_id Whether only the ID is required for this mutation operation.
+ * @param bool $ignore_required Whether to ignore required fields for this mutation operation.
+ * @param array $exclude_fields An array of field names to exclude from the mutation operation.
+ * @return array The GraphQL mutation operation arguments config for this Model object.
+ */
+ public function model_to_mutation_args(
+ Model $model,
+ bool $require_id = true,
+ bool $only_id = false,
+ bool $ignore_required = false,
+ array $exclude_fields = [],
+ ): array {
+ # Variables
+ $args = [];
+
+ # If an ID is required, and the Model supports it, add it to the GraphQL schema
+ if ($require_id) {
+ $args['id'] = $model->id_type === 'integer' ? Type::nonNull(Type::int()) : Type::nonNull(Type::string());
+ }
+
+ # If the Model has a parent Model, add the parent ID to the GraphQL schema
+ if ($model->parent_model_class) {
+ $args['parent_id'] =
+ $model->parent_id_type === 'integer' ? Type::nonNull(Type::int()) : Type::nonNull(Type::string());
+ }
+
+ # If only the ID is required, return the arguments now
+ if ($only_id) {
+ return $args;
+ }
+
+ # Loop through each Field in this Model and obtain its GraphQL field definition
+ foreach ($model->get_fields() as $field) {
+ # For NestedModelFields, ensure we pass the Type for the nested Model
+ if ($model->$field instanceof NestedModelField) {
+ $nested_model_type = $this->get_model_input_object_type($model->$field->model_class);
+ $args[$field]['type'] = $this->field_to_type(
+ field: $model->$field,
+ type: $nested_model_type,
+ ignore_required: $ignore_required,
+ );
+ $args[$field]['description'] = $model->$field->help_text;
+ continue;
+ }
+
+ # Generate and add the GraphQL field from the Field object
+ $args[$field]['type'] = $this->field_to_type(field: $model->$field, ignore_required: $ignore_required);
+ $args[$field]['description'] = $model->$field->help_text;
+ }
+
+ # Include control parameters for this mutation operation
+ $args = array_merge($args, self::get_control_param_args());
+
+ # Exclude any fields that are not required for this mutation operation
+ return array_filter($args, fn($key) => !in_array($key, $exclude_fields), ARRAY_FILTER_USE_KEY);
+ }
+
+ /**
+ * Obtains common control parameters as GraphQL mutation operation arguments
+ * @returns array The control parameters for a GraphQL mutation operation.
+ */
+ public static function get_control_param_args(): array {
+ return [
+ 'placement' => [
+ 'type' => Type::int(),
+ 'description' => 'The index to place this object in the configuration.',
+ ],
+ 'append' => [
+ 'type' => Type::boolean(),
+ 'defaultValue' => false,
+ 'description' =>
+ 'Force new array values to be appended to the existing array instead of replacing ' .
+ 'it. This only applies to update operations.',
+ ],
+ 'delete' => [
+ 'type' => Type::boolean(),
+ 'defaultValue' => false,
+ 'description' =>
+ 'Force given array values to be removed from the existing array. This only applies ' .
+ 'to update operations.',
+ ],
+ 'apply' => [
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => 'Apply configuration changes immediately where applicable.',
+ ],
+ 'async' => [
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => 'Run this operation asynchronously when possible.',
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL mutation operation config that can be used to create a new object for the assigned Model.
+ * This is the GraphQL equivalent to a REST API POST Endpoint that is not 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL mutation operation.
+ * @returns array The GraphQL 'create' mutation config for this Endpoint object.
+ */
+ protected function get_mutation_create(Endpoint $endpoint): array {
+ # Obtain our available resolvers
+ $resolver = new Resolver($endpoint->model);
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'create', endpoint: $endpoint) => [
+ 'type' => $this->get_model_object_type($endpoint->model_name),
+ 'resolve' => [$resolver, 'create'], // Use the GraphQLResolver::create method to resolve this query
+ 'args' => $this->model_to_mutation_args(
+ model: $endpoint->model,
+ require_id: false,
+ ignore_required: true,
+ exclude_fields: ['append', 'delete'],
+ ),
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL mutation operation config that can be used to update a new object for the assigned Model.
+ * This is the GraphQL equivalent to a REST API PATCH Endpoint that is not 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL mutation operation.
+ * @returns array The GraphQL 'update' mutation config for this Endpoint object.
+ */
+ protected function get_mutation_update(Endpoint $endpoint): array {
+ # Obtain our available resolvers
+ $resolver = new Resolver($endpoint->model);
+
+ # Determine if this Model requires an ID to update
+ $requires_id = $endpoint->model->many;
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'update', endpoint: $endpoint) => [
+ 'type' => $this->get_model_object_type($endpoint->model_name),
+ 'resolve' => [$resolver, 'update'], // Use the Resolver::update method to resolve this query
+ 'args' => $this->model_to_mutation_args(
+ model: $endpoint->model,
+ require_id: $requires_id,
+ ignore_required: true,
+ ),
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL mutation operation config that can be used to delete a new object for the assigned Model.
+ * This is the GraphQL equivalent to a REST API DELETE Endpoint that is not 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL mutation operation.
+ * @returns array The GraphQL 'delete' mutation config for this Endpoint object.
+ */
+ protected function get_mutation_delete(Endpoint $endpoint): array {
+ # Obtain our available resolvers
+ $resolver = new Resolver($endpoint->model);
+
+ # Determine if this Model requires an ID to delete
+ $requires_id = $endpoint->model->many;
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'delete', endpoint: $endpoint) => [
+ 'type' => $this->get_model_object_type($endpoint->model_name),
+ 'resolve' => [$resolver, 'delete'], // Use the Resolver::delete method to resolve this query,
+ 'args' => $this->model_to_mutation_args(
+ model: $endpoint->model,
+ require_id: $requires_id,
+ ignore_required: true,
+ exclude_fields: ['append', 'delete', 'placement'],
+ ),
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL mutation operation config that can be used to replace all existing objects for the assigned Model.
+ * This is the GraphQL equivalent to a REST API PUT Endpoint that is 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL mutation operation.
+ * @returns array The GraphQL 'replaceAll' mutation config for this Endpoint object.
+ */
+ protected function get_mutation_replace_all(Endpoint $endpoint): array {
+ # Obtain our available resolvers
+ $resolver = new Resolver($endpoint->model);
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'replaceAll', endpoint: $endpoint) => [
+ 'type' => Type::listOf($this->get_model_object_type($endpoint->model_name)),
+ 'resolve' => [$resolver, 'replace_all'], // Use the Resolver::replace_all method to resolve this query,
+ 'args' => [
+ 'objects' => [
+ 'type' => Type::listOf($this->get_model_input_object_type($endpoint->model_name)),
+ 'description' => 'The objects to replace all existing objects with.',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL mutation operation config that can be used to delete many existing objects for the assigned
+ * Model using a query. This is the GraphQL equivalent to a REST API DELETE Endpoint that is 'many' enabled.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL mutation operation.
+ * @returns array The GraphQL 'deleteMany' mutation config for this Endpoint object.
+ */
+ protected function get_mutation_delete_many(Endpoint $endpoint): array {
+ # Obtain our available resolvers
+ $resolver = new Resolver($endpoint->model);
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'deleteMany', endpoint: $endpoint) => [
+ 'type' => Type::listOf($this->get_model_object_type($endpoint->model_name)),
+ 'resolve' => [$resolver, 'delete_many'], // Use the Resolver::delete_many method to resolve this query,
+ 'args' => [
+ 'query_params' => [
+ 'type' => Type::nonNull($this->query_params_type),
+ 'description' =>
+ 'An object containing the query parameters used to filter which objects to delete.',
+ ],
+ 'limit' => [
+ 'type' => Type::int(),
+ 'defaultValue' => 0,
+ 'description' => 'The maximum number of objects to delete.',
+ ],
+ 'offset' => [
+ 'type' => Type::int(),
+ 'defaultValue' => 0,
+ 'description' => 'The offset to start deleting objects from.',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Obtains a GraphQL mutation operation config that can be used to delete all existing objects for the assigned
+ * Model. This is the GraphQL equivalent to a REST API DELETE Endpoint that is 'many' enabled and has the 'all`
+ * parameter set to true.
+ * @param Endpoint $endpoint The Endpoint object to convert to a GraphQL mutation operation.
+ * @returns array The GraphQL 'deleteAll' mutation config for this Endpoint object.
+ */
+ protected function get_mutation_delete_all(Endpoint $endpoint): array {
+ # Obtain our available resolvers
+ $resolver = new Resolver($endpoint->model);
+
+ return [
+ $this->endpoint_to_operation_name(operation: 'deleteAll', endpoint: $endpoint) => [
+ 'type' => Type::listOf($this->get_model_object_type($endpoint->model_name)),
+ 'resolve' => [$resolver, 'delete_all'], // Use the Resolver::delete_all method to resolve this query,
+ 'args' => [],
+ ],
+ ];
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc
new file mode 100644
index 00000000..bff7c7cf
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc
@@ -0,0 +1,448 @@
+get_schema()) . PHP_EOL;
+ }
+
+ /**
+ * Obtains the OpenAPI schema as an array.
+ */
+ public function get_schema(): array {
+ # Variables
+ $endpoint_classes = get_classes_from_namespace('\\RESTAPI\\Endpoints\\');
+ $response_classes = get_classes_from_namespace('\\RESTAPI\\Responses\\');
+ $auth_classes = get_classes_from_namespace('\\RESTAPI\\Auth\\');
+ $auth_classes_short = get_classes_from_namespace('\\RESTAPI\\Auth\\', shortnames: true);
+ $content_handler_classes = get_classes_from_namespace('\\RESTAPI\\ContentHandlers\\');
+ $restapi_version = new RESTAPIVersion();
+ $assigned_tags = [];
+
+ # Set static openapi details
+ $openapi_config = [
+ 'openapi' => '3.0.0',
+ 'servers' => [['url' => '/', 'description' => 'This firewall']],
+ 'info' => [
+ 'title' => 'pfSense REST API Documentation',
+ 'version' => $restapi_version->current_version->value,
+ 'contact' => [
+ 'name' => 'GitHub',
+ 'url' => 'https://github.com/jaredhendrickson13/pfsense-api',
+ ],
+ 'license' => [
+ 'name' => 'Apache 2.0',
+ 'url' => 'https://raw.githubusercontent.com/jaredhendrickson13/pfsense-api/master/LICENSE',
+ ],
+ 'description' =>
+ '### Getting Started' .
+ '
' .
+ '- [Authentication and Authorization](https://pfrest.org/AUTHENTICATION_AND_AUTHORIZATION/)
' .
+ '- [Working with Object IDs](https://pfrest.org/WORKING_WITH_OBJECT_IDS/)
' .
+ '- [Queries, Filters, and Sorting](https://pfrest.org/QUERIES_FILTERS_AND_SORTING/)
' .
+ '- [Common Control Parameters](https://pfrest.org/COMMON_CONTROL_PARAMETERS/)
' .
+ '- [Working with HATEOAS](https://pfrest.org/WORKING_WITH_HATEOAS/)
',
+ ],
+ 'components' => [
+ 'schemas' => [],
+ 'securitySchemes' => [],
+ ],
+ 'security' => [],
+ 'paths' => [],
+ 'tags' => [],
+ ];
+
+ # Add security and securitySchemes definitions for each Auth class
+ foreach ($auth_classes as $auth_class) {
+ # Create an object for this Auth class so we can obtain class information
+ $auth_method = new $auth_class();
+ $auth_shortname = $auth_method->get_class_shortname();
+
+ # Add global security definitions.
+ $openapi_config['security'][] = [$auth_shortname => []];
+
+ # Add securitySchemes for each \RESTAPI\Auth class
+ $openapi_config['components']['securitySchemes'][$auth_shortname] = $auth_method->security_scheme;
+ }
+
+ # Add Response components for each Response class in RESTAPI\Responses
+ foreach ($response_classes as $response_class) {
+ # Create the Response object
+ $response = new $response_class(message: '', response_id: '');
+ $resp_shortname = $response->get_class_shortname();
+
+ # Add both a schema and response for this Response class
+ $openapi_config['components']['schemas'][$resp_shortname] = $response->to_openapi_schema();
+ }
+
+ # Import each Endpoint class and assign openapi for the endpoint according to the options and Model assigned.
+ foreach ($endpoint_classes as $endpoint_class) {
+ # Create the Response object
+ $endpoint = new $endpoint_class();
+
+ # Add this Endpoint's URL to the OpenAPI `paths`
+ $openapi_config['paths'][$endpoint->url] = [];
+
+ # Initialize this endpoint's OpenAPI tag
+ if (!in_array($endpoint->tag, $assigned_tags)) {
+ $openapi_config['tags'][] = ['name' => $endpoint->tag];
+ $assigned_tags[] = $endpoint->tag;
+ }
+
+ # Obtain the Model assigned to the Endpoint and create any assigned parent Model's
+ $model = "\\RESTAPI\\Models\\$endpoint->model_name";
+ $model = new $model();
+ $model->get_parent_model();
+
+ # Obtain the OpenAPI schema for this Model.
+ $openapi_config['components']['schemas'][$endpoint->model_name] = $model->to_openapi_schema();
+
+ # Assign shared values to each request method defined in this path
+ foreach ($endpoint->request_method_options as $request_method) {
+ # Convert the request method to lower case so it matches the OpenAPI config
+ $request_method = strtolower($request_method);
+
+ # Obtain the privileges and help text associated with this request method
+ $privilege_property = $request_method . '_privileges';
+ $help_text_property = $request_method . '_help_text';
+
+ # Assign endpoint details to variables
+ $help_text = $endpoint->$help_text_property;
+ $endpoint_type = $endpoint->many ? 'Plural' : 'Singular';
+ $parent_model_class = $model->parent_model_class ?: 'None';
+ $priv_options_str = implode(', ', $endpoint->$privilege_property);
+ $required_packages_str = $model->packages ? implode(', ', $model->packages) : 'None';
+ $requires_auth_str = $endpoint->requires_auth ? 'Yes' : 'No';
+ $auth_method_str = implode(', ', $endpoint->auth_methods ?: $auth_classes_short);
+ $cache_class = $model->cache_class ?: 'None';
+ $operation_id = "$request_method{$endpoint->get_class_shortname()}";
+
+ # Determine if this Model applies changes immediately or not
+ $applies_immediately = $model->does_apply_immediately();
+ $applies_immediately = $applies_immediately ? 'Yes' : $applies_immediately;
+ $applies_immediately = $applies_immediately === false ? 'No' : $applies_immediately;
+ $applies_immediately = $applies_immediately === null ? 'Not Applicable' : $applies_immediately;
+
+ # Add openapi for all requests at this path
+ $openapi_config['paths'][$endpoint->url][$request_method] = [
+ 'responses' => [],
+ 'operationId' => $operation_id,
+ 'deprecated' => $endpoint->deprecated,
+ 'description' =>
+ 'Description:
' .
+ "$help_text
" .
+ 'Details:
' .
+ "**Endpoint type**: $endpoint_type
" .
+ "**Associated model**: $endpoint->model_name
" .
+ "**Parent model**: $parent_model_class
" .
+ "**Requires authentication**: $requires_auth_str
" .
+ "**Supported authentication modes:** [ $auth_method_str ]
" .
+ "**Allowed privileges**: [ $priv_options_str ]
" .
+ "**Required packages**: [ $required_packages_str ]
" .
+ "**Applies immediately**: $applies_immediately
" .
+ "**Utilizes cache**: $cache_class",
+ ];
+
+ # Nest this endpoint under its assigned or assumed tag
+ $openapi_config['paths'][$endpoint->url][$request_method]['tags'] = [$endpoint->tag];
+
+ # Ensure the security mode is enforced for this path if the Endpoint has `auth_methods` set
+ if ($endpoint->auth_methods) {
+ foreach ($endpoint->auth_methods as $auth_method) {
+ $openapi_config['paths'][$endpoint->url][$request_method]['security'][] = [$auth_method => []];
+ }
+ }
+
+ # Assign request body definitions for POST, PUT and PATCH requests
+ if (in_array($request_method, ['post', 'put', 'patch'])) {
+ # Only include required fields in the $allOf schema if there are required fields for this Model
+ $allof_schema = ['type' => 'object'];
+ $required_fields = $model->get_fields(required_only: true);
+ if ($required_fields) {
+ $allof_schema['required'] = $required_fields;
+ }
+
+ # For non `many` Endpoints with `many` Models, add the ID to the schema and make it required
+ if (!$endpoint->many and $model->many and $request_method !== 'post') {
+ $schema = [
+ 'schema' => [
+ 'allOf' => [
+ [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => [
+ 'type' => 'integer',
+ 'description' => 'The ID of the object or resource to interact with.',
+ ],
+ ],
+ ],
+ ['$ref' => "#/components/schemas/$endpoint->model_name"],
+ ],
+ ],
+ ];
+ }
+ # For `many` Endpoints with `many` Models, accept arrays of many schema objects
+ elseif ($endpoint->many and $model->many) {
+ # Write the schema objects with any required fields
+ $schema = [
+ 'schema' => [
+ 'type' => 'array',
+ 'items' => [
+ 'allOf' => [
+ ['$ref' => "#/components/schemas/$endpoint->model_name"],
+ $allof_schema,
+ ],
+ ],
+ ],
+ ];
+ }
+ # Otherwise, just assign the schema with all required Fields included
+ else {
+ $schema = [
+ 'schema' => [
+ 'allOf' => [['$ref' => "#/components/schemas/$endpoint->model_name"], $allof_schema],
+ ],
+ ];
+ }
+
+ # Add the `parent_id` field to Models with a `many` parent
+ if ($model->parent_model_class and $model->parent_model->many) {
+ array_unshift($schema['schema']['allOf'], [
+ 'type' => 'object',
+ 'required' => ['parent_id'],
+ 'properties' => [
+ 'parent_id' => [
+ 'type' => 'integer',
+ 'description' => 'The ID of the parent this object is nested under.',
+ ],
+ ],
+ ]);
+ }
+
+ # Populate OpenAPI 'content' definitions for each ContentHandler capable of decoding request bodies.
+ $contents = [];
+ foreach ($content_handler_classes as $content_handler_class) {
+ # Create an object for this content handler so we can extract handler info
+ $content_handler = new $content_handler_class();
+ $content_handler_sn = $content_handler->get_class_shortname();
+
+ # Skip this content handler if it's not allowed by the endpoint
+ if (
+ $endpoint->decode_content_handlers and
+ !in_array($content_handler_sn, $endpoint->decode_content_handlers)
+ ) {
+ continue;
+ }
+ # Skip this content handler if it can't decode
+ if (!$content_handler->can_decode()) {
+ continue;
+ }
+
+ $contents[$content_handler->mime_type] = $schema;
+ }
+ $openapi_config['paths'][$endpoint->url][$request_method]['requestBody']['content'] = $contents;
+ }
+
+ # Assign the ID query parameter for GET and DELETE requests to non `many` Endpoints with a `many` Model assigned
+ if (!$endpoint->many and $model->many and in_array($request_method, ['get', 'delete'])) {
+ $openapi_config['paths'][$endpoint->url][$request_method]['parameters'] = [
+ [
+ 'in' => 'query',
+ 'name' => 'id',
+ 'description' => 'The ID of the object to target.',
+ 'required' => true,
+ 'schema' => [
+ 'oneOf' => [['type' => 'integer'], ['type' => 'string']],
+ ],
+ ],
+ ];
+
+ # Add the `parent_id` parameter if this model has a parent model assigned
+ if ($model->parent_model_class and $model->parent_model->many) {
+ array_unshift($openapi_config['paths'][$endpoint->url][$request_method]['parameters'], [
+ 'in' => 'query',
+ 'name' => 'parent_id',
+ 'description' => 'The ID of the parent this object is nested under.',
+ 'required' => true,
+ 'schema' => [
+ 'oneOf' => [['type' => 'integer'], ['type' => 'string']],
+ ],
+ ]);
+ }
+
+ # Add the `apply` parameter if this is a DELETE request
+ if ($request_method == 'delete' and $model->subsystem) {
+ $openapi_config['paths'][$endpoint->url][$request_method]['parameters'][] = [
+ 'in' => 'query',
+ 'name' => 'apply',
+ 'description' => 'Apply this deletion immediately.',
+ 'required' => false,
+ 'schema' => [
+ 'type' => 'boolean',
+ 'default' => false,
+ ],
+ ];
+ }
+ }
+
+ # Assign the limit, offset, query and sort params to GET endpoints with $many enabled
+ if ($endpoint->many and $request_method === 'get') {
+ $openapi_config['paths'][$endpoint->url][$request_method]['parameters'] = [
+ [
+ 'in' => 'query',
+ 'name' => 'limit',
+ 'description' => 'The number of objects to obtain at once. Set to 0 for no limit.',
+ 'schema' => ['type' => 'integer', 'default' => $endpoint->limit],
+ ],
+ [
+ 'in' => 'query',
+ 'name' => 'offset',
+ 'description' => 'The starting point in the dataset to begin fetching objects.',
+ 'schema' => ['type' => 'integer', 'default' => $endpoint->offset],
+ ],
+ [
+ 'in' => 'query',
+ 'name' => 'sort_by',
+ 'description' => 'The fields to sort response data by.',
+ 'schema' => [
+ 'type' => 'array',
+ 'items' => ['type' => 'string'],
+ 'nullable' => true,
+ 'default' => $endpoint->sort_by,
+ ],
+ ],
+ [
+ 'in' => 'query',
+ 'name' => 'sort_order',
+ 'description' => 'Sort the response data in descending order.',
+ 'schema' => [
+ 'type' => 'string',
+ 'enum' => ['SORT_ASC', 'SORT_DESC'],
+ 'nullable' => true,
+ ],
+ ],
+ [
+ 'in' => 'query',
+ 'name' => 'query',
+ 'style' => 'form',
+ 'explode' => true,
+ 'description' =>
+ 'The arbitrary query parameters to include in the request.
' .
+ 'Note: This does not define an real parameter, rather it allows for any arbitrary query ' .
+ 'parameters to be included in the request.',
+ 'schema' => [
+ 'type' => 'object',
+ 'default' => new stdClass(),
+ 'additionalProperties' => ['type' => 'string'],
+ ],
+ ],
+ ];
+ }
+
+ # Assign the limit and offset to DELETE endpoints with $many enabled
+ if ($endpoint->many and $request_method === 'delete') {
+ $openapi_config['paths'][$endpoint->url][$request_method]['parameters'] = [
+ [
+ 'in' => 'query',
+ 'name' => 'limit',
+ 'description' => 'The maximum number of objects to delete at once. Set to 0 for no limit.',
+ 'schema' => ['type' => 'integer', 'default' => $endpoint->limit],
+ ],
+ [
+ 'in' => 'query',
+ 'name' => 'offset',
+ 'description' => 'The starting point in the dataset to begin fetching objects.',
+ 'schema' => ['type' => 'integer', 'default' => $endpoint->offset],
+ ],
+ [
+ 'in' => 'query',
+ 'name' => 'query',
+ 'style' => 'form',
+ 'explode' => true,
+ 'description' =>
+ 'The arbitrary query parameters to include in the request.
' .
+ 'Note: This does not define an actual parameter, rather it allows for any arbitrary query ' .
+ 'parameters to be included in the request.',
+ 'schema' => [
+ 'type' => 'object',
+ 'default' => new stdClass(),
+ 'additionalProperties' => ['type' => 'string'],
+ ],
+ ],
+ ];
+ }
+
+ # Assign this request method Responses for each Response class defined.
+ foreach ($response_classes as $response_class) {
+ # Construct the Response class
+ $response = new $response_class(message: '', response_id: '');
+ $response_sn = $response->get_class_shortname();
+ $content_handler_classes = get_classes_from_namespace('\\RESTAPI\\ContentHandlers\\');
+
+ # Skip this response class if it is not a response type for this endpoint
+ if ($endpoint->response_types and !in_array($response_sn, $endpoint->response_types)) {
+ continue;
+ }
+
+ # Loop through all ContentHandlers and add relevant accept types to the response
+ foreach ($content_handler_classes as $content_handler_class) {
+ # Create the content handler object and add the schema for the content to our component
+ $content_handler = new $content_handler_class();
+ $content_handler_sn = $content_handler->get_class_shortname();
+
+ # Skip this content handler if the Endpoint does not support it
+ if (
+ $endpoint->encode_content_handlers and
+ !in_array($content_handler_sn, $endpoint->encode_content_handlers)
+ ) {
+ continue;
+ }
+ # Skip content handlers that cannot encode
+ if (!$content_handler->can_encode()) {
+ continue;
+ }
+
+ $openapi_config['paths'][$endpoint->url][$request_method]['responses'][$response->code][
+ 'description'
+ ] = $response->help_text;
+ $openapi_config['paths'][$endpoint->url][$request_method]['responses'][$response->code][
+ 'content'
+ ] = [];
+ $openapi_config['paths'][$endpoint->url][$request_method]['responses'][$response->code][
+ 'content'
+ ] = array_merge(
+ $openapi_config['paths'][$endpoint->url][$request_method]['responses'][$response->code][
+ 'content'
+ ],
+ $content_handler->to_openapi_schema($response, $endpoint),
+ );
+ }
+ }
+ }
+ }
+
+ return $openapi_config;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIContentHandlersBinaryContentHandlerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIContentHandlersBinaryContentHandlerTestCase.inc
new file mode 100644
index 00000000..81eca0c0
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIContentHandlersBinaryContentHandlerTestCase.inc
@@ -0,0 +1,91 @@
+assert_throws_response(
+ response_id: 'BINARY_CONTENT_HANDLER_RESOURCE_NOT_SUPPORTED',
+ code: 406,
+ callable: function () {
+ $handler = new BinaryContentHandler();
+ $response = new Success(message: '', response_id: ''); # Required as context for the encode() method
+ $handler->encode(['data' => []], $response);
+ },
+ );
+ }
+
+ /**
+ * Ensure the encode() method throws an error if the 'filename' key contains invalid characters.
+ */
+ public function test_encode_requires_valid_filename(): void {
+ $this->assert_throws_response(
+ response_id: 'BINARY_CONTENT_HANDLER_INVALID_FILENAME',
+ code: 500,
+ callable: function () {
+ $handler = new BinaryContentHandler();
+ $response = new Success(message: '', response_id: ''); # Required as context for the encode() method
+ $handler->encode(['data' => ['binary_data' => 'data', 'filename' => 'invalid file name']], $response);
+ },
+ );
+ }
+
+ /**
+ * Ensure the encode() method does not throw an error if the required 'binary_data' and 'filename' keys are present in
+ * the content array.
+ */
+ public function test_encode_requires_binary_data(): void {
+ $this->assert_throws_response(
+ response_id: 'BINARY_CONTENT_HANDLER_INVALID_DATA',
+ code: 500,
+ callable: function () {
+ $handler = new BinaryContentHandler();
+ $response = new Success(message: '', response_id: ''); # Required as context for the encode() method
+ $handler->encode(['data' => ['binary_data' => 'data', 'filename' => 'valid_file_name.txt']], $response);
+ },
+ );
+ }
+
+ /**
+ * Ensures the encode() method correctly encodes the content as a binary file for download.
+ */
+ public function test_encode(): void {
+ $this->assert_does_not_throw(
+ callable: function () {
+ $response = new Success(message: '', response_id: '');
+ $handler = new BinaryContentHandler();
+ $binary_data = random_bytes(16);
+ $handler->encode(
+ ['data' => ['binary_data' => $binary_data, 'filename' => 'valid_file_name.txt']],
+ $response,
+ );
+ },
+ );
+ }
+
+ /**
+ * Ensures the to_openapi_schema() method returns the correct schema for the BinaryContentHandler.
+ */
+ public function test_to_openapi_schema(): void {
+ $handler = new BinaryContentHandler();
+ $response = new Success(message: '', response_id: ''); # Required as context for the to_openapi_schema() method
+ $endpoint = new FirewallAliasEndpoint(); # Required as context for the to_openapi_schema() method
+ $this->assert_equals($handler->to_openapi_schema($response, $endpoint), [
+ $handler->mime_type => [
+ 'schema' => [
+ 'type' => 'string',
+ 'format' => 'binary',
+ ],
+ ],
+ ]);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreContentHandlerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreContentHandlerTestCase.inc
index bfd4e305..18c5c920 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreContentHandlerTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreContentHandlerTestCase.inc
@@ -4,6 +4,10 @@ namespace RESTAPI\Tests;
use RESTAPI\Core\ContentHandler;
use RESTAPI\Core\TestCase;
+use RESTAPI\Endpoints\FirewallAliasEndpoint;
+use RESTAPI\Endpoints\FirewallAliasesEndpoint;
+use RESTAPI\Responses\NotFoundError;
+use RESTAPI\Responses\Success;
class APICoreContentHandlerTestCase extends TestCase {
/**
@@ -110,4 +114,95 @@ class APICoreContentHandlerTestCase extends TestCase {
$_SERVER['HTTP_CONTENT_TYPE'] = 'application/json; charset=utf-8';
$this->assert_equals(ContentHandler::get_decode_mime_type(), 'application/json');
}
+
+ /**
+ * Checks that the to_openapi_schema() correctly converts the content handler into a OpenAPI response content
+ * schema for a many endpoint
+ */
+ public function test_to_openapi_schema_many_endpoint(): void {
+ $handler = new ContentHandler();
+ $handler->mime_type = 'application/json';
+ $response = new Success(message: '', response_id: '');
+ $endpoint = new FirewallAliasesEndpoint();
+ $schema = $handler->to_openapi_schema($response, $endpoint);
+ $this->assert_equals($schema, [
+ 'application/json' => [
+ 'schema' => [
+ 'allOf' => [
+ ['$ref' => "#/components/schemas/{$response->get_class_shortname()}"],
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'data' => [
+ 'type' => 'array',
+ 'items' => [
+ '$ref' => "#/components/schemas/{$endpoint->model->get_class_shortname()}",
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Checks that the to_openapi_schema() correctly converts the content handler into a OpenAPI response content
+ * schema for a non-many endpoint
+ */
+ public function test_to_openapi_schema_non_many_endpoint(): void {
+ $handler = new ContentHandler();
+ $handler->mime_type = 'application/json';
+ $response = new Success(message: '', response_id: '');
+ $endpoint = new FirewallAliasEndpoint();
+ $schema = $handler->to_openapi_schema($response, $endpoint);
+ $this->assert_equals($schema, [
+ 'application/json' => [
+ 'schema' => [
+ 'allOf' => [
+ ['$ref' => "#/components/schemas/{$response->get_class_shortname()}"],
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'data' => ['$ref' => "#/components/schemas/{$endpoint->model->get_class_shortname()}"],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Checks that the to_openapi_schema() correctly converts the content handler into a OpenAPI response content
+ * schema for non successful response
+ */
+ public function test_to_openapi_schema_non_success(): void {
+ $handler = new ContentHandler();
+ $handler->mime_type = 'application/json';
+ $response = new NotFoundError(message: '', response_id: '');
+ $endpoint = new FirewallAliasesEndpoint();
+ $schema = $handler->to_openapi_schema($response, $endpoint);
+ $this->assert_equals($schema, [
+ 'application/json' => [
+ 'schema' => [
+ 'allOf' => [
+ ['$ref' => "#/components/schemas/{$response->get_class_shortname()}"],
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'data' => [
+ 'oneOf' => [
+ ['type' => 'array', 'items' => ['type' => 'object']],
+ ['type' => 'object'],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc
index 35e69165..d502b13c 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc
@@ -2,6 +2,7 @@
namespace RESTAPI\Tests;
+use RESTAPI\ContentHandlers\JSONContentHandler;
use RESTAPI\Core\TestCase;
use RESTAPI\Endpoints\DiagnosticsARPTableEndpoint;
use RESTAPI\Endpoints\FirewallAliasEndpoint;
@@ -679,4 +680,134 @@ class APICoreEndpointTestCase extends TestCase {
$this->assert_equals($resp->code, 200);
$this->assert_equals(count($resp->data), 4);
}
+
+ /**
+ * Checks that the encode_content_handler property controls which ContentHandlers can be used to encode the response
+ * for the endpoint.
+ */
+ public function test_encode_content_handler(): void {
+ $this->assert_throws_response(
+ response_id: 'ENDPOINT_ACCEPT_NOT_SUPPORTED',
+ code: 406,
+ callable: function () {
+ # Use the FirewallAliasEndpoint to test this
+ $endpoint = new FirewallAliasEndpoint();
+
+ # Only allow the 'BinaryContentHandler' to encode responses
+ $endpoint->encode_content_handlers = ['BinaryContentHandler'];
+
+ # Try to get JSONContentHandler to encode the response. This should throw our error.
+ $endpoint->check_encode_content_handler_supported(new JSONContentHandler());
+ },
+ );
+ }
+
+ /**
+ * Checks that the decode_content_handler property controls which ContentHandlers can be used to decode requests
+ * for the endpoint.
+ */
+ public function test_decode_content_handler(): void {
+ $this->assert_throws_response(
+ response_id: 'ENDPOINT_CONTENT_TYPE_NOT_SUPPORTED',
+ code: 406,
+ callable: function () {
+ # Use the FirewallAliasEndpoint to test this
+ $endpoint = new FirewallAliasEndpoint();
+
+ # Only allow the 'URLContentHandler' to decode requests
+ $endpoint->decode_content_handlers = ['URLContentHandler'];
+
+ # Try to get JSONContentHandler to decode the request. This should throw our error.
+ $endpoint->check_decode_content_handler_supported(new JSONContentHandler());
+ },
+ );
+ }
+
+ /**
+ * Ensure any errors which occur during the encoding process get encoded using the default JSONContentHandler.
+ */
+ public function test_encode_error_uses_json_as_response(): void {
+ # Try to request the FirewallAliasesEndpoint to return data as binary data (application/octet-stream)
+ $endpoint = new FirewallAliasesEndpoint();
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['HTTP_ACCEPT'] = 'application/octet-stream';
+ $_SERVER['PHP_AUTH_USER'] = 'admin';
+ $_SERVER['PHP_AUTH_PW'] = 'pfsense';
+ $resp = $endpoint->process_request();
+
+ # Ensure the response is JSON
+ $this->assert_equals(json_decode($resp)->code, 406);
+ }
+
+ /**
+ * Ensure the 'sort_by' common control parameter must be the name of a Model field.
+ */
+ public function test_sort_by_must_be_model_field_name(): void {
+ # Use a GET request to request a non-string field to sort by
+ $json_resp = \RESTAPI\Core\Tools\http_request(
+ url: 'https://localhost/api/v2/firewall/aliases',
+ method: 'GET',
+ data: ['sort_by' => 12345],
+ headers: ['Content-Type' => 'application/json'],
+ username: 'admin',
+ password: 'pfsense',
+ validate_certs: false,
+ );
+ # Ensure the response threw a ENDPOINT_SORT_BY_FIELD_INVALID_TYPE error
+ $resp = json_decode($json_resp);
+ $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_BY_FIELD_INVALID_TYPE');
+ $this->assert_equals($resp->code, 400);
+
+ # Make another request to sort by a field that does not exist in the Model
+ $json_resp = \RESTAPI\Core\Tools\http_request(
+ url: 'https://localhost/api/v2/firewall/aliases',
+ method: 'GET',
+ data: ['sort_by' => 'non_existent_field'],
+ headers: ['Content-Type' => 'application/json'],
+ username: 'admin',
+ password: 'pfsense',
+ validate_certs: false,
+ );
+
+ # Ensure the response threw a ENDPOINT_SORT_BY_FIELD_NON_EXISTENT_FIELD error
+ $resp = json_decode($json_resp);
+ $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_BY_FIELD_NON_EXISTENT_FIELD');
+ $this->assert_equals($resp->code, 400);
+ }
+
+ /**
+ * Ensure the 'sort_order' common control parameter must be the name of a valid PHP sort constant.
+ */
+ public function test_sort_order_must_be_php_sort_constant(): void {
+ # Use a GET request to request a non-string sort order
+ $json_resp = \RESTAPI\Core\Tools\http_request(
+ url: 'https://localhost/api/v2/firewall/aliases',
+ method: 'GET',
+ data: ['sort_by' => 'name', 'sort_order' => 12345],
+ headers: ['Content-Type' => 'application/json'],
+ username: 'admin',
+ password: 'pfsense',
+ validate_certs: false,
+ );
+ # Ensure the response threw a ENDPOINT_SORT_ORDER_FIELD_INVALID_TYPE error
+ $resp = json_decode($json_resp);
+ $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_ORDER_FIELD_INVALID_TYPE');
+ $this->assert_equals($resp->code, 400);
+
+ # Make another request to sort order that is not known
+ $json_resp = \RESTAPI\Core\Tools\http_request(
+ url: 'https://localhost/api/v2/firewall/aliases',
+ method: 'GET',
+ data: ['sort_by' => 'name', 'sort_order' => 'unknown_sort_order'],
+ headers: ['Content-Type' => 'application/json'],
+ username: 'admin',
+ password: 'pfsense',
+ validate_certs: false,
+ );
+
+ # Ensure the response threw a ENDPOINT_SORT_ORDER_FIELD_UNKNOWN_SORT_ORDER error
+ $resp = json_decode($json_resp);
+ $this->assert_equals($resp->response_id, 'ENDPOINT_SORT_ORDER_FIELD_UNKNOWN_SORT_ORDER');
+ $this->assert_equals($resp->code, 400);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc
index 469f23f3..c8389fc2 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelSetTestCase.inc
@@ -6,6 +6,7 @@ require_once 'RESTAPI/autoloader.inc';
use RESTAPI;
use RESTAPI\Core\ModelSet;
+use RESTAPI\Models\DNSResolverHostOverride;
use RESTAPI\Models\FirewallAlias;
/**
@@ -340,4 +341,98 @@ class APICoreModelSetTestCase extends RESTAPI\Core\TestCase {
$alias_b->delete();
$alias_c->delete();
}
+
+ /**
+ * Checks that the sort() method correctly sorts the Model objects in the ModelSet.
+ */
+ public function test_sort(): void {
+ # Add three firewall aliases to a ModelSet
+ $alias_b = new FirewallAlias(name: 'alias_b', type: 'port');
+ $alias_a = new FirewallAlias(name: 'alias_a', type: 'host');
+ $alias_c = new FirewallAlias(name: 'alias_c', type: 'network');
+ $modelset = new ModelSet([$alias_a, $alias_b, $alias_c]);
+
+ # Sort the ModelSet by the 'name' field in ascending order and ensure the order is correct
+ $this->assert_equals(
+ $modelset->sort(['name'])->model_objects,
+ (new ModelSet([$alias_a, $alias_b, $alias_c]))->model_objects,
+ );
+
+ # Sort the ModelSet by the 'name' field in descending order and ensure the order is correct
+ $this->assert_equals(
+ $modelset->sort(['name'], order: SORT_DESC)->model_objects,
+ (new ModelSet([$alias_c, $alias_b, $alias_a]))->model_objects,
+ );
+
+ # Sort the ModelSet by the 'type' field in ascending order and ensure the order is correct
+ $this->assert_equals(
+ $modelset->sort(['type'])->model_objects,
+ (new ModelSet([$alias_a, $alias_c, $alias_b]))->model_objects,
+ );
+
+ # Sort the ModelSet by the 'type' field in descending order and ensure the order is correct
+ $this->assert_equals(
+ $modelset->sort(['type'], order: SORT_DESC)->model_objects,
+ (new ModelSet([$alias_b, $alias_c, $alias_a]))->model_objects,
+ );
+ }
+
+ /**
+ * Ensures multi-field sorts work as expected.
+ */
+ public function test_sort_multiple_fields(): void {
+ # Add DNS Resolver host overrides to test this
+ $host_1 = new DNSResolverHostOverride(host: 'a', domain: 'a.example.com', ip: ['127.0.0.1']);
+ $host_2 = new DNSResolverHostOverride(host: 'b', domain: 'a.example.com', ip: ['127.35.0.2']);
+ $host_3 = new DNSResolverHostOverride(host: 'c', domain: 'a.example.com', ip: ['127.0.8.3']);
+ $host_4 = new DNSResolverHostOverride(host: 'a', domain: 'b.example.com', ip: ['127.0.1.4']);
+ $host_5 = new DNSResolverHostOverride(host: 'b', domain: 'b.example.com', ip: ['127.5.1.7']);
+ $host_6 = new DNSResolverHostOverride(host: 'c', domain: 'b.example.com', ip: ['127.3.3.3']);
+ $host_7 = new DNSResolverHostOverride(host: 'a', domain: 'c.example.com', ip: ['127.0.4.2']);
+ $host_8 = new DNSResolverHostOverride(host: 'b', domain: 'c.example.com', ip: ['127.3.0.1']);
+ $host_9 = new DNSResolverHostOverride(host: 'c', domain: 'c.example.com', ip: ['127.5.1.3']);
+
+ # Create a ModelSet using the previously created Models in a random order
+ $modelset = new ModelSet([$host_1, $host_2, $host_3, $host_4, $host_5, $host_6, $host_7, $host_8, $host_9]);
+ shuffle($modelset->model_objects);
+
+ # Sort the ModelSet by the host and domain fields in ascending order and ensure the order is correct
+ $this->assert_equals($modelset->sort(['host', 'domain'])->model_objects, [
+ $host_1,
+ $host_4,
+ $host_7,
+ $host_2,
+ $host_5,
+ $host_8,
+ $host_3,
+ $host_6,
+ $host_9,
+ ]);
+
+ # Sort the ModelSet by the domain and host fields in descending order and ensure the order is correct
+ $this->assert_equals($modelset->sort(['domain', 'host'], order: SORT_DESC)->model_objects, [
+ $host_9,
+ $host_8,
+ $host_7,
+ $host_6,
+ $host_5,
+ $host_4,
+ $host_3,
+ $host_2,
+ $host_1,
+ ]);
+
+ # Sort the ModelSet by domain and ip fields in ascending order and ensure the order is correct
+ $this->assert_equals($modelset->sort(['domain', 'ip'])->model_objects, [
+ $host_1,
+ $host_3,
+ $host_2,
+ $host_4,
+ $host_6,
+ $host_5,
+ $host_7,
+ $host_8,
+ $host_9,
+ ]);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc
index 5db01883..e2d0d841 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc
@@ -8,6 +8,7 @@ use RESTAPI\Caches\RESTAPIVersionReleasesCache;
use RESTAPI\Core\Auth;
use RESTAPI\Core\Model;
use RESTAPI\Models\FirewallAlias;
+use RESTAPI\Models\InterfaceVLAN;
use RESTAPI\Models\SystemStatus;
use RESTAPI\Models\Test;
@@ -982,4 +983,218 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase {
$this->assert_equals($deleted_aliases->count(), 6);
$this->assert_equals(FirewallAlias::read_all()->count(), 0);
}
+
+ /**
+ * Ensure the does_apply_immediately() method correctly determines if the Model object will apply changes
+ * immediately or requires a manual apply.
+ */
+ public function test_does_apply_immediately(): void {
+ # The FirewallAlias Model requires a manual apply via FirewallApply, ensure it returns false
+ $alias = new FirewallAlias();
+ $this->assert_is_false($alias->does_apply_immediately());
+
+ # Applies immediately isn't relevant for the SystemStatus Model, ensure it returns null
+ $status = new SystemStatus();
+ $this->assert_equals($status->does_apply_immediately(), null);
+
+ # The InterfaceVLAN Model automatically applies changes, ensure it returns true
+ $vlan = new InterfaceVLAN();
+ $this->assert_is_true($vlan->does_apply_immediately());
+ }
+
+ /**
+ * Ensures sorting via the Model's query() method works correctly.
+ */
+ public function test_query_sort(): void {
+ # Create FirewallAlias models to test this
+ $a_alias = new FirewallAlias(name: 'a_alias', type: 'network');
+ $a_alias->create();
+ $c_alias = new FirewallAlias(name: 'c_alias', type: 'port');
+ $c_alias->create();
+ $b_alias = new FirewallAlias(name: 'b_alias', type: 'host');
+ $b_alias->create();
+
+ # Ensure no sort_by field leaves the objects in their natural order
+ $unsorted_aliases = FirewallAlias::query();
+ $this->assert_equals($unsorted_aliases->model_objects[0]->name->value, 'a_alias');
+ $this->assert_equals($unsorted_aliases->model_objects[1]->name->value, 'c_alias');
+ $this->assert_equals($unsorted_aliases->model_objects[2]->name->value, 'b_alias');
+
+ # Query for all aliases and sort by name in ascending order
+ $sorted_aliases = FirewallAlias::query(sort_by: ['name'], sort_order: SORT_ASC);
+
+ # Ensure the aliases were sorted and their IDs were retained
+ $this->assert_equals($sorted_aliases->model_objects[0]->name->value, 'a_alias');
+ $this->assert_equals($sorted_aliases->model_objects[0]->id, 0);
+ $this->assert_equals($sorted_aliases->model_objects[1]->name->value, 'b_alias');
+ $this->assert_equals($sorted_aliases->model_objects[1]->id, 2);
+ $this->assert_equals($sorted_aliases->model_objects[2]->name->value, 'c_alias');
+ $this->assert_equals($sorted_aliases->model_objects[2]->id, 1);
+
+ # Query for all aliases and sort by type in descending order
+ $sorted_aliases = FirewallAlias::query(sort_by: ['type'], sort_order: SORT_DESC);
+
+ # Ensure the aliases were sorted and their IDs were retained
+ $this->assert_equals($sorted_aliases->model_objects[0]->type->value, 'port');
+ $this->assert_equals($sorted_aliases->model_objects[0]->id, 1);
+ $this->assert_equals($sorted_aliases->model_objects[1]->type->value, 'network');
+ $this->assert_equals($sorted_aliases->model_objects[1]->id, 0);
+ $this->assert_equals($sorted_aliases->model_objects[2]->type->value, 'host');
+ $this->assert_equals($sorted_aliases->model_objects[2]->id, 2);
+ }
+
+ /**
+ * Ensures the sort() method works correctly when creating a new Model object.
+ */
+ public function test_sort_create(): void {
+ # Create a few FirewallAlias models to test this
+ $alias_0 = new FirewallAlias(name: 'zzz', type: 'host');
+ $alias_1 = new FirewallAlias(name: 'ccc', type: 'host');
+ $alias_2 = new FirewallAlias(name: 'aaa', type: 'host');
+ $alias_3 = new FirewallAlias(name: 'bbb', type: 'host');
+ $alias_4 = new FirewallAlias(name: 'ddd', type: 'host');
+ $alias_5 = new FirewallAlias(name: 'fff', type: 'host');
+
+ # Create the first four aliases
+ $alias_0->create();
+ $alias_1->create();
+ $alias_2->create();
+ $alias_3->create();
+ $alias_4->create();
+
+ # Ensure the aliases are not sorted yet
+ $this->assert_equals(FirewallAlias::read_all()->to_representation(), [
+ $alias_0->to_representation(),
+ $alias_1->to_representation(),
+ $alias_2->to_representation(),
+ $alias_3->to_representation(),
+ $alias_4->to_representation(),
+ ]);
+
+ # Ensure the aliases are sorted by name when creating the fifth alias
+ $alias_5->sort_by = ['name'];
+ $alias_5->sort_order = SORT_ASC;
+ $alias_5->create();
+
+ # Ensure the aliases are now sorted
+ $this->assert_equals(FirewallAlias::read_all()->to_internal(), [
+ $alias_2->to_internal(),
+ $alias_3->to_internal(),
+ $alias_1->to_internal(),
+ $alias_4->to_internal(),
+ $alias_5->to_internal(),
+ $alias_0->to_internal(),
+ ]);
+
+ # Delete all the aliases
+ FirewallAlias::delete_all();
+ }
+
+ /**
+ * Ensures the sort() method works correctly when updating an existing Model object.
+ */
+ public function test_sort_update(): void {
+ # Create a few FirewallAlias models to test this
+ $alias_0 = new FirewallAlias(name: 'zzz', type: 'host');
+ $alias_1 = new FirewallAlias(name: 'ccc', type: 'host');
+ $alias_2 = new FirewallAlias(name: 'aaa', type: 'host');
+ $alias_3 = new FirewallAlias(name: 'bbb', type: 'host');
+ $alias_4 = new FirewallAlias(name: 'ddd', type: 'host');
+ $alias_5 = new FirewallAlias(name: 'fff', type: 'host');
+
+ # Create the aliases
+ $alias_0->create();
+ $alias_1->create();
+ $alias_2->create();
+ $alias_3->create();
+ $alias_4->create();
+ $alias_5->create();
+
+ # Ensure the aliases are not sorted yet
+ $this->assert_equals(FirewallAlias::read_all()->to_representation(), [
+ $alias_0->to_representation(),
+ $alias_1->to_representation(),
+ $alias_2->to_representation(),
+ $alias_3->to_representation(),
+ $alias_4->to_representation(),
+ $alias_5->to_representation(),
+ ]);
+
+ # Ensure the aliases are sorted by name when updating the alias
+ $alias_2->sort_by = ['name'];
+ $alias_2->sort_order = SORT_ASC;
+
+ # Change the name value of $alias_2 and ensure it gets sorted correctly
+ $alias_2->name->value = 'yyy';
+ $alias_2->name->editable = true; # Allow the name to be updated for this test
+ $alias_2->update();
+ $this->assert_equals(FirewallAlias::read_all()->to_internal(), [
+ $alias_3->to_internal(),
+ $alias_1->to_internal(),
+ $alias_4->to_internal(),
+ $alias_5->to_internal(),
+ $alias_2->to_internal(),
+ $alias_0->to_internal(),
+ ]);
+
+ # Ensure $alias_2's ID was updated to reflect the new order
+ $this->assert_equals($alias_2->id, 4);
+
+ # Delete all the aliases
+ FirewallAlias::delete_all();
+ }
+
+ /**
+ * Ensure the sort() method works correctly when replacing all Model objects.
+ */
+ public function test_sort_replace_all(): void {
+ $alias = new FirewallAlias();
+ $alias->sort_by = ['name'];
+ $alias->sort_order = SORT_ASC;
+ $alias->replace_all([
+ ['name' => 'zzz', 'type' => 'host'],
+ ['name' => 'ccc', 'type' => 'host'],
+ ['name' => 'aaa', 'type' => 'host'],
+ ['name' => 'bbb', 'type' => 'host'],
+ ['name' => 'ddd', 'type' => 'host'],
+ ['name' => 'fff', 'type' => 'host'],
+ ]);
+
+ # Ensure the aliases are sorted by name
+ $this->assert_equals(FirewallAlias::read_all()->to_internal(), [
+ ['name' => 'aaa', 'type' => 'host', 'descr' => '', 'address' => '', 'detail' => ''],
+ ['name' => 'bbb', 'type' => 'host', 'descr' => '', 'address' => '', 'detail' => ''],
+ ['name' => 'ccc', 'type' => 'host', 'descr' => '', 'address' => '', 'detail' => ''],
+ ['name' => 'ddd', 'type' => 'host', 'descr' => '', 'address' => '', 'detail' => ''],
+ ['name' => 'fff', 'type' => 'host', 'descr' => '', 'address' => '', 'detail' => ''],
+ ['name' => 'zzz', 'type' => 'host', 'descr' => '', 'address' => '', 'detail' => ''],
+ ]);
+
+ # Delete all the aliases
+ FirewallAlias::delete_all();
+ }
+
+ /**
+ * Ensure an error is thrown if the write lock is not released after all write_config() attempts are exhausted.
+ */
+ public function test_write_lock_not_released(): void {
+ # Set the write lock
+ touch(Model::WRITE_LOCK_FILE);
+
+ # Ensure an error is thrown if the write lock is not released after all write_config() attempts are exhausted
+ $this->assert_throws_response(
+ response_id: 'MODEL_WRITE_CONFIG_LOCKED',
+ code: 503,
+ callable: function () {
+ # Create a new model object to test with
+ $test_model = new Model();
+
+ # Attempt to write the config
+ $test_model->write_config(change_note: 'test', attempts: 1);
+ },
+ );
+
+ # Remove the write lock
+ unlink(Model::WRITE_LOCK_FILE);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreResponseTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreResponseTestCase.inc
index 1b555077..4e9ab0fd 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreResponseTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreResponseTestCase.inc
@@ -66,25 +66,6 @@ class APICoreResponseTestCase extends RESTAPI\Core\TestCase {
]);
}
- /**
- * Checks that the Response object is correctly converted to its OpenAPI component representation when the
- * `to_openapi_component()` method is run.
- */
- public function test_response_to_openapi_component() {
- # Create a Response object to represent as an OpenAPI component.
- $response = new RESTAPI\Core\Response(message: 'Test message', response_id: 'TEST_RESPONSE_ID');
- $response->help_text = 'Example OpenAPI description for this response component.';
-
- $this->assert_equals($response->to_openapi_component(), [
- 'description' => 'Example OpenAPI description for this response component.',
- 'content' => [
- 'application/json' => [
- 'schema' => ['$ref' => '#/components/schemas/Response'],
- ],
- ],
- ]);
- }
-
/**
* Checks that the Response object is correctly converted it its OpenAPI schema representation when the
* `to_openapi_schema()` method is run.
@@ -100,10 +81,12 @@ class APICoreResponseTestCase extends RESTAPI\Core\TestCase {
'code' => [
'description' => 'The HTTP status code that corresponds with the API response.',
'type' => 'integer',
+ 'default' => $response->code,
],
'status' => [
'description' => 'The HTTP status message that corresponds with the HTTP status code.',
'type' => 'string',
+ 'default' => $response->status,
],
'response_id' => [
'description' =>
@@ -120,7 +103,6 @@ class APICoreResponseTestCase extends RESTAPI\Core\TestCase {
'The data requested from the API. In the event that many objects have' .
'been requested, this field will be an array of objects. Otherwise, it will only return' .
'the single object requested.',
- 'example' => [],
'oneOf' => [['type' => 'array', 'items' => ['type' => 'object']], ['type' => 'object']],
],
'_links' => [
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreSchemaTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreSchemaTestCase.inc
new file mode 100644
index 00000000..090b1a0c
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreSchemaTestCase.inc
@@ -0,0 +1,62 @@
+assert_equals($schema->file_path, $schema::SCHEMA_DIR . $schema->file_name);
+ }
+
+ /**
+ * Ensure the schema is saved to file when the save_schema method is called.
+ */
+ public function test_save_schema(): void {
+ # Use the GraphQLSchema for this since Schema is an abstract class.
+ $schema = new GraphQLSchema();
+
+ # Remove the schema file if it exists.
+ unlink($schema->file_path);
+ $schema->save_schema();
+ $this->assert_is_true(file_exists($schema->file_path));
+ }
+
+ /**
+ * Ensure we can read the schema from file.
+ */
+ public function test_read_schema_from_file(): void {
+ # Use the GraphQLSchema for this since Schema is an abstract class.
+ $schema = new GraphQLSchema();
+
+ # Ensure the schema file exists.
+ $schema->save_schema();
+ $this->assert_is_true(file_exists($schema->file_path));
+
+ # Ensure the schema is read from file.
+ $this->assert_is_not_empty($schema->read_schema_from_file());
+ }
+
+ /**
+ * Ensure the schema URL is built correctly.
+ */
+ public function test_build_schema_url(): void {
+ # Use the GraphQLSchema for this since Schema is an abstract class.
+ $schema = new GraphQLSchema();
+
+ # Remove the schema file if it exists.
+ unlink($schema->file_path);
+ $schema->save_schema();
+ $schema->build_schema_url();
+ $this->assert_is_true(
+ file_exists(
+ '/usr/local/www/api/v2/schema/' . pathinfo($schema->file_name, PATHINFO_FILENAME) . '/index.php',
+ ),
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc
index e709a1e2..00779f69 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc
@@ -7,12 +7,13 @@ require_once 'RESTAPI/autoloader.inc';
use RESTAPI\Core\TestCase;
use function RESTAPI\Core\Tools\bandwidth_to_bits;
use function RESTAPI\Core\Tools\is_assoc_array;
+use function RESTAPI\Core\Tools\to_upper_camel_case;
class APICoreToolsTestCase extends TestCase {
/**
* Checks that the is_assoc_array() function correctly identifies associative arrays from indexed arrays.
*/
- public function test_is_assoc_array() {
+ public function test_is_assoc_array(): void {
$this->assert_is_true(is_assoc_array(['test' => 'this is an associative array']));
$this->assert_is_false(is_assoc_array([0 => 'this is not an associative array']));
}
@@ -21,7 +22,7 @@ class APICoreToolsTestCase extends TestCase {
* Checks that the bandwidth_to_bits() function correctly converts a provided bandwidth and scale into the
* total number of bits of the bandwidth.
*/
- public function test_bandwidth_to_bits() {
+ public function test_bandwidth_to_bits(): void {
# Set scale factors we can use to convert various bandwidth values into total bit count
$gigabit_scale_factor = 1024 * 1024 * 1024;
$megabit_scale_factor = 1024 * 1024;
@@ -41,4 +42,14 @@ class APICoreToolsTestCase extends TestCase {
},
);
}
+
+ /**
+ * Checks that the to_upper_camel_case() function correctly converts a provided string into upper camel case.
+ */
+ public function test_to_upper_camel_case(): void {
+ $this->assert_equals(to_upper_camel_case('this_is_a_test'), 'ThisIsATest');
+ $this->assert_equals(to_upper_camel_case('this-is-a-test'), 'ThisIsATest');
+ $this->assert_equals(to_upper_camel_case('this is a test'), 'ThisIsATest');
+ $this->assert_equals(to_upper_camel_case('_this is-a test'), 'ThisIsATest');
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsGraphQLEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsGraphQLEndpointTestCase.inc
new file mode 100644
index 00000000..9695dbcb
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsGraphQLEndpointTestCase.inc
@@ -0,0 +1,60 @@
+assert_is_true($endpoint->response_handler($not_found_resp) instanceof GraphQLResponse);
+ $this->assert_is_true($endpoint->response_handler($bad_request_resp) instanceof GraphQLResponse);
+ $this->assert_is_true($endpoint->response_handler($success_resp) instanceof GraphQLResponse);
+ }
+
+ /**
+ * Make a POST request to /api/v2/graphql to ensure it works end-to-end.
+ */
+ public function test_endpoint_e2e(): void {
+ $json_resp = \RESTAPI\Core\Tools\http_request(
+ # Make a POST request to the GraphQL endpoint to read the system hostname.
+ url: 'https://localhost/api/v2/graphql',
+ method: 'POST',
+ data: ['query' => '{ readSystemHostname { hostname domain } }'],
+ headers: ['Content-Type' => 'application/json'],
+ username: 'admin',
+ password: 'pfsense',
+ validate_certs: false,
+ );
+ $resp = json_decode($json_resp, associative: true);
+
+ # Ensure the response is successful and contains the expected data.
+ $hostname = new SystemHostname();
+ $this->assert_equals($resp, [
+ 'data' => [
+ 'readSystemHostname' => [
+ 'hostname' => $hostname->hostname->value,
+ 'domain' => $hostname->domain->value,
+ ],
+ ],
+ ]);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsNestedModelFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsNestedModelFieldTestCase.inc
index 05e40e90..966dba2a 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsNestedModelFieldTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsNestedModelFieldTestCase.inc
@@ -136,14 +136,14 @@ class APIFieldsNestedModelFieldTestCase extends TestCase {
/**
* Ensures the `sort()` method correctly sorts values based on the nested Model's sort criteria.
*/
- public function test_sort() {
+ public function test_sort(): void {
# Create a NestedModelObject to test with.
$field = new NestedModelField('FirewallAlias', required: true);
$field->name = 'test';
# Assign the nested Model sort criteria for testing - by field `name` in alphabetical order
- $field->model->sort_by_field = 'name';
- $field->model->sort_option = SORT_ASC;
+ $field->model->sort_by = ['name'];
+ $field->model->sort_order = SORT_ASC;
# Set the value to an array of alias objects that are out of order
$field->value = [
@@ -153,23 +153,26 @@ class APIFieldsNestedModelFieldTestCase extends TestCase {
['name' => 'bbb', 'type' => 'network'],
];
+ # Ensure we validate the value to populate the ModelSet
+ $field->validate();
+
# Sort the values and ensure they are now placed in alphabetical order
$field->sort();
$this->assert_equals($field->value, [
- ['name' => 'aaa', 'type' => 'port'],
- ['name' => 'bbb', 'type' => 'network'],
- ['name' => 'ccc', 'type' => 'host'],
- ['name' => 'ddd', 'type' => 'host'],
+ ['id' => 0, 'name' => 'aaa', 'type' => 'port', 'descr' => '', 'address' => [], 'detail' => []],
+ ['id' => 1, 'name' => 'bbb', 'type' => 'network', 'descr' => '', 'address' => [], 'detail' => []],
+ ['id' => 2, 'name' => 'ccc', 'type' => 'host', 'descr' => '', 'address' => [], 'detail' => []],
+ ['id' => 3, 'name' => 'ddd', 'type' => 'host', 'descr' => '', 'address' => [], 'detail' => []],
]);
# Sort again but this time in descending order
- $field->model->sort_option = SORT_DESC;
+ $field->model->sort_order = SORT_DESC;
$field->sort();
$this->assert_equals($field->value, [
- ['name' => 'ddd', 'type' => 'host'],
- ['name' => 'ccc', 'type' => 'host'],
- ['name' => 'bbb', 'type' => 'network'],
- ['name' => 'aaa', 'type' => 'port'],
+ ['id' => 0, 'name' => 'ddd', 'type' => 'host', 'descr' => '', 'address' => [], 'detail' => []],
+ ['id' => 1, 'name' => 'ccc', 'type' => 'host', 'descr' => '', 'address' => [], 'detail' => []],
+ ['id' => 2, 'name' => 'bbb', 'type' => 'network', 'descr' => '', 'address' => [], 'detail' => []],
+ ['id' => 3, 'name' => 'aaa', 'type' => 'port', 'descr' => '', 'address' => [], 'detail' => []],
]);
}
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsObjectFieldTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsObjectFieldTestCase.inc
new file mode 100644
index 00000000..2edb58ee
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIFieldsObjectFieldTestCase.inc
@@ -0,0 +1,46 @@
+assert_throws_response(
+ response_id: 'OBJECT_FIELD_INVALID_VALUE',
+ code: 400,
+ callable: function () use ($field) {
+ $field->validate_extra([0, 1, 2]);
+ },
+ );
+
+ # Ensure good values do not throw an exception
+ $this->assert_does_not_throw(
+ callable: function () use ($field) {
+ $field->validate_extra(['good' => 'value']);
+ $field->validate_extra(['another' => ['good' => 'value']]);
+ },
+ );
+ }
+
+ /**
+ * Ensure that the from_internal() method simply returns the array value as is
+ */
+ public function test_from_internal(): void {
+ # Variables
+ $field = new ObjectField();
+ $field->from_internal(['good' => ['value']]);
+
+ # Ensure the value is returned as is
+ $this->assert_equals(['good' => ['value']], $field->value);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIGraphQLResolverTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIGraphQLResolverTestCase.inc
new file mode 100644
index 00000000..8a080f3e
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIGraphQLResolverTestCase.inc
@@ -0,0 +1,625 @@
+auth = new Auth();
+ $this->auth->username = 'admin';
+
+ # First, create several new FirewallAlias models to query.
+ $alias = new FirewallAlias();
+ $alias->replace_all([
+ [
+ 'name' => 'test1',
+ 'type' => 'host',
+ 'descr' => 'Test alias 1',
+ 'address' => ['1.2.3.4'],
+ 'detail' => ['test detail 1'],
+ ],
+ [
+ 'name' => 'test2',
+ 'type' => 'network',
+ 'descr' => 'Test alias 2',
+ 'address' => ['1.2.3.4/32'],
+ 'detail' => ['test detail 2'],
+ ],
+ [
+ 'name' => 'test3',
+ 'type' => 'port',
+ 'descr' => 'Test alias 3',
+ 'address' => ['80', '443'],
+ 'detail' => ['test detail 3'],
+ ],
+ ]);
+ }
+
+ /**
+ * Clean up FirewallAlias models after testing.
+ */
+ public function teardown(): void {
+ # Clean up the test data.
+ $alias = new FirewallAlias();
+ $alias->delete_all();
+ }
+
+ /**
+ * Ensure the 'query' resolver correctly queries Model objects
+ */
+ public function test_query_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->query(
+ root: null,
+ args: [
+ 'query_params' => ['name' => 'test2'],
+ 'limit' => 0,
+ 'offset' => 0,
+ 'reverse' => false,
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the query results are as expected
+ $this->assert_equals($result[0], [
+ 'id' => 1,
+ 'name' => 'test2',
+ 'type' => 'network',
+ 'descr' => 'Test alias 2',
+ 'address' => ['1.2.3.4/32'],
+ 'detail' => ['test detail 2'],
+ ]);
+ }
+
+ /**
+ * Ensure the 'read' resolver correctly reads Model objects
+ */
+ public function test_read_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->read(
+ root: null,
+ args: [
+ 'id' => 2,
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the read results are as expected
+ $this->assert_equals($result, [
+ 'id' => 2,
+ 'name' => 'test3',
+ 'type' => 'port',
+ 'descr' => 'Test alias 3',
+ 'address' => ['80', '443'],
+ 'detail' => ['test detail 3'],
+ ]);
+ }
+
+ /**
+ * Ensure the 'create' resolver correctly creates Model objects
+ */
+ public function test_create_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->create(
+ root: null,
+ args: [
+ 'name' => 'test4',
+ 'type' => 'host',
+ 'descr' => 'Test alias 4',
+ 'address' => ['4.3.2.1'],
+ 'detail' => ['test detail 4'],
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the created object is as expected
+ $this->assert_equals($result, [
+ 'id' => 3,
+ 'name' => 'test4',
+ 'type' => 'host',
+ 'descr' => 'Test alias 4',
+ 'address' => ['4.3.2.1'],
+ 'detail' => ['test detail 4'],
+ ]);
+
+ # Ensure the created object is in the config
+ $this->assert_is_true($resolver->model->query(name: 'test4')->exists());
+ }
+
+ /**
+ * Ensure the 'update' resolver correctly updates Model objects
+ */
+ public function test_update_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->update(
+ root: null,
+ args: [
+ 'id' => 1,
+ 'type' => 'host',
+ 'descr' => 'Test alias 5',
+ 'address' => ['1.2.3.4'],
+ 'detail' => ['test detail 5'],
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the updated object is as expected
+ $this->assert_equals($result, [
+ 'id' => 1,
+ 'name' => 'test2',
+ 'type' => 'host',
+ 'descr' => 'Test alias 5',
+ 'address' => ['1.2.3.4'],
+ 'detail' => ['test detail 5'],
+ ]);
+
+ # Ensure the updated object is in the config
+ $this->assert_is_true($resolver->model->query(descr: 'Test alias 5')->exists());
+ }
+
+ /**
+ * Ensure the 'delete' resolver correctly deletes Model objects
+ */
+ public function test_delete_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->delete(
+ root: null,
+ args: [
+ 'id' => 2,
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the deleted object is as expected
+ $this->assert_equals($result, [
+ 'id' => 2,
+ 'name' => 'test3',
+ 'type' => 'port',
+ 'descr' => 'Test alias 3',
+ 'address' => ['80', '443'],
+ 'detail' => ['test detail 3'],
+ ]);
+
+ # Ensure the deleted object is not in the config
+ $this->assert_is_false($resolver->model->query(name: 'test3')->exists());
+ }
+
+ /**
+ * Ensure the 'replace_all' resolver correctly replaces all Model objects
+ */
+ public function test_replace_all_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->replace_all(
+ root: null,
+ args: [
+ 'objects' => [
+ [
+ 'name' => 'test5',
+ 'type' => 'host',
+ 'descr' => 'Test alias 5',
+ 'address' => ['5.5.5.5'],
+ 'detail' => ['test detail 5'],
+ ],
+ [
+ 'name' => 'test6',
+ 'type' => 'network',
+ 'descr' => 'Test alias 6',
+ 'address' => ['6.6.6.6/6'],
+ 'detail' => ['test detail 6'],
+ ],
+ [
+ 'name' => 'test7',
+ 'type' => 'port',
+ 'descr' => 'Test alias 7',
+ 'address' => ['7', '77'],
+ 'detail' => ['test detail 7'],
+ ],
+ ],
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the replaced objects are as expected
+ $this->assert_equals($resolver->model->read_all()->count(), 3);
+ $this->assert_is_true($resolver->model->query(name: 'test5')->exists());
+ $this->assert_is_true($resolver->model->query(name: 'test6')->exists());
+ $this->assert_is_true($resolver->model->query(name: 'test7')->exists());
+ }
+
+ /**
+ * Ensure the 'delete_many' resolver correctly deletes multiple Model objects
+ */
+ public function test_delete_many_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->delete_many(
+ root: null,
+ args: [
+ 'query_params' => ['name' => 'test1'],
+ 'limit' => 0,
+ 'offset' => 0,
+ 'reverse' => false,
+ ],
+ context: ['auth' => $this->auth],
+ info: null,
+ );
+
+ # Ensure the deleted objects are as expected
+ $this->assert_is_false($resolver->model->query(name: 'test1')->exists());
+ }
+
+ /**
+ * Ensure the 'delete_all' resolver correctly deletes all Model objects
+ */
+ public function test_delete_all_resolver(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+ $result = $resolver->delete_all(root: null, args: [], context: ['auth' => $this->auth], info: null);
+
+ # Ensure the deleted objects are as expected
+ $this->assert_equals($resolver->model->read_all()->count(), 0);
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the query resolver
+ */
+ public function test_check_query_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_query_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot query the model without an allowed privilege when running the query method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->query(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the query resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'query', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can query the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the query resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'query', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the read resolver
+ */
+ public function test_check_read_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_read_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot read the model without an allowed privilege when running the read method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->read(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the read resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'read', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can read the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the read resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'read', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the create resolver
+ */
+ public function test_check_create_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_create_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot create the model without an allowed privilege when running the create method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->create(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the create resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'create', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can create the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the create resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'create', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the update resolver
+ */
+ public function test_check_update_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_update_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot update the model without an allowed privilege when running the update method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->update(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the update resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'update', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can update the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the update resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'update', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the delete resolver
+ */
+ public function test_check_delete_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_delete_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot delete the model without an allowed privilege when running the delete method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->delete(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the delete resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'delete', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can delete the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the delete resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'delete', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the replace_all resolver
+ */
+ public function test_check_replace_all_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_replace_all_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot replace_all the model without an allowed privilege when running the replace_all method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->replace_all(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the replace_all resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'replace_all', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can replace_all the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the replace_all resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'replace_all', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the delete_many resolver
+ */
+ public function test_check_delete_many_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_delete_many_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot delete_many the model without an allowed privilege when running the delete_many method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->delete_many(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the delete_many resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'delete_many', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can delete_many the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the delete_many resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'delete_many', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+
+ /**
+ * Ensure the 'check_privs' method correctly checks privileges for the delete_all resolver
+ */
+ public function test_check_delete_all_privilege(): void {
+ $resolver = new Resolver(model: new FirewallAlias());
+
+ # Create a user we can use for testing
+ $user = new User(name: 'test_check_delete_all_privilege', password: 'testpass', priv: []);
+ $user->create();
+ $auth = new Auth();
+ $auth->username = $user->name->value;
+
+ # Ensure we cannot delete_all the model without an allowed privilege when running the delete_all method
+ $this->assert_throws(
+ exceptions: ['GraphQL\Error\Error'],
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->delete_all(root: null, args: [], context: ['auth' => $auth], info: null);
+ },
+ );
+
+ # Ensure we cannot call check_privs for the delete_all resolver without an allowed privilege
+ $this->assert_throws_response(
+ response_id: 'GRAPHQL_RESOLVER_UNAUTHORIZED',
+ code: 403,
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'delete_all', auth: $auth);
+ },
+ );
+
+ # Use the admin user to ensure we can delete_all the model with an allowed privilege
+ $auth->username = 'admin';
+
+ # Ensure we can call check_privs for the delete_all resolver with an allowed privilege
+ $this->assert_does_not_throw(
+ callable: function () use ($resolver, $user, $auth) {
+ $resolver->check_privs(resolver: 'delete_all', auth: $auth);
+ },
+ );
+
+ # Delete the user
+ $user->delete();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc
index fdc8e16e..b9471aef 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsAPISettingsTestCase.inc
@@ -5,6 +5,7 @@ namespace RESTAPI\Tests;
use RESTAPI\Core\TestCase;
use RESTAPI\Models\FirewallRule;
use RESTAPI\Models\RESTAPISettings;
+use RESTAPI\Models\User;
use const RESTAPI\Models\API_SETTINGS_BACKUP_FILE_PATH;
use const RESTAPI\Models\API_SETTINGS_BACKUP_NOT_CONFIGURED;
use const RESTAPI\Models\API_SETTINGS_RESTORE_NO_BACKUP;
@@ -544,4 +545,26 @@ class APIModelsAPISettingsTestCase extends TestCase {
$rule = new FirewallRule(id: 0);
$this->assert_is_false(array_key_exists('_links', $rule->to_representation()));
}
+
+ /**
+ * Checks that the `expose_sensitive_fields` field successfully enables or disables exposing sensitive fields in
+ * representations.
+ */
+ public function test_expose_sensitive_fields(): void {
+ # Enable expose_sensitive_fields
+ $api_settings = new RESTAPISettings(expose_sensitive_fields: true);
+ $api_settings->update();
+
+ # Ensure sensitive fields now show in the Model's representation
+ $user = new User(id: 0);
+ $this->assert_is_not_empty($user->to_representation()['password']);
+
+ # Disable expose_sensitive_fields
+ $api_settings = new RESTAPISettings(expose_sensitive_fields: false);
+ $api_settings->update();
+
+ # Ensure sensitive fields are now hidden in the Model's representation
+ $user = new User(id: 0);
+ $this->assert_is_empty($user->to_representation()['password']);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDAccessListEntryTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDAccessListEntryTestCase.inc
new file mode 100644
index 00000000..5a480335
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDAccessListEntryTestCase.inc
@@ -0,0 +1,67 @@
+bind_acl = new BINDAccessList(
+ async: false,
+ name: 'test_acl',
+ description: 'A test access list.',
+ entries: [['value' => '127.0.0.1/32']],
+ );
+ $this->bind_acl->create(apply: true);
+ }
+
+ /**
+ * Tear down the test case by deleting the BIND access list.
+ */
+ public function teardown(): void {
+ $this->bind_acl->delete(apply: true);
+ }
+
+ /**
+ * Check that we can create, read, update and delete BIND access list entries.
+ */
+ public function test_crud(): void {
+ # Create a new access list entry
+ $bind_acl_entry = new BINDAccessListEntry(
+ parent_id: $this->bind_acl->id,
+ async: false,
+ value: '1.2.3.4/32',
+ description: 'A test entry.',
+ );
+ $bind_acl_entry->create(apply: true);
+
+ # Ensure the entry is present in the ACL within named.conf
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_contains($named_conf, '1.2.3.4/32');
+
+ # Update the access list entry
+ $bind_acl_entry->value->value = '4.3.2.1/24';
+ $bind_acl_entry->update(apply: true);
+
+ # Ensure the entry was updated
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_contains($named_conf, '4.3.2.1/24');
+ $this->assert_str_does_not_contain($named_conf, '1.2.3.4/32');
+
+ # Delete the access list entry
+ $bind_acl_entry->delete(apply: true);
+
+ # Ensure the entry was deleted
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_does_not_contain($named_conf, '4.3.2.1/24');
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDAccessListTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDAccessListTestCase.inc
new file mode 100644
index 00000000..b15be7e2
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDAccessListTestCase.inc
@@ -0,0 +1,53 @@
+ '1.2.3.4/32', 'description' => 'A test entry.']],
+ );
+ $bind_acl->create(apply: true);
+
+ # Ensure we can read the access list
+ $acl_q = BINDAccessList::query(name: 'test_acl');
+ $this->assert_is_true($acl_q->exists());
+ $this->assert_equals($acl_q->first()->name->value, 'test_acl');
+ $this->assert_equals($acl_q->first()->description->value, 'A test access list.');
+ $this->assert_equals($acl_q->first()->entries->value[0]['value'], '1.2.3.4/32');
+ $this->assert_equals($acl_q->first()->entries->value[0]['description'], 'A test entry.');
+
+ # Ensure the ACL is configured in the named.conf file
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_contains($named_conf, 'acl "test_acl" {');
+ $this->assert_str_contains($named_conf, '1.2.3.4/32;');
+
+ # Update the access list
+ $bind_acl->description->value = 'An updated test access list.';
+ $bind_acl->entries->value = [['value' => '4.3.2.1/24', 'description' => 'An updated test entry.']];
+ $bind_acl->update(apply: true);
+
+ # Ensure the access list was updated
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_contains($named_conf, '4.3.2.1/24');
+
+ # Delete the access list
+ $bind_acl->delete(apply: true);
+
+ # Ensure the access list was deleted
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_does_not_contain($named_conf, 'test_acl');
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSettingsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSettingsTestCase.inc
new file mode 100644
index 00000000..1f6c4723
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSettingsTestCase.inc
@@ -0,0 +1,249 @@
+update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'listen-on port 53 { any; };',
+ );
+
+ # Change 'listenon' to 'lan' and ensure it is correctly represented in the BIND configuration
+ $bind_settings->listenon->value = ['lan'];
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'listen-on port 53 { 192.168.1.1; };',
+ );
+ }
+
+ /**
+ * Checks that the bind_notify field correctly controls the 'notify' settings in the BIND configuration.
+ */
+ public function test_bind_notify(): void {
+ $bind_settings = new BINDSettings(bind_notify: true, async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'notify yes;',
+ );
+
+ $bind_settings->bind_notify->value = false;
+ $bind_settings->update(apply: true);
+ $this->assert_str_does_not_contain(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'notify yes;',
+ );
+ }
+
+ /**
+ * Checks that the 'bind_hide_version' field correctly controls the 'version' settings in the BIND configuration.
+ */
+ public function test_bind_hide_version(): void {
+ $bind_settings = new BINDSettings(bind_hide_version: true, async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'version none;',
+ );
+
+ $bind_settings->bind_hide_version->value = false;
+ $bind_settings->update(apply: true);
+ $this->assert_str_does_not_contain(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'version none;',
+ );
+ }
+
+ /**
+ * Checks that the 'bind_ram_limit' field correctly controls the 'max-cache-size' settings in the BIND configuration.
+ */
+ public function test_bind_ram_limit(): void {
+ $bind_settings = new BINDSettings(bind_ram_limit: '128M', async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'max-cache-size 128M;',
+ );
+
+ $bind_settings->bind_ram_limit->value = '256M';
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'max-cache-size 256M;',
+ );
+ }
+
+ /**
+ * Checks that the rate limit fields correctly control the 'rate-limit' settings in the BIND configuration.
+ */
+ public function test_rate_limit(): void {
+ # Ensure we can disabled rate limiting
+ $bind_settings = new BINDSettings(rate_enabled: false, async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_does_not_contain(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'rate-limit',
+ );
+
+ # Ensure we can enable rate limiting
+ $bind_settings->rate_enabled->value = true;
+ $bind_settings->rate_limit->value = 100;
+ $bind_settings->update(apply: true);
+ $bind_conf = (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output;
+ $this->assert_str_contains($bind_conf, 'rate-limit');
+ $this->assert_str_contains($bind_conf, 'responses-per-second 100;');
+
+ # Ensure we can enable log only rate limiting
+ $bind_settings->log_only->value = true;
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'log-only yes;',
+ );
+
+ # Ensure we can disable log only rate limiting
+ $bind_settings->log_only->value = false;
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'log-only no;',
+ );
+ }
+
+ /**
+ * Check that the forwarder fields correctly control the 'forwarders' settings in the BIND configuration.
+ */
+ public function test_forwarder(): void {
+ # Ensure we can disable forwarding
+ $bind_settings = new BINDSettings(bind_forwarder: false, async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_does_not_contain(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'forwarders',
+ );
+
+ # Ensure we can enable forwarding
+ $bind_settings->bind_forwarder->value = true;
+ $bind_settings->bind_forwarder_ips->value = ['127.0.0.1', '127.0.0.2'];
+ $bind_settings->update(apply: true);
+ $bind_conf = (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output;
+ $this->assert_str_contains($bind_conf, 'forwarders { 127.0.0.1;127.0.0.2; };');
+ }
+
+ /**
+ * Checks that the 'bind_dnssec_validation' field correctly controls the 'dnssec-validation' settings in the BIND configuration.
+ */
+ public function test_bind_dnssec_validation(): void {
+ $bind_settings = new BINDSettings(bind_dnssec_validation: 'auto', async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'dnssec-validation auto;',
+ );
+
+ $bind_settings->bind_dnssec_validation->value = 'on';
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'dnssec-validation yes;',
+ );
+
+ $bind_settings->bind_dnssec_validation->value = 'off';
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'dnssec-validation no;',
+ );
+ }
+
+ /**
+ * Checks that the 'listenport' field correctly controls the 'listen-on' settings in the BIND configuration.
+ */
+ public function test_listenport(): void {
+ $bind_settings = new BINDSettings(listenport: '5353', async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'listen-on port 5353',
+ );
+
+ $bind_settings->listenport->value = '53';
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'listen-on port 53',
+ );
+ }
+
+ /**
+ * Checks that the 'controlport' field correctly controls the 'controls' settings in the BIND configuration.
+ */
+ public function test_controlport(): void {
+ $bind_settings = new BINDSettings(controlport: '953', async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'inet 127.0.0.1 port 953',
+ );
+
+ $bind_settings->controlport->value = '8953';
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'inet 127.0.0.1 port 8953',
+ );
+ }
+
+ /**
+ * Checks that the 'bind_custom_options' field correctly controls the 'options' settings in the BIND configuration.
+ */
+ public function test_bind_custom_options(): void {
+ $bind_settings = new BINDSettings(bind_custom_options: 'example custom option;', async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'example custom option;',
+ );
+
+ $bind_settings->bind_custom_options->value = '';
+ $bind_settings->update(apply: true);
+ $this->assert_str_does_not_contain(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'example custom option;',
+ );
+ }
+
+ /**
+ * Checks that the 'bind_global_settings' field correctly controls the 'options' settings in the BIND configuration.
+ */
+ public function test_bind_global_settings(): void {
+ $bind_settings = new BINDSettings(bind_global_settings: 'example global setting;', async: false);
+ $bind_settings->update(apply: true);
+ $this->assert_str_contains(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'example global setting;',
+ );
+
+ $bind_settings->bind_global_settings->value = '';
+ $bind_settings->update(apply: true);
+ $this->assert_str_does_not_contain(
+ (new Command('cat ' . self::BIND_CONFIG_PATH, trim_whitespace: true))->output,
+ 'example global setting;',
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSyncRemoteHostTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSyncRemoteHostTestCase.inc
new file mode 100644
index 00000000..fb9c9215
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSyncRemoteHostTestCase.inc
@@ -0,0 +1,38 @@
+create();
+
+ # Update the remote host
+ $remote_host->syncdestinenable->value = false;
+ $remote_host->syncprotocol->value = 'http';
+ $remote_host->ipaddress->value = '127.0.0.2';
+ $remote_host->syncport->value = '80';
+ $remote_host->username->value = 'admin2';
+ $remote_host->password->value = 'pfsense2';
+ $remote_host->update();
+
+ # Delete the remote host
+ $remote_host->delete();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSyncSettingsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSyncSettingsTestCase.inc
new file mode 100644
index 00000000..d49b0770
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDSyncSettingsTestCase.inc
@@ -0,0 +1,22 @@
+assert_does_not_throw(
+ callable: function () {
+ $sync = new BINDSyncSettings(synconchanges: 'manual', synctimeout: 60, masterip: '1.2.3.4');
+ $sync->update();
+ },
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDViewTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDViewTestCase.inc
new file mode 100644
index 00000000..f3085688
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDViewTestCase.inc
@@ -0,0 +1,58 @@
+create(apply: true);
+
+ # Ensure the view was created in named.conf
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_contains($named_conf, 'view "test_view" {');
+ $this->assert_str_contains($named_conf, 'recursion yes;');
+ $this->assert_str_contains($named_conf, 'match-clients { any; };');
+ $this->assert_str_contains($named_conf, 'allow-recursion { localhost; };');
+ $this->assert_str_contains($named_conf, 'example_option value;');
+
+ # Update the view
+ $view->name->value = 'test_view_updated';
+ $view->recursion->value = false;
+ $view->match_clients->value = ['localhost'];
+ $view->allow_recursion->value = ['any'];
+ $view->bind_custom_options->value = 'example_option updated;';
+ $view->update(apply: true);
+
+ # Ensure the view was updated in named.conf
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_contains($named_conf, 'view "test_view_updated" {');
+ $this->assert_str_does_not_contain($named_conf, 'view "test_view" {');
+ $this->assert_str_contains($named_conf, 'recursion no;');
+ $this->assert_str_contains($named_conf, 'match-clients { localhost; };');
+ $this->assert_str_contains($named_conf, 'allow-recursion { any; };');
+ $this->assert_str_contains($named_conf, 'example_option updated;');
+
+ # Delete the view
+ $view->delete(apply: true);
+
+ # Ensure the view was deleted from named.conf
+ $named_conf = file_get_contents('/var/etc/named/etc/namedb/named.conf');
+ $this->assert_str_does_not_contain($named_conf, 'view "test_view_updated" {');
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDZoneRecordTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDZoneRecordTestCase.inc
new file mode 100644
index 00000000..c52c89ed
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDZoneRecordTestCase.inc
@@ -0,0 +1,130 @@
+view = new BINDView(
+ name: 'test_view',
+ recursion: true,
+ match_clients: ['any'],
+ allow_recursion: ['any'],
+ async: false,
+ );
+ $this->view->create(apply: true);
+
+ $this->zone = new BINDZone(
+ async: false,
+ name: 'example.com',
+ description: 'A test zone.',
+ type: 'master',
+ view: [$this->view->name->value],
+ serial: 123456,
+ baseip: '1.2.3.4',
+ nameserver: 'ns1.example.com',
+ mail: 'admin.example.com',
+ records: [['name' => 'example.com.', 'type' => 'A', 'rdata' => '127.0.0.1']],
+ );
+ $this->zone->create(apply: true);
+ }
+
+ /**
+ * Tear down the test case by deleting the view and zone.
+ */
+ public function teardown(): void {
+ $this->zone->delete(apply: true);
+ $this->view->delete(apply: true);
+ }
+
+ /**
+ * Ensure an error is thrown when the parent zone is reverse zone and the type is not PTR or NS.
+ */
+ public function test_reverse_zone_type(): void {
+ # Update the zone to be a reverse zone
+ $this->zone->reversev4->value = true;
+ $this->zone->update(apply: true);
+
+ # Create a new zone record with an invalid type and ensure an error is thrown
+ $this->assert_throws_response(
+ response_id: 'BIND_ZONE_RECORD_INVALID_REVERSE_TYPE',
+ code: 400,
+ callable: function () {
+ $record = new BINDZoneRecord(
+ parent_id: $this->zone->id,
+ name: 'in-addr.arpa.4.3.2.1',
+ type: 'A',
+ rdata: '1.2.3.4',
+ );
+ $record->validate();
+ },
+ );
+
+ # Create a new zone record with a valid type and ensure no error is thrown
+ $this->assert_does_not_throw(
+ callable: function () {
+ $record = new BINDZoneRecord(
+ parent_id: $this->zone->id,
+ name: 'in-addr.arpa.4.3.2.1',
+ type: 'PTR',
+ rdata: 'ptr.example.com',
+ );
+ $record->validate();
+ },
+ );
+
+ # Update the zone to remove the reverse zone flag
+ $this->zone->reversev4->value = false;
+ $this->zone->update(apply: true);
+ }
+
+ /**
+ * Ensure we can create, update, read and delete a zone
+ */
+ public function test_crud(): void {
+ # Create a new zone record
+ $record = new BINDZoneRecord(
+ parent_id: $this->zone->id,
+ rdata: '127.0.0.1',
+ name: 'a.example.com.',
+ type: 'A',
+ async: false,
+ );
+ $record->create(apply: true);
+
+ # Read the zone file and ensure the record was created
+ $zone_db = new Command('cat ' . self::NAMED_PATH . 'master/test_view/example.com.DB', trim_whitespace: true);
+ $this->assert_str_contains($zone_db->output, 'a.example.com. IN A 127.0.0.1');
+
+ # Update the zone record
+ $record->name->value = 'b.example.com.';
+ $record->type->value = 'CNAME';
+ $record->rdata->value = 'cname.example.com';
+ $record->update(apply: true);
+
+ # Read the zone file and ensure the record was updated
+ $zone_db = new Command('cat ' . self::NAMED_PATH . 'master/test_view/example.com.DB', trim_whitespace: true);
+ $this->assert_str_contains($zone_db->output, 'b.example.com. IN CNAME cname.example.com');
+ $this->assert_str_does_not_contain($zone_db->output, 'a.example.com. IN A 127.0.0.1');
+
+ # Delete the zone record
+ $record->delete(apply: true);
+
+ # Read the zone file and ensure the record was deleted
+ $zone_db = new Command('cat ' . self::NAMED_PATH . 'master/test_view/example.com.DB', trim_whitespace: true);
+ $this->assert_str_does_not_contain($zone_db->output, 'b.example.com. IN CNAME cname.example.com');
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDZoneTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDZoneTestCase.inc
new file mode 100644
index 00000000..ee65c131
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsBINDZoneTestCase.inc
@@ -0,0 +1,135 @@
+view = new BINDView(
+ name: 'test_view',
+ recursion: true,
+ match_clients: ['any'],
+ allow_recursion: ['any'],
+ async: false,
+ );
+ $this->view->create(apply: true);
+ }
+
+ /**
+ * Tear down the test case by deleting the view.
+ */
+ public function teardown(): void {
+ $this->view->delete(apply: true);
+ }
+
+ /**
+ * Ensure we can create, update, read and delete a zone
+ */
+ public function test_crud(): void {
+ # Create a new master zone
+ $zone = new BINDZone(
+ async: false,
+ name: 'test.zone.example.com',
+ description: 'A test zone.',
+ type: 'master',
+ view: [$this->view->name->value],
+ ttl: 321,
+ baseip: '1.2.3.4',
+ nameserver: 'ns1.example.com',
+ mail: 'admin.example.com',
+ serial: 123456,
+ refresh: 3605,
+ retry: 605,
+ expire: 86405,
+ minimum: 2605,
+ records: [
+ ['name' => 'a.example.com.', 'type' => 'A', 'rdata' => '4.3.2.1'],
+ ['name' => 'mx.example.com.', 'type' => 'MX', 'rdata' => 'mail.example.com.', 'priority' => 5],
+ ],
+ );
+ $zone->create(apply: true);
+
+ # Read the named.conf and zone file
+ $named_conf = file_get_contents(self::NAMED_PATH . 'named.conf');
+ $zone_db = new Command(
+ 'cat ' . self::NAMED_PATH . 'master/test_view/test.zone.example.com.DB',
+ trim_whitespace: true,
+ );
+
+ # Ensure the zone was created in named.conf and the zone file has expected values
+ $this->assert_str_contains($named_conf, 'zone "test.zone.example.com" {');
+ $this->assert_str_contains($named_conf, 'type master;');
+ $this->assert_str_contains($zone_db->output, '$TTL 321');
+ $this->assert_str_contains($zone_db->output, '123456 ; serial');
+ $this->assert_str_contains($zone_db->output, '3605 ; refresh');
+ $this->assert_str_contains($zone_db->output, '605 ; retry');
+ $this->assert_str_contains($zone_db->output, '86405 ; expire');
+ $this->assert_str_contains($zone_db->output, '2605 ; default_ttl');
+ $this->assert_str_contains($zone_db->output, 'a.example.com. IN A 4.3.2.1');
+ $this->assert_str_contains($zone_db->output, 'mx.example.com. IN MX 5 mail.example.com.');
+ $this->assert_str_contains(
+ $zone_db->output,
+ 'test.zone.example.com. IN SOA ns1.example.com. admin.example.com.',
+ );
+
+ # Update the zone
+ $zone->name->value = 'new.zone.example.com';
+ $zone->description->value = 'An updated test zone.';
+ $zone->type->value = 'master';
+ $zone->ttl->value = 123;
+ $zone->baseip->value = '127.0.0.53';
+ $zone->nameserver->value = 'ns2.example.com';
+ $zone->mail->value = 'admin2.example.com';
+ $zone->serial->value = 654321;
+ $zone->refresh->value = 3606;
+ $zone->retry->value = 606;
+ $zone->expire->value = 86406;
+ $zone->minimum->value = 2606;
+ $zone->records->value = [
+ ['name' => 'a2.example.com.', 'type' => 'A', 'rdata' => '5.5.5.5'],
+ ['name' => 'mx2.example.com.', 'type' => 'MX', 'rdata' => 'mail2.example.com.', 'priority' => 15],
+ ];
+ $zone->update(apply: true);
+
+ # Read the named.conf and zone file
+ $named_conf = file_get_contents(self::NAMED_PATH . 'named.conf');
+ $zone_db = new Command(
+ 'cat ' . self::NAMED_PATH . 'master/test_view/new.zone.example.com.DB',
+ trim_whitespace: true,
+ );
+
+ # Ensure the zone was updated in named.conf and the zone file has expected values
+ $this->assert_str_contains($named_conf, 'zone "new.zone.example.com" {');
+ $this->assert_str_does_not_contain($named_conf, 'zone "test.zone.example.com" {');
+ $this->assert_str_contains($zone_db->output, '$TTL 123');
+ $this->assert_str_contains($zone_db->output, '654321 ; serial');
+ $this->assert_str_contains($zone_db->output, '3606 ; refresh');
+ $this->assert_str_contains($zone_db->output, '606 ; retry');
+ $this->assert_str_contains($zone_db->output, '86406 ; expire');
+ $this->assert_str_contains($zone_db->output, '2606 ; default_ttl');
+ $this->assert_str_contains($zone_db->output, 'a2.example.com. IN A 5.5.5.5');
+ $this->assert_str_contains($zone_db->output, 'mx2.example.com. IN MX 15 mail2.example.com.');
+ $this->assert_str_contains(
+ $zone_db->output,
+ 'new.zone.example.com. IN SOA ns2.example.com. admin2.example.com.',
+ );
+
+ # Delete the zone
+ $zone->delete(apply: true);
+
+ # Ensure the zone was deleted from named.conf and the zone file was removed
+ $named_conf = file_get_contents(self::NAMED_PATH . 'named.conf');
+ $this->assert_str_does_not_contain($named_conf, 'zone "new.zone.example.com" {');
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateAuthorityGenerateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateAuthorityGenerateTestCase.inc
new file mode 100644
index 00000000..a3789e07
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateAuthorityGenerateTestCase.inc
@@ -0,0 +1,182 @@
+always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $ca->create();
+
+ # Ensure we have a matching keypair
+ $this->assert_is_not_empty($ca->crt->value);
+ $this->assert_is_not_empty($ca->prv->value);
+ $this->assert_is_true(X509Validator::is_matching_keypair($ca->crt->value, $ca->prv->value));
+
+ # Ensure our RSA key length is correct
+ $prv = openssl_pkey_get_private($ca->prv->value);
+ $key_details = openssl_pkey_get_details($prv);
+ $this->assert_is_true($key_details['type'] === OPENSSL_KEYTYPE_RSA);
+ $this->assert_is_true($key_details['bits'] === 2048);
+
+ # Cleanup the CA
+ $ca->delete();
+ }
+
+ /**
+ * Ensure we can generate a new Certificate Authority with an ECDSA key.
+ */
+ public function test_generate_ecdsa_certificate_authority(): void {
+ $ca = new CertificateAuthorityGenerate(
+ descr: 'test_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'ECDSA',
+ ecname: 'prime256v1',
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $ca->always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $ca->create();
+
+ # Ensure we have a matching keypair
+ $this->assert_is_not_empty($ca->crt->value);
+ $this->assert_is_not_empty($ca->prv->value);
+ $this->assert_is_true(X509Validator::is_matching_keypair($ca->crt->value, $ca->prv->value));
+
+ # Ensure our ECDSA key is correct
+ $prv = openssl_pkey_get_private($ca->prv->value);
+ $key_details = openssl_pkey_get_details($prv);
+ $this->assert_is_true($key_details['type'] === OPENSSL_KEYTYPE_EC);
+ $this->assert_is_true($key_details['ec']['curve_name'] === 'prime256v1');
+
+ # Cleanup the CA
+ $ca->delete();
+ }
+
+ /**
+ * Ensure we can generate a new intermediate Certificate Authority.
+ */
+ public function test_generate_intermediate_certificate_authority(): void {
+ $root_ca = new CertificateAuthorityGenerate(
+ descr: 'root_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $root_ca->always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $root_ca->create();
+
+ $intermediate_ca = new CertificateAuthorityGenerate(
+ descr: 'intermediate_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: true,
+ caref: $root_ca->refid->value,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $intermediate_ca->always_apply = false; # Disable always_apply to test create method without overloading cpu
+ $intermediate_ca->create();
+
+ # Ensure we have a matching keypair
+ $this->assert_is_not_empty($intermediate_ca->crt->value);
+ $this->assert_is_not_empty($intermediate_ca->prv->value);
+ $this->assert_is_true(
+ X509Validator::is_matching_keypair($intermediate_ca->crt->value, $intermediate_ca->prv->value),
+ );
+
+ # Ensure our RSA key length is correct
+ $prv = openssl_pkey_get_private($intermediate_ca->prv->value);
+ $key_details = openssl_pkey_get_details($prv);
+ $this->assert_is_true($key_details['type'] === OPENSSL_KEYTYPE_RSA);
+ $this->assert_is_true($key_details['bits'] === 2048);
+
+ # Ensure the root CAs next serial has incremented
+ $this->assert_is_greater_than(
+ CertificateAuthority::query(refid: $root_ca->refid->value)->first()->serial->value,
+ $root_ca->serial->value,
+ );
+
+ # Ensure our intermediate CA is signed by the root CA
+ $inter_ca_cert = openssl_x509_read($intermediate_ca->crt->value);
+ $root_ca_cert = openssl_x509_read($root_ca->crt->value);
+ $root_ca_cert = openssl_pkey_get_public($root_ca_cert);
+ $this->assert_equals(openssl_x509_verify($inter_ca_cert, $root_ca_cert), 1);
+
+ # Cleanup the CAs
+ $intermediate_ca->delete();
+ $root_ca->delete();
+ }
+
+ /**
+ * Ensure the get_ecname_choices method returns EC names.
+ */
+ public function test_get_ecname_choices(): void {
+ $ca = new CertificateAuthorityGenerate();
+ $this->assert_is_not_empty($ca->get_ecname_choices());
+ }
+
+ /**
+ * Ensure the get_digest_alg_choices method returns digest algorithms.
+ */
+ public function test_get_digest_alg_choices(): void {
+ $ca = new CertificateAuthorityGenerate();
+ $this->assert_is_not_empty($ca->get_digest_alg_choices());
+ }
+
+ /**
+ * Ensure the get_country_choices method returns countries.
+ */
+ public function test_get_country_choices(): void {
+ $ca = new CertificateAuthorityGenerate();
+ $this->assert_is_not_empty($ca->get_country_choices());
+ $this->assert_is_true(in_array('US', $ca->get_country_choices()));
+ $this->assert_is_true(in_array('CA', $ca->get_country_choices()));
+ $this->assert_is_true(in_array('GB', $ca->get_country_choices()));
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateAuthorityRenewTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateAuthorityRenewTestCase.inc
new file mode 100644
index 00000000..6e5be88b
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateAuthorityRenewTestCase.inc
@@ -0,0 +1,118 @@
+always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $ca->create();
+
+ # Store the generated CA as a class property
+ $this->ca = CertificateAuthority::query(refid: $ca->refid->value)->first();
+ }
+
+ /**
+ * Tear down the test case so we clean up the Certificate Authority we created.
+ */
+ public function teardown(): void {
+ # Cleanup the CA
+ $this->ca->always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $this->ca->delete();
+ }
+
+ /**
+ * Ensure when we renew the CertificateAuthority while reusing the key and serial, both the certificate and
+ * the key remain the same.
+ */
+ public function test_renew_certificate_authority_reuse(): void {
+ # Before we renew, obtain the existing CA cert
+ $old_cert = $this->ca->crt->value;
+ $old_key = $this->ca->prv->value;
+
+ # Renew the Certificate Authority
+ $renew = new CertificateAuthorityRenew(
+ caref: $this->ca->refid->value,
+ reusekey: true,
+ reuseserial: true,
+ strictsecurity: false,
+ );
+ $renew->always_apply = false; # Prevent clobbering CPU during tests
+ $renew->create();
+ $renew->reload_config(true);
+
+ # Refresh our CA object
+ $this->ca = CertificateAuthority::query(refid: $this->ca->refid->value)->first();
+
+ # Ensure the certificate, key and serial are the same
+ $this->assert_equals($old_cert, $this->ca->crt->value);
+ $this->assert_equals($old_key, $this->ca->prv->value);
+ $this->assert_equals($renew->oldserial->value, $renew->newserial->value);
+ }
+
+ /**
+ * Ensure when we renew the CertificateAuthority without reusing the key and serial, the certificate, key, and serial
+ * all change.
+ */
+ public function test_renew_certificate_authority_no_reuse(): void {
+ # Before we renew, obtain the existing CA cert, key, and serial
+ $old_cert = $this->ca->crt->value;
+ $old_key = $this->ca->prv->value;
+
+ # Renew the Certificate Authority
+ $renew = new CertificateAuthorityRenew(
+ caref: $this->ca->refid->value,
+ reusekey: false,
+ reuseserial: false,
+ strictsecurity: false,
+ );
+ $renew->always_apply = false; # Prevent clobbering CPU during tests
+ $renew->create();
+
+ # Refresh our CA object
+ $this->ca = CertificateAuthority::query(refid: $this->ca->refid->value)->first();
+
+ # Ensure the certificate, key, and serial have all changed
+ $this->assert_not_equals($old_cert, $this->ca->crt->value);
+ $this->assert_not_equals($old_key, $this->ca->prv->value);
+ $this->assert_not_equals($renew->oldserial->value, $renew->newserial->value);
+ }
+
+ /**
+ * Ensure the reusekey and reuseserial fields both default to rue internally
+ */
+ public function test_reusekey_and_reuseserial_default_to_true(): void {
+ $renew = new CertificateAuthorityRenew();
+ $this->assert_equals($renew->get_internal()['reusekey'], true);
+ $this->assert_equals($renew->get_internal()['reuseserial'], true);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateGenerateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateGenerateTestCase.inc
new file mode 100644
index 00000000..24ab5fb0
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateGenerateTestCase.inc
@@ -0,0 +1,128 @@
+ca = new CertificateAuthorityGenerate(
+ descr: 'test_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $this->ca->create();
+ $this->ca = CertificateAuthority::query(refid: $this->ca->refid->value)->first();
+ }
+
+ /**
+ * Cleanup the parent CA.
+ */
+ public function teardown(): void {
+ $this->ca->delete();
+ }
+
+ /**
+ * Ensure we can generate a new Certificate with an RSA key.
+ */
+ public function test_generate_rsa_certificate(): void {
+ $cert = new CertificateGenerate(
+ descr: 'testcert',
+ caref: $this->ca->refid->value,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ type: 'user',
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ dn_commonname: 'testcert.example.com',
+ dn_dns_sans: ['testcert2.example.com', 'testcert3.example.com'],
+ dn_email_sans: ['example@example.com'],
+ dn_ip_sans: ['127.0.0.1'],
+ dn_uri_sans: ['http://example.com'],
+ );
+ $cert->create();
+
+ # Ensure we have a matching keypair
+ $this->assert_is_not_empty($cert->crt->value);
+ $this->assert_is_not_empty($cert->prv->value);
+ $this->assert_is_true(X509Validator::is_matching_keypair($cert->crt->value, $cert->prv->value));
+
+ # Ensure our RSA key length is correct
+ $prv = openssl_pkey_get_private($cert->prv->value);
+ $key_details = openssl_pkey_get_details($prv);
+ $this->assert_is_true($key_details['type'] === OPENSSL_KEYTYPE_RSA);
+ $this->assert_is_true($key_details['bits'] === 2048);
+
+ # Ensure the DN fields are present in the certificate
+ $cert_data = openssl_x509_parse($cert->crt->value);
+ $this->assert_equals($cert_data['subject']['C'], 'US');
+ $this->assert_equals($cert_data['subject']['ST'], 'UT');
+ $this->assert_equals($cert_data['subject']['L'], 'Salt Lake City');
+ $this->assert_equals($cert_data['subject']['O'], 'ACME Org');
+ $this->assert_equals($cert_data['subject']['OU'], 'IT');
+ $this->assert_equals($cert_data['subject']['CN'], 'testcert.example.com');
+ $this->assert_str_contains($cert_data['extensions']['subjectAltName'], 'DNS:testcert2.example.com');
+ $this->assert_str_contains($cert_data['extensions']['subjectAltName'], 'DNS:testcert3.example.com');
+ $this->assert_str_contains($cert_data['extensions']['subjectAltName'], 'email:example@example.com');
+ $this->assert_str_contains($cert_data['extensions']['subjectAltName'], 'IP Address:127.0.0.1');
+ $this->assert_str_contains($cert_data['extensions']['subjectAltName'], 'URI:http://example.com');
+
+ # Cleanup the CA
+ $cert->delete();
+ }
+
+ /**
+ * Ensure the get_ecname_choices method returns EC names.
+ */
+ public function test_get_ecname_choices(): void {
+ $cert = new CertificateGenerate();
+ $this->assert_is_not_empty($cert->get_ecname_choices());
+ }
+
+ /**
+ * Ensure the get_digest_alg_choices method returns digest algorithms.
+ */
+ public function test_get_digest_alg_choices(): void {
+ $cert = new CertificateGenerate();
+ $this->assert_is_not_empty($cert->get_digest_alg_choices());
+ }
+
+ /**
+ * Ensure the get_country_choices method returns countries.
+ */
+ public function test_get_country_choices(): void {
+ $cert = new CertificateGenerate();
+ $this->assert_is_not_empty($cert->get_country_choices());
+ $this->assert_is_true(in_array('US', $cert->get_country_choices()));
+ $this->assert_is_true(in_array('CA', $cert->get_country_choices()));
+ $this->assert_is_true(in_array('GB', $cert->get_country_choices()));
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificatePKCS12ExportTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificatePKCS12ExportTestCase.inc
new file mode 100644
index 00000000..9981318b
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificatePKCS12ExportTestCase.inc
@@ -0,0 +1,33 @@
+assert_equals([], $model->get_internal());
+ }
+
+ /**
+ * Ensure the _create() method correctly converts the certificate to a PKCS12 archive.
+ */
+ public function test_create_with_pass(): void {
+ # Obtain the default certificate
+ $cert = new Certificate(id: 0);
+
+ # Create a PKCS12 archive from the certificate
+ $model = new CertificatePKCS12Export(certref: $cert->refid->value, encryption: 'high', passphrase: 'testpass');
+ $model->create();
+
+ # Ensure we can load the PKCS12 archive
+ $pkcs12 = openssl_pkcs12_read($model->binary_data->value, $certs, 'testpass');
+ $this->assert_not_equals(false, $pkcs12);
+ }
+}
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
new file mode 100644
index 00000000..ef320577
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc
@@ -0,0 +1,152 @@
+always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $ca->create();
+
+ # Store the generated CA as a class property
+ $this->ca = CertificateAuthority::query(refid: $ca->refid->value)->first();
+
+ # Generate a new Certificate
+ $cert = new CertificateGenerate(
+ descr: 'testcert',
+ caref: $this->ca->refid->value,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ type: 'user',
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ dn_commonname: 'testcert.example.com',
+ dn_dns_sans: ['testcert2.example.com', 'testcert3.example.com'],
+ dn_email_sans: ['example@example.com'],
+ dn_ip_sans: ['127.0.0.1'],
+ dn_uri_sans: ['http://example.com'],
+ );
+ $cert->create();
+
+ # Store the generated Certificate as a class property
+ $this->cert = Certificate::query(refid: $cert->refid->value)->first();
+ }
+
+ /**
+ * Tear down the test case so we clean up the Certificate Authority we created.
+ */
+ public function teardown(): void {
+ # Cleanup the Certificate Authority
+ $this->ca->always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $this->ca->delete();
+
+ # Cleanup the Certificate
+ $this->cert->delete();
+ }
+
+ /**
+ * Ensure when we renew the Certificate while reusing the key and serial, both the certificate 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
+ $renew = new CertificateRenew(
+ certref: $this->cert->refid->value,
+ reusekey: true,
+ reuseserial: true,
+ strictsecurity: false,
+ );
+ $renew->always_apply = false; # Prevent clobbering CPU during tests
+ $renew->create();
+
+ # 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);
+ $this->assert_equals($old_key, $this->cert->prv->value);
+ $this->assert_equals($renew->oldserial->value, $renew->newserial->value);
+ }
+
+ /**
+ * Ensure when we renew the Certificate without reusing the key and serial, the certificate, key, and serial
+ * all change.
+ */
+ public function test_renew_certificate_no_reuse(): void {
+ # Before we renew, obtain the existing CA cert, key, and serial
+ $old_cert = $this->cert->crt->value;
+ $old_key = $this->cert->prv->value;
+
+ # Renew the Certificate
+ $renew = new CertificateRenew(
+ certref: $this->cert->refid->value,
+ reusekey: false,
+ reuseserial: false,
+ strictsecurity: false,
+ );
+ $renew->always_apply = false; # Prevent clobbering CPU during tests
+ $renew->create();
+
+ # Refresh our CA object
+ $this->cert = Certificate::query(refid: $this->cert->refid->value)->first();
+
+ # Ensure the certificate, key, and serial have all changed
+ $this->assert_not_equals($old_cert, $this->cert->crt->value);
+ $this->assert_not_equals($old_key, $this->cert->prv->value);
+ $this->assert_not_equals($renew->oldserial->value, $renew->newserial->value);
+ }
+
+ /**
+ * Ensure the reusekey and reuseserial fields both default to rue internally
+ */
+ public function test_reusekey_and_reuseserial_default_to_true(): void {
+ $renew = new CertificateRenew();
+ $this->assert_equals($renew->get_internal()['reusekey'], true);
+ $this->assert_equals($renew->get_internal()['reuseserial'], true);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListRevokedCertificateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListRevokedCertificateTestCase.inc
new file mode 100644
index 00000000..7959ac1c
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListRevokedCertificateTestCase.inc
@@ -0,0 +1,133 @@
+ca = new CertificateAuthorityGenerate(
+ descr: 'test_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $this->ca->create();
+ $this->ca = CertificateAuthority::query(refid: $this->ca->refid->value)->first();
+
+ $cert = new CertificateGenerate(
+ descr: 'testcert',
+ caref: $this->ca->refid->value,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ type: 'user',
+ dn_commonname: 'testcert.example.com',
+ );
+ $cert->create();
+ $this->cert = Certificate::query(descr: 'testcert')->first();
+
+ $this->crl = new CertificateRevocationList(
+ descr: 'test_internal_ca',
+ caref: $this->ca->refid->value,
+ method: 'internal',
+ lifetime: 730,
+ serial: 1,
+ );
+ $this->crl->create();
+ }
+
+ /**
+ * Cleans up the test cases by deleting the CA and certificate
+ */
+ public function teardown(): void {
+ $this->cert->delete();
+ $this->ca->delete();
+ }
+
+ /*
+ * Ensures revoked certificates can be created, updated and deleted
+ */
+ public function test_create_update_and_delete(): void {
+ # Ensure we can revoke the certificate
+ $revoked_cert = new CertificateRevocationListRevokedCertificate(
+ parent_id: $this->crl->id,
+ certref: $this->cert->refid->value,
+ reason: 0,
+ );
+ $revoked_cert->create();
+
+ # Ensure the revoked certificate details were populated
+ $this->assert_equals($revoked_cert->descr->value, $this->cert->descr->value);
+ $this->assert_is_not_empty($revoked_cert->revoke_time->value);
+
+ # Reload the CRL and keep track of this CRL iteration
+ $this->crl->from_internal();
+ $crl_serial = $this->crl->serial->value;
+ $crl_text = $this->crl->text->value;
+
+ # Update the revoked certificate's reason and ensure the CRL is updated
+ $revoked_cert->reason->value = 1;
+ $revoked_cert->update();
+ $this->crl->from_internal();
+ $this->assert_not_equals($crl_serial, $this->crl->serial->value);
+ $this->assert_not_equals($crl_text, $this->crl->text->value);
+
+ # Delete the revoked certificate
+ $revoked_cert->delete();
+ }
+
+ /**
+ * Ensures revoked certificates can't be created for external CRLs
+ */
+ public function test_external_crl_rejection(): void {
+ $this->assert_throws_response(
+ response_id: 'CERTIFICATE_REVOCATION_LIST_REVOKED_CERTIFICATE_PARENT_NOT_INTERNAL',
+ code: 406,
+ callable: function () {
+ $revoked_cert = new CertificateRevocationListRevokedCertificate(
+ parent_id: $this->crl->id,
+ certref: $this->cert->refid->value,
+ reason: 0,
+ );
+ $revoked_cert->parent_model->method->value = 'external';
+ $revoked_cert->validate_extra();
+ },
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListTestCase.inc
index 5b9e5e89..0887a06f 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRevocationListTestCase.inc
@@ -2,8 +2,12 @@
namespace RESTAPI\Tests;
+use RESTAPI\Core\Model;
use RESTAPI\Core\TestCase;
+use RESTAPI\Models\Certificate;
use RESTAPI\Models\CertificateAuthority;
+use RESTAPI\Models\CertificateAuthorityGenerate;
+use RESTAPI\Models\CertificateGenerate;
use RESTAPI\Models\CertificateRevocationList;
class APIModelsCertificateRevocationListTestCase extends TestCase {
@@ -70,6 +74,60 @@ sJ2heugpAOP3tLQrMgGw6M9XBPjVBsnErVgRu11OJnUttL7kUgWcrTlaOYhPqvYx
O2KYIqqr1w7O0epy0oKKZSHlwQoIOy8S1CNCIrFiYx4BJ9Gq34cjZ/OpkQPijQ==
-----END X509 CRL-----";
+ /**
+ * @var CertificateAuthority|Model $ca The CertificateAuthority model to use for testing.
+ */
+ private CertificateAuthority|Model $ca;
+
+ /**
+ * @var Certificate|Model $cert The Certificate model to use for testing.
+ */
+ private Certificate|Model $cert;
+
+ /**
+ * Sets up the test cases by ensuring a CA and certificate are generated
+ */
+ public function setup(): void {
+ $this->ca = new CertificateAuthorityGenerate(
+ descr: 'test_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $this->ca->create();
+ $this->ca = CertificateAuthority::query(refid: $this->ca->refid->value)->first();
+
+ $cert = new CertificateGenerate(
+ descr: 'testcert',
+ caref: $this->ca->refid->value,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ type: 'user',
+ dn_commonname: 'testcert.example.com',
+ );
+ $cert->create();
+ $this->cert = Certificate::query(descr: 'testcert')->first();
+ }
+
+ /**
+ * Cleans up the test cases by deleting the CA and certificate
+ */
+ public function teardown(): void {
+ $this->cert->delete();
+ $this->ca->delete();
+ }
+
/*
* Ensures CRLs can be created and deleted
*/
@@ -107,4 +165,65 @@ O2KYIqqr1w7O0epy0oKKZSHlwQoIOy8S1CNCIrFiYx4BJ9Gq34cjZ/OpkQPijQ==
# Ensure there are no more CRLs
$this->assert_equals(count(CertificateRevocationList::read_all()->model_objects), 0);
}
+
+ /*
+ * Ensures the 'to_x509_crl' method works as expected
+ */
+ public function test_to_x509_crl(): void {
+ # Ensure an undefined CRL method throws an error
+ $this->assert_throws_response(
+ response_id: 'CERTIFICATE_REVOCATION_LIST_FAILED_TO_GENERATE_CRL',
+ code: 500,
+ callable: function () {
+ $internal_crl = new CertificateRevocationList();
+ $internal_crl->to_x509_crl();
+ },
+ );
+
+ # Ensure a valid internal CRL outputs the raw CRL data
+ $internal_crl = new CertificateRevocationList(
+ descr: 'test_internal_ca',
+ caref: $this->ca->refid->value,
+ method: 'internal',
+ lifetime: 730,
+ serial: 1,
+ );
+ $internal_crl->create();
+ $this->assert_str_contains($internal_crl->to_x509_crl(), '-----BEGIN X509 CRL-----');
+
+ # Delete the CRL
+ $internal_crl->delete();
+ }
+
+ /**
+ * Ensure CRL can be generated with certificates being revoked
+ */
+ public function test_generate_with_revoked_certificates(): void {
+ # Create a CRL with a revoked certificate
+ $crl = new CertificateRevocationList(
+ descr: 'test_crl',
+ caref: $this->ca->refid->value,
+ method: 'internal',
+ lifetime: 730,
+ serial: 1,
+ cert: [
+ [
+ 'certref' => $this->cert->refid->value,
+ 'reason' => 1,
+ ],
+ ],
+ );
+ $crl->create();
+
+ # Ensure we have CRL data
+ $this->assert_str_contains($crl->text->value, '-----BEGIN X509 CRL-----');
+
+ # Update the CRL and ensure the serial number is incremented
+ $crl_serial = $crl->serial->value;
+ $crl->update();
+ $this->assert_equals($crl->serial->value, $crl_serial + 1);
+
+ # Delete the CRL
+ $crl->delete();
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestSignTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestSignTestCase.inc
new file mode 100644
index 00000000..d4d09823
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestSignTestCase.inc
@@ -0,0 +1,86 @@
+csr = new CertificateSigningRequest(
+ descr: 'test_csr',
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ dn_country: 'US',
+ dn_state: 'CA',
+ dn_city: 'San Francisco',
+ dn_organization: 'ACME Corp',
+ dn_organizationalunit: 'IT',
+ lifetime: 3650,
+ dn_commonname: 'test.example.com',
+ );
+ $this->csr->create(apply: true);
+
+ # Create CA
+ $this->ca = new CertificateAuthorityGenerate(
+ descr: 'test_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $this->ca->create(apply: true);
+ }
+
+ /**
+ * Remove the CSR and CA after testing
+ */
+ public function teardown(): void {
+ $this->csr->delete();
+ $this->ca->delete();
+ }
+
+ /**
+ * Ensure we can sign an existing CSR.
+ */
+ public function test_sign(): void {
+ # Sign our CSR
+ $sign = new CertificateSigningRequestSign(
+ descr: 'test_csr_sign',
+ caref: $this->ca->refid->value,
+ csr: $this->csr->csr->value,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ );
+ $sign->create(apply: true);
+
+ # Ensure the CSR was signed and the CSR value was removed
+ $this->assert_str_contains($sign->crt->value, '-----BEGIN CERTIFICATE-----');
+
+ # Ensure the CA's next serial has incremented
+ $prev_ca_serial = $this->ca->serial->value;
+ $this->ca->from_internal();
+ $this->assert_is_greater_than($this->ca->serial->value, $prev_ca_serial);
+
+ # Delete the signed certificate
+ $sign->delete();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestTestCase.inc
new file mode 100644
index 00000000..6b82dfe2
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateSigningRequestTestCase.inc
@@ -0,0 +1,54 @@
+create(apply: true);
+
+ # Ensure the CSR was created
+ $this->assert_str_contains($csr->csr->value, '-----BEGIN CERTIFICATE REQUEST-----');
+ file_put_contents('/tmp/testca.csr', $csr->csr->value);
+
+ # Generate a CA and sign the CSR
+ new Command('openssl genpkey -algorithm RSA -out /tmp/testca.key');
+ new Command('openssl req -new -x509 -key /tmp/testca.key -out /tmp/testca.crt -days 3650 -subj "/CN=Test CA"');
+ new Command(
+ 'openssl x509 -req -in /tmp/testca.csr -CA /tmp/testca.crt -CAkey /tmp/testca.key -out /tmp/testca.crt -days 3650',
+ );
+
+ # Ensure we can complete the CSR by updating the Certificate object's 'crt' field with the signed certificate
+ $cert = new Certificate(id: $csr->id);
+ $cert->crt->value = file_get_contents('/tmp/testca.crt');
+ $cert->update(apply: true);
+
+ # Ensure the pending CSR was removed
+ $this->assert_is_empty($cert->csr->value);
+
+ # Remove the test files
+ new Command('rm /tmp/testca.*');
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerLeaseTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerLeaseTestCase.inc
index f09eebf2..b242a74e 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerLeaseTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsDHCPServerLeaseTestCase.inc
@@ -8,9 +8,9 @@ use RESTAPI\Models\DHCPServerStaticMapping;
class APIModelsDHCPServerLeaseTestCase extends TestCase {
/**
- * Checks that we can correct read DHCP leases
+ * Checks that we can correct read and delete DHCP leases
*/
- public function test_read(): void {
+ public function test_crud(): void {
# Create a DHCP server static mapping to populate a DHCP lease
$static_mapping = new DHCPServerStaticMapping(
parent_id: 'lan',
@@ -29,5 +29,82 @@ class APIModelsDHCPServerLeaseTestCase extends TestCase {
$this->assert_equals($lease_q->first()->mac->value, $static_mapping->mac->value);
$this->assert_equals($lease_q->first()->hostname->value, $static_mapping->hostname->value);
$this->assert_equals($lease_q->first()->descr->value, $static_mapping->descr->value);
+
+ # Ensure we can delete the DHCP lease
+ $lease_q->delete();
+
+ # Ensure we can delete all leases
+ $this->assert_is_greater_than_or_equal(DHCPServerLease::delete_all()->count(), 1);
+
+ # Delete the static mapping
+ $static_mapping->delete(apply: true);
+ }
+
+ /**
+ * Checks that the isc_remove_lease method correctly removes a DHCP lease from the ISC leases file
+ */
+ public function test_isc_remove_lease(): void {
+ # Mock some lease data to use for this test
+ $mock_lease_data =
+ 'lease 127.0.0.1 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}' .
+ PHP_EOL .
+ 'lease 127.0.0.2 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}' .
+ PHP_EOL .
+ 'lease 127.0.0.3 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}';
+
+ $this->assert_equals(
+ DHCPServerLease::isc_remove_lease($mock_lease_data, ip: '127.0.0.2'),
+ 'lease 127.0.0.1 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}' .
+ PHP_EOL .
+ 'lease 127.0.0.3 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}',
+ );
+ $this->assert_equals(
+ DHCPServerLease::isc_remove_lease($mock_lease_data, ip: '127.0.0.1'),
+ 'lease 127.0.0.2 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}' .
+ PHP_EOL .
+ 'lease 127.0.0.3 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}',
+ );
+ $this->assert_equals(
+ DHCPServerLease::isc_remove_lease($mock_lease_data, ip: '127.0.0.3'),
+ 'lease 127.0.0.1 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}' .
+ PHP_EOL .
+ 'lease 127.0.0.2 {' .
+ PHP_EOL .
+ ' doesnt matter whats here' .
+ PHP_EOL .
+ '}',
+ );
}
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsGraphQLTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsGraphQLTestCase.inc
new file mode 100644
index 00000000..7521b035
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsGraphQLTestCase.inc
@@ -0,0 +1,391 @@
+auth = new Auth();
+ $this->auth->username = 'admin';
+
+ # First, create several new FirewallAlias models to query.
+ $alias = new FirewallAlias();
+ $alias->replace_all([
+ [
+ 'name' => 'test1',
+ 'type' => 'host',
+ 'descr' => 'Test alias 1',
+ 'address' => ['1.2.3.4'],
+ 'detail' => ['test detail 1'],
+ ],
+ [
+ 'name' => 'test2',
+ 'type' => 'network',
+ 'descr' => 'Test alias 2',
+ 'address' => ['1.2.3.4/32'],
+ 'detail' => ['test detail 2'],
+ ],
+ [
+ 'name' => 'test3',
+ 'type' => 'port',
+ 'descr' => 'Test alias 3',
+ 'address' => ['80', '443'],
+ 'detail' => ['test detail 3'],
+ ],
+ ]);
+ }
+
+ /**
+ * Clean up FirewallAlias models after testing.
+ */
+ public function teardown(): void {
+ # Clean up the test data.
+ $alias = new FirewallAlias();
+ $alias->delete_all();
+ }
+
+ /**
+ * Check that a GraphQL query to read individual models works as expected.
+ */
+ public function test_graphql_read(): void {
+ # Define a new GraphQL model to execute a read query against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value = '{ readFirewallAlias(id: 0) { name type descr address detail } }';
+ $graphql->create();
+
+ # Ensure the result of the GraphQL query matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'readFirewallAlias' => [
+ 'name' => 'test1',
+ 'type' => 'VAL_HOST',
+ 'descr' => 'Test alias 1',
+ 'address' => ['1.2.3.4'],
+ 'detail' => ['test detail 1'],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure we can query for all Model objects without query_params or pagination.
+ */
+ public function test_query_all_models_without_params(): void {
+ # Define a new GraphQL model to execute a query against.
+ $graphql = new GraphQL(client: $this->auth);
+
+ $graphql->query->value = '{ queryFirewallAliases { name type } }';
+ $graphql->create();
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'queryFirewallAliases' => [
+ ['name' => 'test1', 'type' => 'VAL_HOST'],
+ ['name' => 'test2', 'type' => 'VAL_NETWORK'],
+ ['name' => 'test3', 'type' => 'VAL_PORT'],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure we can query for a specific Model object using query_params.
+ */
+ public function test_query_model_with_params(): void {
+ # Define a new GraphQL model to execute a query against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value = '{ queryFirewallAliases(query_params: {id: 1}) { name type } }';
+ $graphql->create();
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'queryFirewallAliases' => [['name' => 'test2', 'type' => 'VAL_NETWORK']],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure we can limit the number of results returned by using the limit argument.
+ */
+ public function test_query_models_with_limit(): void {
+ # Define a new GraphQL model to execute a query against.
+ $graphql = new GraphQL(client: $this->auth);
+
+ $graphql->query->value = '{ queryFirewallAliases(limit: 2) { name type } }';
+ $graphql->create();
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'queryFirewallAliases' => [
+ ['name' => 'test1', 'type' => 'VAL_HOST'],
+ ['name' => 'test2', 'type' => 'VAL_NETWORK'],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure we can change the offset of the results returned by using the offset argument.
+ */
+ public function test_query_models_with_offset(): void {
+ # Define a new GraphQL model to execute a query against.
+ $graphql = new GraphQL(client: $this->auth);
+
+ $graphql->query->value = '{ queryFirewallAliases(offset: 1) { name type } }';
+ $graphql->create();
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'queryFirewallAliases' => [
+ ['name' => 'test2', 'type' => 'VAL_NETWORK'],
+ ['name' => 'test3', 'type' => 'VAL_PORT'],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure we can reverse the order of the results returned by using the reverse argument.
+ */
+ public function test_query_models_with_reverse(): void {
+ # Define a new GraphQL model to execute a query against.
+ $graphql = new GraphQL(client: $this->auth);
+
+ $graphql->query->value = '{ queryFirewallAliases(reverse: true) { name type } }';
+ $graphql->create();
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'queryFirewallAliases' => [
+ ['name' => 'test3', 'type' => 'VAL_PORT'],
+ ['name' => 'test2', 'type' => 'VAL_NETWORK'],
+ ['name' => 'test1', 'type' => 'VAL_HOST'],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure we can create a new Model object using a GraphQL mutation.
+ */
+ public function test_create_mutation(): void {
+ # Define a new GraphQL model to execute a mutation against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value =
+ "mutation { createFirewallAlias(name: \"test4\", type: VAL_HOST, descr: \"Test alias 4\", address: [\"4.3.2.1\"], detail: [\"test detail 4\"]) { name type descr address detail } }";
+ $graphql->create();
+
+ # Ensure the result of the GraphQL mutation matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'createFirewallAlias' => [
+ 'name' => 'test4',
+ 'type' => 'VAL_HOST',
+ 'descr' => 'Test alias 4',
+ 'address' => ['4.3.2.1'],
+ 'detail' => ['test detail 4'],
+ ],
+ ],
+ ]);
+
+ # Ensure the new object was really created
+ $alias_q = FirewallAlias::query(name: 'test4');
+ $this->assert_is_true($alias_q->exists());
+ $this->assert_equals($alias_q->first()->name->value, 'test4');
+ $this->assert_equals($alias_q->first()->type->value, 'host');
+ $this->assert_equals($alias_q->first()->descr->value, 'Test alias 4');
+ $this->assert_equals($alias_q->first()->address->value, ['4.3.2.1']);
+ $this->assert_equals($alias_q->first()->detail->value, ['test detail 4']);
+
+ # Delete the new object
+ $alias_q->first()->delete();
+ }
+
+ /**
+ * Ensure we can update an existing Model object using a GraphQL mutation.
+ */
+ public function test_update_mutation(): void {
+ # Define a new GraphQL model to execute a mutation against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value =
+ "mutation { updateFirewallAlias(id: 0, type: VAL_NETWORK, descr: \"Test alias 5\", address: [\"5.5.5.5/5\"], detail: [\"test detail 5\"]) { id type descr address detail } }";
+ $graphql->create();
+
+ # Ensure the result of the GraphQL mutation matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'updateFirewallAlias' => [
+ 'id' => 0,
+ 'type' => 'VAL_NETWORK',
+ 'descr' => 'Test alias 5',
+ 'address' => ['5.5.5.5/5'],
+ 'detail' => ['test detail 5'],
+ ],
+ ],
+ ]);
+
+ # Ensure the object was really updated
+ $alias = new FirewallAlias(id: 0);
+ $this->assert_equals($alias->type->value, 'network');
+ $this->assert_equals($alias->descr->value, 'Test alias 5');
+ $this->assert_equals($alias->address->value, ['5.5.5.5/5']);
+ $this->assert_equals($alias->detail->value, ['test detail 5']);
+ }
+
+ /**
+ * Ensure we can delete an existing Model object using a GraphQL mutation.
+ */
+ public function test_delete_mutation(): void {
+ # Define a new GraphQL model to execute a mutation against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value = 'mutation { deleteFirewallAlias(id: 0) { id name } }';
+ $graphql->create();
+
+ # Ensure the result of the GraphQL mutation matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'deleteFirewallAlias' => [
+ 'id' => 0,
+ 'name' => 'test1',
+ ],
+ ],
+ ]);
+
+ # Ensure the object was really deleted
+ $alias_q = FirewallAlias::query(name: 'test1');
+ $this->assert_is_false($alias_q->exists());
+ }
+
+ /**
+ * Ensure we can replace all Model objects using a GraphQL mutation.
+ */
+ public function test_replace_all_mutation(): void {
+ # Define a new GraphQL model to execute a mutation against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value =
+ "mutation { replaceAllFirewallAliases(objects: [{name: \"test4\", type: VAL_HOST, descr: \"Test alias 4\", address: [\"4.4.4.4\"], detail: [\"test detail 4\"]}]) { name type descr address detail } }";
+ $graphql->create();
+
+ # Ensure the result of the GraphQL mutation matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'replaceAllFirewallAliases' => [
+ [
+ 'name' => 'test4',
+ 'type' => 'VAL_HOST',
+ 'descr' => 'Test alias 4',
+ 'address' => ['4.4.4.4'],
+ 'detail' => ['test detail 4'],
+ ],
+ ],
+ ],
+ ]);
+
+ # Ensure the new object was really created
+ $alias_q = FirewallAlias::read_all();
+ $this->assert_equals($alias_q->count(), 1);
+ $this->assert_equals($alias_q->first()->name->value, 'test4');
+ $this->assert_equals($alias_q->first()->type->value, 'host');
+ $this->assert_equals($alias_q->first()->descr->value, 'Test alias 4');
+ $this->assert_equals($alias_q->first()->address->value, ['4.4.4.4']);
+ $this->assert_equals($alias_q->first()->detail->value, ['test detail 4']);
+ }
+
+ /**
+ * Ensure we can delete all Model objects using a GraphQL mutation.
+ */
+ public function test_delete_all_mutation(): void {
+ # Define a new GraphQL model to execute a mutation against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value = 'mutation { deleteAllFirewallAliases { id name } }';
+ $graphql->create();
+
+ # Ensure the result of the GraphQL mutation matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'deleteAllFirewallAliases' => [
+ ['id' => 0, 'name' => 'test1'],
+ ['id' => 1, 'name' => 'test2'],
+ ['id' => 2, 'name' => 'test3'],
+ ],
+ ],
+ ]);
+
+ # Ensure the objects were really deleted
+ $alias_q = FirewallAlias::read_all();
+ $this->assert_equals($alias_q->count(), 0);
+ }
+
+ /**
+ * Ensure we can delete many Model objects via query using a GraphQL mutation
+ */
+ public function test_delete_many_mutation(): void {
+ # Define a new GraphQL model to execute a mutation against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value =
+ "mutation { deleteManyFirewallAliases(query_params: {name__except: \"test2\"}) { id name } }";
+ $graphql->create();
+
+ # Ensure the result of the GraphQL mutation matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'deleteManyFirewallAliases' => [['id' => 0, 'name' => 'test1'], ['id' => 2, 'name' => 'test3']],
+ ],
+ ]);
+
+ # Ensure the objects were really deleted
+ $alias_q = FirewallAlias::read_all();
+ $this->assert_equals($alias_q->count(), 1);
+ $this->assert_equals($alias_q->first()->name->value, 'test2');
+ }
+
+ /**
+ * Ensure we can execute a query with variables.
+ */
+ public function test_query_with_variables(): void {
+ # Define a new GraphQL model to execute a query against.
+ $graphql = new GraphQL(client: $this->auth);
+ $graphql->query->value =
+ "query(\$query_params: QueryParams) { queryFirewallAliases(query_params: \$query_params) { name } }";
+ $graphql->variables->value = ['query_params' => ['id__except' => 1]];
+ $graphql->create();
+
+ # Ensure the result of the GraphQL query matches the expected values.
+ $this->assert_equals($graphql->result->toArray(), [
+ 'data' => [
+ 'queryFirewallAliases' => [['name' => 'test1'], ['name' => 'test3']],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure the GraphQL model's internal callable returns an empty array.
+ */
+ public function test_internal_callable(): void {
+ $graphql = new GraphQL(client: $this->auth);
+ $this->assert_equals($graphql->get_internal(), []);
+ }
+
+ /**
+ * Ensure the GraphQL 'result' property is excluded from serialization. This property is not serializable and will
+ * throw an error when the Model tries to set the 'initial_object' property.
+ */
+ public function test_result_property_excluded_from_serialization(): void {
+ $graphql = new GraphQL(client: $this->auth);
+ $this->assert_is_true(!in_array('result', $graphql->__sleep()));
+ $this->assert_does_not_throw(
+ callable: function () use ($graphql) {
+ serialize($graphql);
+ },
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecChildSAStatusTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecChildSAStatusTestCase.inc
new file mode 100644
index 00000000..f1994ad9
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecChildSAStatusTestCase.inc
@@ -0,0 +1,47 @@
+assert_equals($status->name->value, 'con2');
+ $this->assert_equals($status->uniqueid->value, 9261);
+ $this->assert_equals($status->reqid->value, 4);
+ $this->assert_equals($status->state->value, 'INSTALLED');
+ $this->assert_equals($status->mode->value, 'TUNNEL');
+ $this->assert_equals($status->protocol->value, 'ESP');
+ $this->assert_equals($status->encap->value, true);
+ $this->assert_equals($status->spi_in->value, 'a9sd8f9as8d9f');
+ $this->assert_equals($status->spi_out->value, 'asd9fa8sd9f8');
+ $this->assert_equals($status->encr_alg->value, 'AES_CBC');
+ $this->assert_equals($status->encr_keysize->value, 256);
+ $this->assert_equals($status->integ_alg->value, 'HMAC_SHA2_256_128');
+ $this->assert_equals($status->dh_group->value, 'MODP_2048');
+ $this->assert_equals($status->bytes_in->value, 1952934);
+ $this->assert_equals($status->packets_in->value, 3116);
+ $this->assert_equals($status->use_in->value, 1);
+ $this->assert_equals($status->bytes_out->value, 1397428);
+ $this->assert_equals($status->packets_out->value, 2343);
+ $this->assert_equals($status->use_out->value, 1);
+ $this->assert_equals($status->rekey_time->value, 860);
+ $this->assert_equals($status->life_time->value, 1361);
+ $this->assert_equals($status->install_time->value, 2239);
+ $this->assert_equals($status->local_ts->value, ['10.0.0.0/9|/0']);
+ $this->assert_equals($status->remote_ts->value, ['192.100.0.0/16|/0']);
+
+ # Unset mock data
+ $mock_internal_objects = null;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecSAStatusTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecSAStatusTestCase.inc
new file mode 100644
index 00000000..c0738dd6
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsIPsecSAStatusTestCase.inc
@@ -0,0 +1,124 @@
+ 'con2',
+ 'uniqueid' => '851',
+ 'version' => '2',
+ 'state' => 'ESTABLISHED',
+ 'local-host' => '1.2.3.4',
+ 'local-port' => '4500',
+ 'local-id' => '1.2.3.4',
+ 'remote-host' => '4.3.2.1',
+ 'remote-port' => '4501',
+ 'remote-id' => '4.3.2.1',
+ 'initiator-spi' => '1122334455aabbccdd',
+ 'responder-spi' => 'aabbccdd1122334455',
+ 'nat-remote' => 'yes',
+ 'nat-any' => 'yes',
+ 'encr-alg' => 'AES_CBC',
+ 'encr-keysize' => '256',
+ 'integ-alg' => 'HMAC_SHA2_256_128',
+ 'prf-alg' => 'PRF_HMAC_SHA2_256',
+ 'dh-group' => 'MODP_2048',
+ 'established' => '21133',
+ 'rekey-time' => '3014',
+ 'child-sas' => [
+ [
+ 'name' => 'con2',
+ 'uniqueid' => '9261',
+ 'reqid' => '4',
+ 'state' => 'INSTALLED',
+ 'mode' => 'TUNNEL',
+ 'protocol' => 'ESP',
+ 'encap' => 'yes',
+ 'spi-in' => 'a9sd8f9as8d9f',
+ 'spi-out' => 'asd9fa8sd9f8',
+ 'encr-alg' => 'AES_CBC',
+ 'encr-keysize' => '256',
+ 'integ-alg' => 'HMAC_SHA2_256_128',
+ 'dh-group' => 'MODP_2048',
+ 'bytes-in' => '1952934',
+ 'packets-in' => '3116',
+ 'use-in' => '1',
+ 'bytes-out' => '1397428',
+ 'packets-out' => '2343',
+ 'use-out' => '1',
+ 'rekey-time' => '860',
+ 'life-time' => '1361',
+ 'install-time' => '2239',
+ 'local-ts' => ['10.0.0.0/9|/0'],
+ 'remote-ts' => ['192.100.0.0/16|/0'],
+ ],
+ ],
+ ],
+ ];
+
+ /**
+ * Ensure we can read the IPsecSAStatus model data.
+ */
+ public function test_read(): void {
+ global $mock_internal_objects;
+ $mock_internal_objects = self::MOCK_DATA;
+ $status = new IPsecSAStatus(id: 0);
+
+ # Ensure all values are expected
+ $this->assert_equals($status->con_id->value, 'con2');
+ $this->assert_equals($status->uniqueid->value, 851);
+ $this->assert_equals($status->version->value, 2);
+ $this->assert_equals($status->state->value, 'ESTABLISHED');
+ $this->assert_equals($status->local_host->value, '1.2.3.4');
+ $this->assert_equals($status->local_port->value, '4500');
+ $this->assert_equals($status->local_id->value, '1.2.3.4');
+ $this->assert_equals($status->remote_host->value, '4.3.2.1');
+ $this->assert_equals($status->remote_port->value, '4501');
+ $this->assert_equals($status->remote_id->value, '4.3.2.1');
+ $this->assert_equals($status->initiator_spi->value, '1122334455aabbccdd');
+ $this->assert_equals($status->responder_spi->value, 'aabbccdd1122334455');
+ $this->assert_equals($status->nat_remote->value, true);
+ $this->assert_equals($status->nat_any->value, true);
+ $this->assert_equals($status->encr_alg->value, 'AES_CBC');
+ $this->assert_equals($status->encr_keysize->value, 256);
+ $this->assert_equals($status->integ_alg->value, 'HMAC_SHA2_256_128');
+ $this->assert_equals($status->prf_alg->value, 'PRF_HMAC_SHA2_256');
+ $this->assert_equals($status->dh_group->value, 'MODP_2048');
+ $this->assert_equals($status->established->value, 21133);
+ $this->assert_equals($status->rekey_time->value, 3014);
+
+ # Ensure child SA values are present
+ $this->assert_equals(count($status->child_sas->value), 1);
+ $this->assert_equals($status->child_sas->value[0]['name'], 'con2');
+ $this->assert_equals($status->child_sas->value[0]['uniqueid'], 9261);
+ $this->assert_equals($status->child_sas->value[0]['reqid'], 4);
+ $this->assert_equals($status->child_sas->value[0]['state'], 'INSTALLED');
+ $this->assert_equals($status->child_sas->value[0]['mode'], 'TUNNEL');
+ $this->assert_equals($status->child_sas->value[0]['protocol'], 'ESP');
+ $this->assert_equals($status->child_sas->value[0]['encap'], true);
+ $this->assert_equals($status->child_sas->value[0]['spi_in'], 'a9sd8f9as8d9f');
+ $this->assert_equals($status->child_sas->value[0]['spi_out'], 'asd9fa8sd9f8');
+ $this->assert_equals($status->child_sas->value[0]['encr_alg'], 'AES_CBC');
+ $this->assert_equals($status->child_sas->value[0]['encr_keysize'], 256);
+ $this->assert_equals($status->child_sas->value[0]['integ_alg'], 'HMAC_SHA2_256_128');
+ $this->assert_equals($status->child_sas->value[0]['dh_group'], 'MODP_2048');
+ $this->assert_equals($status->child_sas->value[0]['bytes_in'], 1952934);
+ $this->assert_equals($status->child_sas->value[0]['packets_in'], 3116);
+ $this->assert_equals($status->child_sas->value[0]['use_in'], 1);
+ $this->assert_equals($status->child_sas->value[0]['bytes_out'], 1397428);
+ $this->assert_equals($status->child_sas->value[0]['packets_out'], 2343);
+ $this->assert_equals($status->child_sas->value[0]['use_out'], 1);
+ $this->assert_equals($status->child_sas->value[0]['rekey_time'], 860);
+ $this->assert_equals($status->child_sas->value[0]['life_time'], 1361);
+ $this->assert_equals($status->child_sas->value[0]['install_time'], 2239);
+ $this->assert_equals($status->child_sas->value[0]['local_ts'], ['10.0.0.0/9|/0']);
+ $this->assert_equals($status->child_sas->value[0]['remote_ts'], ['192.100.0.0/16|/0']);
+
+ # Unset mock data
+ $mock_internal_objects = null;
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsLogSettingsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsLogSettingsTestCase.inc
new file mode 100644
index 00000000..d67e5625
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsLogSettingsTestCase.inc
@@ -0,0 +1,36 @@
+update();
+
+ # Read the log settings and ensure the values we assigned are present
+ $log_settings = new LogSettings();
+ $this->assert_equals($log_settings->logfilesize->value, 500000);
+ $this->assert_equals($log_settings->rotatecount->value, 6);
+ $this->assert_equals($log_settings->enableremotelogging->value, true);
+ $this->assert_equals($log_settings->remoteserver->value, '127.0.0.1:514');
+ $this->assert_equals($log_settings->logall->value, true);
+
+ # Disable remote logging
+ $log_settings->enableremotelogging->value = false;
+ $log_settings->update();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNServerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNServerTestCase.inc
index 08a589fa..2d1da183 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNServerTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNServerTestCase.inc
@@ -46,6 +46,7 @@ class APIModelsOpenVPNServerTestCase extends TestCase {
'dev_mode' => 'tun',
'protocol' => 'UDP4',
'interface' => 'wan',
+ 'use_tls' => true,
'tls' => $this->tls_key,
'tls_type' => 'auth',
'dh_length' => '2048',
@@ -436,4 +437,11 @@ class APIModelsOpenVPNServerTestCase extends TestCase {
},
);
}
+
+ /**
+ * Checks that the 'generate_tls_key()' method correctly generates a new OpenVPN TLS key
+ */
+ public function test_generate_tls_key(): void {
+ $this->assert_str_contains(OpenVPNServer::generate_tls_key(), '-----BEGIN OpenVPN Static key V1-----');
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsServiceTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsServiceTestCase.inc
index 685db008..c4483fb6 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsServiceTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsServiceTestCase.inc
@@ -9,33 +9,36 @@ class APIModelsServiceTestCase extends TestCase {
/**
* Checks that the `get_services()` method correctly identifies all available services.
*/
- public function test_get_services() {
+ public function test_get_services(): void {
# Ensure expected services are found in the method's response
$expected_services = ['unbound', 'ntpd', 'syslogd', 'dhcpd', 'dpinger', 'sshd'];
- # Loop through each identified service and ensure it is expected
- foreach (Service::get_services() as $service) {
- $this->assert_is_true(in_array($service['name'], $expected_services));
+ # Ensure each expected service is found in the method's response
+ foreach ($expected_services as $service) {
+ $this->assert_is_true(
+ in_array($service, array_column(Service::get_services(), 'name')),
+ "Expected $service to be in the list of services",
+ );
}
}
/**
* Checks that the `get_service_name_choices()` method correctly identifies all available service names.
*/
- public function test_get_service_name_choices() {
+ public function test_get_service_name_choices(): void {
# Ensure expected services are found in the method's response
$expected_services = ['unbound', 'ntpd', 'syslogd', 'dhcpd', 'dpinger', 'sshd'];
- # Loop through each identified service and ensure it is expected
- foreach ((new Service())->get_service_name_choices() as $service) {
- $this->assert_is_true(in_array($service, $expected_services));
+ # Loop through each expected service and ensure it is found
+ foreach ($expected_services as $service) {
+ $this->assert_is_true(in_array($service, (new Service())->get_service_name_choices()));
}
}
/**
* Checks that the `get_id_by_name()` correctly obtains the ID of the Service object with a specific `name`
*/
- public function test_get_id_by_name() {
+ public function test_get_id_by_name(): void {
# Create a Service object to test with
$test_service = new Service();
@@ -47,7 +50,7 @@ class APIModelsServiceTestCase extends TestCase {
/**
* Checks that a Service can be stopped, started and restarted.
*/
- public function test_can_perform_service_actions() {
+ public function test_can_perform_service_actions(): void {
# Define a Service to test with
$test_service = new Service(data: ['name' => 'ntpd']);
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsWireGuardPeerTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsWireGuardPeerTestCase.inc
index 29ceb29e..b292afed 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsWireGuardPeerTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsWireGuardPeerTestCase.inc
@@ -50,6 +50,14 @@ class APIModelsWireGuardPeerTestCase extends TestCase {
$peer->validate();
},
);
+
+ # Ensure empty PSK does not throw an error
+ $this->assert_does_not_throw(
+ callable: function () {
+ $peer = new WireGuardPeer(publickey: 'KG0BA4UyPilHH5qnXCfr6Lw8ynecOPor88tljLy3AHk=', presharedkey: '');
+ $peer->validate();
+ },
+ );
}
/**
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIResponsesGraphQLResponseTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIResponsesGraphQLResponseTestCase.inc
new file mode 100644
index 00000000..f3cf3ac2
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIResponsesGraphQLResponseTestCase.inc
@@ -0,0 +1,162 @@
+auth = new Auth();
+ $this->auth->username = 'admin';
+ }
+
+ /**
+ * Ensure an execution result is always present when constructing a GraphQLResponse object.
+ */
+ public function test_execution_result(): void {
+ # Create a new GraphQLResponse object.
+ $response = new GraphQLResponse();
+
+ # Ensure the ExecutionResult object is always present.
+ $this->assert_is_true($response->execution_result instanceof ExecutionResult);
+ }
+
+ /**
+ * Ensure the GraphQLResponse object is correctly represented as an array when calling to_representation.
+ */
+ public function test_to_representation(): void {
+ # Test success response
+ $hostname = new SystemHostname();
+ $graphql = new GraphQL(query: '{ readSystemHostname { hostname domain } }', client: $this->auth);
+ $graphql->create();
+ $graphql_resp = new GraphQLResponse(data: $graphql);
+ $this->assert_equals($graphql_resp->to_representation(), [
+ 'data' => [
+ 'readSystemHostname' => [
+ 'hostname' => $hostname->hostname->value,
+ 'domain' => $hostname->domain->value,
+ ],
+ ],
+ ]);
+
+ # Ensure any errors that occur are also represented correctly.
+ $graphql = new GraphQL(query: '{ readFirewallRule(id: 65535) { id } }', client: $this->auth);
+ $graphql->create();
+ $graphql_resp = new GraphQLResponse(data: $graphql);
+ $this->assert_equals($graphql_resp->to_representation(), [
+ 'errors' => [
+ [
+ 'message' => 'Object with ID `65535` does not exist.',
+ 'locations' => [['line' => 1, 'column' => 3]],
+ 'path' => ['readFirewallRule'],
+ 'extensions' => ['response_id' => 'MODEL_OBJECT_NOT_FOUND'],
+ ],
+ ],
+ 'data' => ['readFirewallRule' => null],
+ ]);
+ }
+
+ /**
+ * Ensure the to_graphql_response method correctly converts a standard Response object into a GraphQLResponse object.
+ */
+ public function test_to_graphql_response(): void {
+ # Create a few different Response objects to test with.
+ $not_found_resp = new ValidationError(message: 'Test not found', response_id: 'TEST_NOT_FOUND');
+ $bad_request_resp = new ValidationError(message: 'Test bad request', response_id: 'TEST_BAD_REQUEST');
+ $success_resp = new ValidationError(message: 'Test success', response_id: 'TEST_SUCCESS');
+
+ # Ensure the to_graphql_response method correctly converts a standard Response object into a GraphQLResponse object.
+ $this->assert_is_true(GraphQLResponse::to_graphql_response($not_found_resp) instanceof GraphQLResponse);
+ $this->assert_is_true(GraphQLResponse::to_graphql_response($bad_request_resp) instanceof GraphQLResponse);
+ $this->assert_is_true(GraphQLResponse::to_graphql_response($success_resp) instanceof GraphQLResponse);
+
+ # Ensure the error message is correctly set in the GraphQLResponse object.
+ $graphql_resp = GraphQLResponse::to_graphql_response($not_found_resp);
+ $this->assert_equals($graphql_resp->to_representation(), [
+ 'errors' => [
+ [
+ 'message' => 'Test not found',
+ 'extensions' => ['response_id' => 'TEST_NOT_FOUND'],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensure the to_openapi_schema method correctly returns the OpenAPI schema for a GraphQLResponse object.
+ */
+ public function test_to_openapi_schema(): void {
+ # Create a new GraphQLResponse object.
+ $response = new GraphQLResponse();
+
+ # Ensure the OpenAPI schema is correctly returned.
+ $this->assert_equals($response->to_openapi_schema(), [
+ 'type' => 'object',
+ 'properties' => [
+ 'data' => ['description' => 'The GraphQL response data.', 'type' => 'object'],
+ 'errors' => [
+ 'description' => 'The GraphQL response errors.',
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'message' => [
+ 'description' => 'The error message.',
+ 'type' => 'string',
+ ],
+ 'extensions' => [
+ 'description' => 'The error extensions.',
+ 'type' => 'object',
+ 'properties' => [
+ 'response_id' => [
+ 'description' => 'The error response ID.',
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ 'locations' => [
+ 'description' => 'The error locations.',
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'line' => [
+ 'description' => 'The error line.',
+ 'type' => 'integer',
+ ],
+ 'column' => [
+ 'description' => 'The error column.',
+ 'type' => 'integer',
+ ],
+ ],
+ ],
+ ],
+ 'path' => [
+ 'description' => 'The error path.',
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsIPAddressValidatorTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsIPAddressValidatorTestCase.inc
index 70caf739..77445c16 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsIPAddressValidatorTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsIPAddressValidatorTestCase.inc
@@ -243,4 +243,40 @@ class APIValidatorsIPAddressValidatorTestCase extends TestCase {
$test_validator->validate('www.example.com');
});
}
+
+ /**
+ * Checks that the IPAddressValidator 'allow_port' option works as expected.
+ */
+ public function test_ip_address_validator_port_suffix() {
+ # Ensure socket addresses are valid when allowed
+ $this->assert_does_not_throw(
+ callable: function () {
+ $test_validator = new IPAddressValidator(allow_port: true);
+ $test_validator->validate('1.2.3.4:443');
+
+ $test_validator = new IPAddressValidator(allow_fqdn: true, allow_port: true);
+ $test_validator->validate('example.com:80');
+ },
+ );
+
+ # Ensure socket addresses are invalid when the port is not a valid port number
+ $this->assert_throws_response(
+ response_id: 'IP_ADDRESS_VALIDATOR_INVALID_PORT_SUFFIX',
+ code: 400,
+ callable: function () {
+ $test_validator = new IPAddressValidator(allow_port: true);
+ $test_validator->validate('1.2.3.4:65536');
+ },
+ );
+
+ # Ensure socket addresses are not allowed when the allow_port option is not enabled
+ $this->assert_throws_response(
+ response_id: 'IP_ADDRESS_VALIDATOR_FAILED',
+ code: 400,
+ callable: function () {
+ $test_validator = new IPAddressValidator(allow_port: false);
+ $test_validator->validate('1.2.3.4:80');
+ },
+ );
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsURLValidatorTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsURLValidatorTestCase.inc
new file mode 100644
index 00000000..277c60a6
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIValidatorsURLValidatorTestCase.inc
@@ -0,0 +1,32 @@
+validate('http://example.com');
+
+ # Ensure allow keywords are accepted
+ $validator = new URLValidator(allow_keywords: ['not a url!']);
+ $validator->validate('not a url!');
+
+ # Ensure invalid URLs are rejected
+ $this->assert_throws_response(
+ response_id: 'URL_VALIDATOR_FAILED',
+ code: 400,
+ callable: function () {
+ $validator = new URLValidator();
+ $validator->validate('not a url!');
+ },
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/IPAddressValidator.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/IPAddressValidator.inc
index 4fb6af2f..4c8f0791 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/IPAddressValidator.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/IPAddressValidator.inc
@@ -12,35 +12,24 @@ use RESTAPI\Responses\ValidationError;
* Defines a Validator that checks if a given value is a valid IPv4 or IPv6 address.
*/
class IPAddressValidator extends RESTAPI\Core\Validator {
- public bool $allow_ipv4;
- public bool $allow_ipv6;
- public bool $allow_ipv6_link_local;
- public bool $allow_alias;
- public bool $allow_fqdn;
- public array $allow_keywords;
-
/**
* @param bool $allow_ipv4 Allow value to be an IPv4 address.
* @param bool $allow_ipv6 Allow value to be an IPv6 address.
* @param bool $allow_ipv6_link_local Allow value to be an IPv6 link local address. Only applies if $allow_ipv6
* is `true`.
* @param bool $allow_fqdn Allow value to be an FQDN.
+ * @param bool $allow_port Allow value to end with a port number (e.g. `:80`).
* @param array $allow_keywords An array of non IP/FQDN values to allow.
*/
public function __construct(
- bool $allow_ipv4 = true,
- bool $allow_ipv6 = true,
- bool $allow_ipv6_link_local = true,
- bool $allow_alias = false,
- bool $allow_fqdn = false,
- array $allow_keywords = [],
+ public bool $allow_ipv4 = true,
+ public bool $allow_ipv6 = true,
+ public bool $allow_ipv6_link_local = true,
+ public bool $allow_alias = false,
+ public bool $allow_fqdn = false,
+ public bool $allow_port = false,
+ public array $allow_keywords = [],
) {
- $this->allow_ipv4 = $allow_ipv4;
- $this->allow_ipv6 = $allow_ipv6;
- $this->allow_ipv6_link_local = $allow_ipv6_link_local;
- $this->allow_alias = $allow_alias;
- $this->allow_fqdn = $allow_fqdn;
- $this->allow_keywords = $allow_keywords;
}
/**
@@ -49,7 +38,12 @@ class IPAddressValidator extends RESTAPI\Core\Validator {
* @param string $field_name The field name of the value being validated. This is used for error messages.
* @throws \RESTAPI\Responses\ValidationError When the value is not a valid IPv4 address.
*/
- public function validate(mixed $value, string $field_name = '') {
+ public function validate(mixed $value, string $field_name = ''): void {
+ # Allow the value to end with a port number if allowed
+ if ($this->allow_port) {
+ $value = $this->validate_port_suffix($value, $field_name);
+ }
+
# Non-port alias values are valid if allowed
$alias_query = FirewallAlias::query(['name' => $value, 'type__except' => 'port']);
if ($this->allow_alias and $alias_query->exists()) {
@@ -96,4 +90,32 @@ class IPAddressValidator extends RESTAPI\Core\Validator {
response_id: 'IP_ADDRESS_VALIDATOR_FAILED',
);
}
+
+ /**
+ * Checks if a given value is a valid socket address (ADDR:PORT) and validates that the port number is valid.
+ * @param mixed $value The value to be validated.
+ * @param string $field_name
+ * @return string
+ */
+ private function validate_port_suffix(mixed $value, string $field_name): string {
+ # Check if the value has a port suffix, if not return the value as is
+ if (!str_contains($value, ':')) {
+ return $value;
+ }
+
+ # Split the value into the IP address and port number
+ $split_value = explode(':', $value);
+ $ip_address = $split_value[0];
+ $port_number = $split_value[1];
+
+ # Ensure the port number is a valid port number
+ if (!is_port($port_number)) {
+ throw new ValidationError(
+ message: "Field `$field_name` contains an invalid port suffix `$port_number`.",
+ response_id: 'IP_ADDRESS_VALIDATOR_INVALID_PORT_SUFFIX',
+ );
+ }
+
+ return $ip_address;
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/URLValidator.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/URLValidator.inc
new file mode 100644
index 00000000..cc517944
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Validators/URLValidator.inc
@@ -0,0 +1,51 @@
+allow_keywords = $allow_keywords;
+ }
+
+ /**
+ * Checks if a given value is a valid hostname.
+ * @param mixed $value The value to validate.
+ * @param string $field_name The field name of the value being validated. This is used for error messages.
+ * @throws ValidationError When the value is not a valid hostname
+ */
+ public function validate(mixed $value, string $field_name = ''): void {
+ # Allow this value if it matches an allowed keyword exactly
+ if (in_array($value, $this->allow_keywords)) {
+ return;
+ }
+
+ # Accept URL values
+ if (filter_var($value, FILTER_VALIDATE_URL)) {
+ return;
+ }
+
+ # Otherwise throw a ValidationError, this value is not valid
+ throw new ValidationError(
+ message: "Field `$field_name` must be a valid URL, received `$value`.",
+ response_id: 'URL_VALIDATOR_FAILED',
+ );
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc
index cf69bdde..63dd6e66 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/autoloader.inc
@@ -18,19 +18,8 @@ require_once 'pkg-utils.inc';
require_once 'firewall_virtual_ip.inc';
require_once 'includes/functions.inc.php';
-# Check if the php-jwt dependency has already been installed. This is necessary to concurrently run v1 and v2 on the
-# same system since both packages install this dependency, which creates conflicts.
-# TODO: Look into the feasibility of making php-jwt its own pfSense package that both packages can share
-$jwt_path_prefix = !is_dir('/etc/inc/firebase/php-jwt/src') ? 'RESTAPI/.resources/includes/' : '';
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/JWT.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/JWK.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/Key.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/CachedKeySet.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/ExpiredException.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/SignatureInvalidException.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/BeforeValidException.php";
-require_once "{$jwt_path_prefix}firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php";
+# Include the Composer autoloader
+require_once '/usr/local/pkg/RESTAPI/.resources/vendor/autoload.php';
# Ensure the RESTAPI BaseTraits is imported before any other libraries
require_once 'RESTAPI/Core/BaseTraits.inc';
@@ -48,6 +37,8 @@ const RESTAPI_LIBRARIES = [
'/usr/local/pkg/RESTAPI/Models/',
'/usr/local/pkg/RESTAPI/QueryFilters/',
'/usr/local/pkg/RESTAPI/ContentHandlers/',
+ '/usr/local/pkg/RESTAPI/Schemas/',
+ '/usr/local/pkg/RESTAPI/GraphQL/',
'/usr/local/pkg/RESTAPI/Auth',
'/usr/local/pkg/RESTAPI/Endpoints/',
'/usr/local/pkg/RESTAPI/Forms/',
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml
index 5b54c0eb..69c8e0e8 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml
+++ b/pfSense-pkg-RESTAPI/files/usr/local/share/pfSense-pkg-RESTAPI/info.xml
@@ -16,6 +16,7 @@
enabled
disabled
disabled
+ disabled
disabled
id
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/www/api/swagger/swagger-initializer.js b/pfSense-pkg-RESTAPI/files/usr/local/www/api/swagger/swagger-initializer.js
index 06205b03..94be061f 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/www/api/swagger/swagger-initializer.js
+++ b/pfSense-pkg-RESTAPI/files/usr/local/www/api/swagger/swagger-initializer.js
@@ -1,6 +1,6 @@
window.onload = function() {
window.ui = SwaggerUIBundle({
- url: "/api/v2/schema",
+ url: "/api/v2/schema/openapi",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/www/api/v2/schema/index.php b/pfSense-pkg-RESTAPI/files/usr/local/www/api/v2/schema/index.php
deleted file mode 100644
index f99c2b05..00000000
--- a/pfSense-pkg-RESTAPI/files/usr/local/www/api/v2/schema/index.php
+++ /dev/null
@@ -1,4 +0,0 @@
- "$SSH_CONFIG_FILE"
# Copy the source code to the vagrant box using SCP (vagrant upload skips hidden files)
-rsync -avz --progress -e "ssh -F $SSH_CONFIG_FILE" ../pfsense-api vagrant@default:/home/vagrant/build/ --exclude node_modules --exclude .git --exclude .phpdoc --exclude vendor --exclude .vagrant
+rsync -avz --progress -e "ssh -F $SSH_CONFIG_FILE" ../pfsense-api vagrant@default:/home/vagrant/build/ --exclude node_modules --exclude .git --exclude .phpdoc --exclude ./vendor --exclude .vagrant
# Run the build script on the vagrant box
cat << END | vagrant ssh
composer install --working-dir /home/vagrant/build/pfsense-api
-rm -rf /home/vagrant/build/pfsense-api/vendor/composer && rm /home/vagrant/build/pfsense-api/vendor/autoload.php
-cp -r /home/vagrant/build/pfsense-api/vendor/* /home/vagrant/build/pfsense-api/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/includes/
+cp -r /home/vagrant/build/pfsense-api/vendor/* /home/vagrant/build/pfsense-api/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/.resources/vendor/
python3.8 /home/vagrant/build/pfsense-api/tools/make_package.py -t $BUILD_VERSION
END