diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3db366a79..ea95f957f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,6 +30,8 @@ jobs:
PFSENSE_VERSION: "2.7.2"
- FREEBSD_VERSION: FreeBSD-15.0-CURRENT
PFSENSE_VERSION: "24.03"
+ - FREEBSD_VERSION: FreeBSD-15.0-CURRENT
+ PFSENSE_VERSION: "24.11"
steps:
- uses: actions/checkout@v4
diff --git a/README.md b/README.md
index 1b251a6a0..bb759714c 100644
--- a/README.md
+++ b/README.md
@@ -45,11 +45,11 @@ Install on pfSense Plus:
pkg-static -C /dev/null add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-24.03-pkg-RESTAPI.pkg
```
-## Support for pfSense Plus 24.11
+> [!WARNING]
+> Before installing the package, always ensure your pfSense version is supported! Supported versions are listed [here](https://pfrest.org/INSTALL_AND_CONFIG/#supported-pfsense-versions).
+> Installation of the package on unsupported versions of pfSense may result in unexpected behavior and/or system instability.
-Working on supporting in https://github.com/jaredhendrickson13/pfsense-api/discussions/610
-
-> [!IMPORTANT]
+> [!TIP]
> You may need to customize the installation command to reference the package built for your pfSense version. Check
> the [releases page](https://github.com/jaredhendrickson13/pfsense-api/releases) to find the package built for
> your version of pfSense.
diff --git a/composer.lock b/composer.lock
index 3125900bd..87ac4f886 100644
--- a/composer.lock
+++ b/composer.lock
@@ -1,156 +1,146 @@
{
- "_readme": [
- "This file locks the dependencies of your project to a known state",
- "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
- "This file is @generated automatically"
- ],
- "content-hash": "df390555a5bc256768abe12103f30b54",
- "packages": [
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "df390555a5bc256768abe12103f30b54",
+ "packages": [
+ {
+ "name": "firebase/php-jwt",
+ "version": "v6.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/firebase/php-jwt.git",
+ "reference": "a49db6f0a5033aef5143295342f1c95521b075ff"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/firebase/php-jwt/zipball/a49db6f0a5033aef5143295342f1c95521b075ff",
+ "reference": "a49db6f0a5033aef5143295342f1c95521b075ff",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4||^8.0"
+ },
+ "require-dev": {
+ "guzzlehttp/guzzle": "^6.5||^7.4",
+ "phpspec/prophecy-phpunit": "^2.0",
+ "phpunit/phpunit": "^9.5",
+ "psr/cache": "^1.0||^2.0",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.0"
+ },
+ "suggest": {
+ "ext-sodium": "Support EdDSA (Ed25519) signatures",
+ "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Firebase\\JWT\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": ["BSD-3-Clause"],
+ "authors": [
{
- "name": "firebase/php-jwt",
- "version": "v6.10.0",
- "source": {
- "type": "git",
- "url": "https://github.com/firebase/php-jwt.git",
- "reference": "a49db6f0a5033aef5143295342f1c95521b075ff"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/firebase/php-jwt/zipball/a49db6f0a5033aef5143295342f1c95521b075ff",
- "reference": "a49db6f0a5033aef5143295342f1c95521b075ff",
- "shasum": ""
- },
- "require": {
- "php": "^7.4||^8.0"
- },
- "require-dev": {
- "guzzlehttp/guzzle": "^6.5||^7.4",
- "phpspec/prophecy-phpunit": "^2.0",
- "phpunit/phpunit": "^9.5",
- "psr/cache": "^1.0||^2.0",
- "psr/http-client": "^1.0",
- "psr/http-factory": "^1.0"
- },
- "suggest": {
- "ext-sodium": "Support EdDSA (Ed25519) signatures",
- "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Firebase\\JWT\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Neuman Vong",
- "email": "neuman+pear@twilio.com",
- "role": "Developer"
- },
- {
- "name": "Anant Narayanan",
- "email": "anant@php.net",
- "role": "Developer"
- }
- ],
- "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
- "homepage": "https://github.com/firebase/php-jwt",
- "keywords": [
- "jwt",
- "php"
- ],
- "support": {
- "issues": "https://github.com/firebase/php-jwt/issues",
- "source": "https://github.com/firebase/php-jwt/tree/v6.10.0"
- },
- "time": "2023-12-01T16:26:39+00:00"
+ "name": "Neuman Vong",
+ "email": "neuman+pear@twilio.com",
+ "role": "Developer"
},
{
- "name": "webonyx/graphql-php",
- "version": "v15.18.1",
- "source": {
- "type": "git",
- "url": "https://github.com/webonyx/graphql-php.git",
- "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/a167afab66d8aa74b7f552759c0bbd906afb4134",
- "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "php": "^7.4 || ^8"
- },
- "require-dev": {
- "amphp/amp": "^2.6",
- "amphp/http-server": "^2.1",
- "dms/phpunit-arraysubset-asserts": "dev-master",
- "ergebnis/composer-normalize": "^2.28",
- "friendsofphp/php-cs-fixer": "3.64.0",
- "mll-lab/php-cs-fixer-config": "^5.9.2",
- "nyholm/psr7": "^1.5",
- "phpbench/phpbench": "^1.2",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "1.12.10",
- "phpstan/phpstan-phpunit": "1.4.1",
- "phpstan/phpstan-strict-rules": "1.6.1",
- "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
- "psr/http-message": "^1 || ^2",
- "react/http": "^1.6",
- "react/promise": "^2.0 || ^3.0",
- "rector/rector": "^1.0",
- "symfony/polyfill-php81": "^1.23",
- "symfony/var-exporter": "^5 || ^6 || ^7",
- "thecodingmachine/safe": "^1.3 || ^2"
- },
- "suggest": {
- "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
- "psr/http-message": "To use standard GraphQL server",
- "react/promise": "To leverage async resolving on React PHP platform"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "GraphQL\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "A PHP port of GraphQL reference implementation",
- "homepage": "https://github.com/webonyx/graphql-php",
- "keywords": [
- "api",
- "graphql"
- ],
- "support": {
- "issues": "https://github.com/webonyx/graphql-php/issues",
- "source": "https://github.com/webonyx/graphql-php/tree/v15.18.1"
- },
- "funding": [
- {
- "url": "https://opencollective.com/webonyx-graphql-php",
- "type": "open_collective"
- }
- ],
- "time": "2024-11-13T16:21:54+00:00"
+ "name": "Anant Narayanan",
+ "email": "anant@php.net",
+ "role": "Developer"
+ }
+ ],
+ "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
+ "homepage": "https://github.com/firebase/php-jwt",
+ "keywords": ["jwt", "php"],
+ "support": {
+ "issues": "https://github.com/firebase/php-jwt/issues",
+ "source": "https://github.com/firebase/php-jwt/tree/v6.10.0"
+ },
+ "time": "2023-12-01T16:26:39+00:00"
+ },
+ {
+ "name": "webonyx/graphql-php",
+ "version": "v15.18.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webonyx/graphql-php.git",
+ "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/a167afab66d8aa74b7f552759c0bbd906afb4134",
+ "reference": "a167afab66d8aa74b7f552759c0bbd906afb4134",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "php": "^7.4 || ^8"
+ },
+ "require-dev": {
+ "amphp/amp": "^2.6",
+ "amphp/http-server": "^2.1",
+ "dms/phpunit-arraysubset-asserts": "dev-master",
+ "ergebnis/composer-normalize": "^2.28",
+ "friendsofphp/php-cs-fixer": "3.64.0",
+ "mll-lab/php-cs-fixer-config": "^5.9.2",
+ "nyholm/psr7": "^1.5",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "1.12.10",
+ "phpstan/phpstan-phpunit": "1.4.1",
+ "phpstan/phpstan-strict-rules": "1.6.1",
+ "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
+ "psr/http-message": "^1 || ^2",
+ "react/http": "^1.6",
+ "react/promise": "^2.0 || ^3.0",
+ "rector/rector": "^1.0",
+ "symfony/polyfill-php81": "^1.23",
+ "symfony/var-exporter": "^5 || ^6 || ^7",
+ "thecodingmachine/safe": "^1.3 || ^2"
+ },
+ "suggest": {
+ "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
+ "psr/http-message": "To use standard GraphQL server",
+ "react/promise": "To leverage async resolving on React PHP platform"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "GraphQL\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": ["MIT"],
+ "description": "A PHP port of GraphQL reference implementation",
+ "homepage": "https://github.com/webonyx/graphql-php",
+ "keywords": ["api", "graphql"],
+ "support": {
+ "issues": "https://github.com/webonyx/graphql-php/issues",
+ "source": "https://github.com/webonyx/graphql-php/tree/v15.18.1"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/webonyx-graphql-php",
+ "type": "open_collective"
}
- ],
- "packages-dev": [],
- "aliases": [],
- "minimum-stability": "stable",
- "stability-flags": [],
- "prefer-stable": false,
- "prefer-lowest": false,
- "platform": [],
- "platform-dev": [],
- "plugin-api-version": "2.6.0"
+ ],
+ "time": "2024-11-13T16:21:54+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "2.6.0"
}
diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md
index 6e426f1fc..699518b68 100644
--- a/docs/INSTALL_AND_CONFIG.md
+++ b/docs/INSTALL_AND_CONFIG.md
@@ -16,7 +16,11 @@ run pfSense. It's recommended to follow Netgate's [minimum hardware requirements
- pfSense CE 2.7.2
- pfSense Plus 24.03
+- pfSense Plus 24.11
+!!! Warning
+ Installation of the package on unsupported versions of pfSense may result in unexpected behavior and/or system instability.
+
!!! Tip
Don't see your version of pfSense? Older versions of pfSense may be supported by older versions of this package.
Check the [releases page](https://github.com/jaredhendrickson13/pfsense-api/releases).
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc
index 900e356c4..94f99bbf3 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc
@@ -162,7 +162,14 @@ class Auth {
public function authorize(): bool {
# Variables
$is_not_authorized = false;
- $this->client_privileges = get_user_privileges(getUserEntry($this->username));
+
+ # Start with pfSense 24.11, getUserEntry returns an array with the key 'item' containing the user data.
+ # We need to handle both cases to support both.
+ $user_ent = getUserEntry($this->username);
+ $user_ent = array_key_exists('item', $user_ent) ? $user_ent['item'] : $user_ent;
+
+ # Obtain the client's privileges and check if they have the required privileges
+ $this->client_privileges = get_user_privileges($user_ent);
# This client is not authorized if the client does not have at least one of the required privileges
if ($this->required_privileges and !array_intersect($this->required_privileges, $this->client_privileges)) {
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc
index f36c6c635..1e0604487 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc
@@ -342,6 +342,37 @@ class Model {
$this->related_objects = new ModelSet();
}
+ /**
+ * Magic method to serialize only the necessary properties of this Model object.
+ * @return int[]|string[]
+ */
+ public function __sleep() {
+ # Variables
+ $excluded_properties = ['initial_object', 'client', 'parent_model', 'related_objects'];
+ $properties = array_keys(get_object_vars($this));
+
+ # Filter out excluded properties from the list of properties to serialize
+ return array_filter($properties, function ($prop) use ($excluded_properties) {
+ return !in_array($prop, $excluded_properties, true);
+ });
+ }
+
+ /**
+ * Creates an unlinked copy of this Model object.
+ * @return Model The cloned Model object.
+ */
+ public function copy(): Model {
+ # Create a copy of this object by serializing and unserializing it
+ $copy = unserialize(serialize($this));
+
+ # Reassign the parent Model object if it exists
+ if ($this->parent_model) {
+ $copy->parent_model = $this->parent_model;
+ }
+
+ return $copy;
+ }
+
/**
* Checks for options passed in during object construction and maps known options to Model properties. Any
* options that are not known options to the core Model will be returned so they can be merged into
@@ -394,7 +425,7 @@ class Model {
if ($this->config_path or $this->internal_callable) {
$this->id = $id;
$this->from_internal();
- $this->initial_object = unserialize(serialize($this));
+ $this->initial_object = $this->copy();
}
}
}
@@ -550,8 +581,7 @@ class Model {
*/
protected static function init_config(string $path) {
# Initialize the configuration array of a specified path.
- global $config;
- array_init_path($config, $path);
+ config_init_path($path);
}
/**
@@ -562,8 +592,7 @@ class Model {
* path keys an empty string and $default is non-null
*/
public static function get_config(string $path, mixed $default = null) {
- global $config;
- return array_get_path($config, $path, $default);
+ return config_get_path($path, $default);
}
/**
@@ -574,8 +603,7 @@ class Model {
* @returns mixed $val or $default if the path prefix does not exist
*/
public static function set_config(string $path, mixed $value, mixed $default = null) {
- global $config;
- return array_set_path($config, $path, $value, $default);
+ return config_set_path($path, $value, $default);
}
/**
@@ -616,8 +644,7 @@ class Model {
* @returns array copy of the removed value or null
*/
public static function del_config(string $path): mixed {
- global $config;
- return array_del_path($config, $path);
+ return config_del_path($path);
}
/**
@@ -630,8 +657,7 @@ class Model {
* non-null value, otherwise false.
*/
public static function is_config_enabled(string $path, string $enable_key = 'enable'): bool {
- global $config;
- return array_path_enabled($config, $path, $enable_key);
+ return config_path_enabled($path, $enable_key);
}
/**
@@ -979,7 +1005,7 @@ class Model {
# Obtain the object from its internal form
$this->from_internal();
- $this->initial_object = unserialize(serialize($this));
+ $this->initial_object = $this->copy();
}
# Loop through each field in this Model and assign their values using the `representation_data`.
@@ -2018,7 +2044,7 @@ class Model {
}
# Refresh the initial object
- $this->initial_object = unserialize(serialize($this));
+ $this->initial_object = $this->copy();
}
# Return the current representation of this object
@@ -2108,7 +2134,7 @@ class Model {
}
# Refresh the initial object
- $this->initial_object = unserialize(serialize($this));
+ $this->initial_object = $this->copy();
}
# Return the current representation of this object
@@ -2286,7 +2312,7 @@ class Model {
}
# Refresh the initial object
- $this->initial_object = unserialize(serialize($this));
+ $this->initial_object = $this->copy();
}
# Return the current representation of this object
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc
index dfede7047..ee03055b2 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Tools.inc
@@ -168,3 +168,28 @@ function get_classes_from_namespace(string $namespace, bool $shortnames = false)
return $classes;
}
+
+/**
+ * Generates a random MAC address string.
+ * @returns string A random MAC address string.
+ */
+function generate_mac_address(): string {
+ # Generate the first three octets (OUI) with fixed bits for unicast and locally administered addresses
+ $first_octet = dechex((mt_rand(0x00, 0xff) & 0xfe) | 0x02); // Ensure it is locally administered
+ $mac = [
+ $first_octet,
+ dechex(mt_rand(0x00, 0xff)),
+ dechex(mt_rand(0x00, 0xff)),
+ dechex(mt_rand(0x00, 0xff)),
+ dechex(mt_rand(0x00, 0xff)),
+ dechex(mt_rand(0x00, 0xff)),
+ ];
+
+ # Zero-pad single-character hex values and return the MAC address
+ return implode(
+ ':',
+ array_map(function ($part) {
+ return str_pad($part, 2, '0', STR_PAD_LEFT);
+ }, $mac),
+ );
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREEndpoint.inc
new file mode 100644
index 000000000..51760e2ce
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/interface/gre';
+ $this->model_name = 'InterfaceGRE';
+ $this->request_method_options = ['GET', 'POST', 'PATCH', 'DELETE'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREsEndpoint.inc
new file mode 100644
index 000000000..a9c3a3b37
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceGREsEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/interface/gres';
+ $this->model_name = 'InterfaceGRE';
+ $this->many = true;
+ $this->request_method_options = ['GET', 'DELETE'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGEndpoint.inc
new file mode 100644
index 000000000..4e2b84421
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGEndpoint.inc
@@ -0,0 +1,23 @@
+url = '/api/v2/interface/lagg';
+ $this->model_name = 'InterfaceLAGG';
+ $this->request_method_options = ['GET', 'POST', 'PATCH', 'DELETE'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGsEndpoint.inc
new file mode 100644
index 000000000..4fe78175d
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/InterfaceLAGGsEndpoint.inc
@@ -0,0 +1,24 @@
+url = '/api/v2/interface/laggs';
+ $this->model_name = 'InterfaceLAGG';
+ $this->many = true;
+ $this->request_method_options = ['GET', 'DELETE'];
+
+ # Construct the parent Endpoint object
+ parent::__construct();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc
index 9292a7346..2b5c20f24 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CARP.inc
@@ -69,7 +69,7 @@ class CARP extends Model {
public function get_carp_internal(): array {
return [
'enable' => $this->is_carp_enabled(),
- 'maintenance_mode' => $this->is_config_enabled('/', 'virtualip_carp_maintenancemode'),
+ 'maintenance_mode' => $this->is_config_enabled('', 'virtualip_carp_maintenancemode'),
];
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc
index 4cbdac411..be3715e83 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Certificate.inc
@@ -17,6 +17,7 @@ use RESTAPI\Validators\X509Validator;
class Certificate extends Model {
public StringField $descr;
public UIDField $refid;
+ public StringField $caref;
public StringField $type;
public Base64Field $csr;
public Base64Field $crt;
@@ -37,6 +38,13 @@ class Certificate extends Model {
help_text: 'The unique ID assigned to this certificate for internal system use. This value is generated ' .
'by this system and cannot be changed.',
);
+ $this->caref = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The unique ID of the existing pfSense Certificate Authority that signed this certificate.' .
+ 'This value is assigned by this system and cannot be changed.',
+ );
$this->type = new StringField(
default: 'server',
choices: ['server', 'user'],
@@ -83,6 +91,19 @@ class Certificate extends Model {
return $prv;
}
+ /**
+ * Extends the default _create() method to ensure the certificate is fully imported before creating it.
+ */
+ public function _create(): void {
+ # Import the cert first using pfSense's cert_import function and relink CAs (if necessary)
+ $config_data = $this->to_internal();
+ cert_import($config_data, $this->crt->value, $this->prv->value);
+ $this->caref->value = $config_data['caref'] ?? null;
+
+ # Create the Certificate object
+ parent::_create();
+ }
+
/**
* Extends the default _update() method to ensure any `csr` value is removed before updating a Certificate.
*/
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc
index 1ebe10cbc..bfe4645ac 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/CertificateAuthority.inc
@@ -63,7 +63,8 @@ class CertificateAuthority extends Model {
help_text: 'The X509 certificate string.',
);
$this->prv = new Base64Field(
- required: true,
+ default: null,
+ allow_null: true,
sensitive: true,
validators: [new X509Validator(allow_prv: true, allow_ecprv: true)],
help_text: 'The X509 private key string.',
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
index 39fc3a761..636d418fc 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/DHCPServer.inc
@@ -252,22 +252,22 @@ class DHCPServer extends Model {
}
/**
- * Initializes configurations objects for defined interface that have not yet configured the DHCP server
+ * Initializes configuration objects for defined interface that have not yet configured the DHCP server
*/
private function init_interfaces(): void {
# Variables
- $ifs_using_dhcpd = array_keys($this->get_config(path: $this->config_path, default: []));
+ $ifs_using_dhcp_server = array_keys($this->get_config(path: $this->config_path, default: []));
# Loop through each defined interface
- foreach (NetworkInterface::read_all()->model_objects as $if) {
+ foreach ($this->get_config('interfaces', []) as $if_id => $if) {
# Skip this interface if it is not a static interface or the subnet value is greater than or equal to 31
- if ($if->typev4->value !== 'static' or $if->subnet->value >= 31) {
+ if (empty($if['ipaddr']) or $if['ipaddr'] === 'dhcp' or $if->subnet->value >= 31) {
continue;
}
# Otherwise, make this interface eligible for a DHCP server
- if (!in_array($if->id, $ifs_using_dhcpd)) {
- $this->set_config(path: "$this->config_path/$if->id", value: []);
+ if (!in_array($if_id, $ifs_using_dhcp_server)) {
+ $this->set_config(path: "$this->config_path/$if_id", value: []);
}
}
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc
index dd417aa18..b626884b5 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FirewallAlias.inc
@@ -93,34 +93,39 @@ class FirewallAlias extends Model {
* @returns string The validated value to set.
* @throws ValidationError When the `address` value is invalid.
*/
- public function validate_address(string $addresses): string {
+ public function validate_address(string $address): string {
+ # Variables
+ $aliases = $this->read_all();
+ $type = $this->type->value;
+
# Ensure value is a port, port range or port alias when `type` is `port`
- if ($this->type->value === 'port' and !is_port_or_range_or_alias($addresses)) {
+ $port_alias_q = $aliases->query(name: $address, type: 'port');
+ if ($type === 'port' and !is_port_or_range($address) and !$port_alias_q->exists()) {
throw new ValidationError(
- message: "Port alias 'address' value '$addresses' is not a valid port, range, or alias.",
+ message: "Port alias 'address' value '$address' is not a valid port, range, or alias.",
response_id: 'INVALID_PORT_ALIAS_ADDRESS',
);
}
# Ensure value is an IP, FQDN or alias when `type` is `host`
- if ($this->type->value === 'host' and !is_ipaddroralias($addresses) and !is_fqdn($addresses)) {
+ $host_alias_q = $aliases->query(name: $address, type: 'host');
+ if ($type === 'host' and !is_ipaddr($address) and !is_fqdn($address) and !$host_alias_q->exists()) {
throw new ValidationError(
- message: "Host alias 'address' value '$addresses' is not a valid IP, FQDN, or alias.",
+ message: "Host alias 'address' value '$address' is not a valid IP, FQDN, or alias.",
response_id: 'INVALID_HOST_ALIAS_ADDRESS',
);
}
# Ensure value is a CIDR, FQDN or alias when `type` is `network`
- if ($this->type->value === 'network') {
- if (!is_subnet($addresses) and alias_get_type($addresses) != 'network' and !is_fqdn($addresses)) {
- throw new ValidationError(
- message: "Host alias 'address' value '$addresses' is not a valid CIDR, FQDN, or alias.",
- response_id: 'INVALID_NETWORK_ALIAS_ADDRESS',
- );
- }
+ $network_alias_q = $aliases->query(name: $address, type: 'network');
+ if ($type === 'network' and !is_subnet($address) and !is_fqdn($address) and !$network_alias_q->exists()) {
+ throw new ValidationError(
+ message: "Host alias 'address' value '$address' is not a valid CIDR, FQDN, or alias.",
+ response_id: 'INVALID_NETWORK_ALIAS_ADDRESS',
+ );
}
- return $addresses;
+ return $address;
}
/**
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceGRE.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceGRE.inc
new file mode 100644
index 000000000..2ec2f8cb3
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceGRE.inc
@@ -0,0 +1,178 @@
+config_path = 'gres/gre';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->if = new InterfaceField(
+ required: true,
+ help_text: 'The pfSense interface interface serving as the local address to be used for the GRE tunnel.',
+ );
+ $this->greif = new StringField(
+ default: null,
+ allow_null: true,
+ read_only: true,
+ help_text: 'The real interface name for this GRE interface.',
+ );
+ $this->descr = new StringField(
+ default: '',
+ allow_empty: true,
+ help_text: 'A description for this GRE interface.',
+ );
+ $this->add_static_route = new BooleanField(
+ default: false,
+ internal_name: 'link1',
+ help_text: 'Whether to add an explicit static route for the remote inner tunnel address/subnet via the ' .
+ 'local tunnel address.',
+ );
+ $this->remote_addr = new StringField(
+ required: true,
+ internal_name: 'remote-addr',
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)],
+ help_text: 'The remote address to use for the GRE tunnel.',
+ );
+ $this->tunnel_local_addr = new StringField(
+ default: null,
+ allow_null: true,
+ internal_name: 'tunnel-local-addr',
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)],
+ help_text: 'The local IPv4 address to use for the GRE tunnel.',
+ );
+ $this->tunnel_remote_addr = new StringField(
+ required: true,
+ unique: true,
+ internal_name: 'tunnel-remote-addr',
+ conditions: ['!tunnel_local_addr' => null],
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)],
+ help_text: 'The remote IPv4 address to use for the GRE tunnel.',
+ );
+ $this->tunnel_remote_net = new IntegerField(
+ default: 32,
+ minimum: 1,
+ maximum: 32,
+ internal_name: 'tunnel-remote-net',
+ conditions: ['!tunnel_local_addr' => null],
+ help_text: 'The remote IPv4 subnet bitmask to use for the GRE tunnel.',
+ );
+ $this->tunnel_local_addr6 = new StringField(
+ default: null,
+ allow_null: true,
+ internal_name: 'tunnel-local-addr6',
+ validators: [new IPAddressValidator(allow_ipv4: false, allow_ipv6: true)],
+ help_text: 'The local IPv6 address to use for the GRE tunnel.',
+ );
+ $this->tunnel_remote_addr6 = new StringField(
+ required: true,
+ unique: true,
+ internal_name: 'tunnel-remote-addr6',
+ conditions: ['!tunnel_local_addr6' => null],
+ validators: [new IPAddressValidator(allow_ipv4: false, allow_ipv6: true)],
+ help_text: 'The remote IPv6 address to use for the GRE tunnel.',
+ );
+ $this->tunnel_remote_net6 = new IntegerField(
+ default: 128,
+ minimum: 1,
+ maximum: 128,
+ internal_name: 'tunnel-remote-net6',
+ conditions: ['!tunnel_local_addr6' => null],
+ help_text: 'The remote IPv6 subnet bitmask to use for the GRE tunnel.',
+ );
+
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Adds extra validation to this entire Model.
+ * @throws ValidationError If neither a local IPv4 nor IPv6 tunnel address is present.
+ */
+ public function validate_extra(): void {
+ # Require either a local IPv4 and/or IPv6 address to present
+ if (!$this->tunnel_local_addr->value and !$this->tunnel_local_addr6->value) {
+ throw new ValidationError(
+ message: 'GRE tunnel must have a `tunnel_local_addr` and/or `tunnel_local_addr6`.',
+ response_id: 'INTERFACE_GRE_HAS_NO_LOCAL_ADDRESS',
+ );
+ }
+ }
+
+ /**
+ * Applies changes to this Interface GRE Tunnel.
+ */
+ public function apply(): void {
+ # If the greif is already assigned to an interface, reconfigure the interface
+ $if_q = NetworkInterface::query(if: $this->greif->value);
+ if ($if_q->exists()) {
+ interface_configure($if_q->first()->id);
+ }
+ }
+
+ /**
+ * Applies the deletion of this Interface GRE Tunnel.
+ */
+ public function apply_delete(): void {
+ pfSense_interface_destroy($this->greif->value);
+ }
+
+ /**
+ * Extend the default _create method to create the GRE interface and obtain the real interface name.
+ */
+ public function _create(): void {
+ $this->greif->value = interface_gre_configure($this->to_internal());
+ parent::_create();
+ }
+
+ /**
+ * Extend the default _update method to reconfigure the GRE interface.
+ */
+ public function _update(): void {
+ interface_gre_configure($this->to_internal());
+ parent::_update();
+ }
+
+ /**
+ * Extend the default _delete method to prevent deletion of the GRE interface while in use.
+ * @throws ConflictError If the GRE interface is in use.
+ */
+ public function _delete(): void {
+ # If the greif is in use, don't allow deletion
+ $if_q = NetworkInterface::query(if: $this->greif->value);
+ if ($if_q->exists()) {
+ throw new ConflictError(
+ message: 'Cannot delete GRE interface while it is in use.',
+ response_id: 'INTERFACE_GRE_CANNOT_DELETE_WHILE_IN_USE',
+ );
+ }
+
+ parent::_delete();
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceLAGG.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceLAGG.inc
new file mode 100644
index 000000000..efa0a7d9b
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceLAGG.inc
@@ -0,0 +1,167 @@
+config_path = 'laggs/lagg';
+ $this->many = true;
+ $this->always_apply = true;
+
+ # Set model fields
+ $this->laggif = new StringField(
+ read_only: true,
+ maximum_length: 32,
+ help_text: 'The real name of the LAGG interface.',
+ );
+ $this->descr = new StringField(
+ default: '',
+ allow_empty: true,
+ help_text: 'A description to help document the purpose of this LAGG interface.',
+ );
+ $this->members = new StringField(
+ required: true,
+ many: true,
+ many_minimum: 1,
+ help_text: 'A list of member interfaces to include in the LAGG.',
+ );
+ $this->proto = new StringField(
+ required: true,
+ choices: ['lacp', 'failover', 'loadbalance', 'roundrobin', 'none'],
+ help_text: 'The LAGG protocol to use.',
+ );
+ $this->lacptimeout = new StringField(
+ default: 'slow',
+ choices: ['slow', 'fast'],
+ conditions: ['proto' => 'lacp'],
+ help_text: 'The LACP timeout mode to use.',
+ );
+ $this->lagghash = new StringField(
+ default: 'l2,l3,l4',
+ choices: ['l2', 'l3', 'l4', 'l2,l3', 'l2,l4', 'l3,l4', 'l2,l3,l4'],
+ conditions: ['proto' => ['lacp', 'loadbalance']],
+ help_text: 'The LAGG hash algorithm to use.',
+ );
+ $this->failovermaster = new StringField(
+ default: 'auto',
+ conditions: ['proto' => 'failover'],
+ help_text: 'The failover master interface to use.',
+ );
+ parent::__construct($id, $parent_id, $data, ...$options);
+ }
+
+ /**
+ * Add extra validation to the `members` field.
+ * @param string $value The value to validate.
+ * @return string The validated value to assign.
+ * @throws ValidationError When the requested member interface does not exist.
+ */
+ public function validate_members(string $value): string {
+ # Throw a validation error if the request member does not exist
+ if (!does_interface_exist($value)) {
+ throw new ValidationError(
+ message: "Member interface `$value` does not exist or is invalid.",
+ response_id: 'INTERFACE_LAGG_MEMBER_DOES_NOT_EXIST',
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Adds extra validation to the `failovermaster` field.
+ * @param string $value The value to validate.
+ * @return string The validated value to assign.
+ * @throws ValidationError When the requested failover master interface is not assigned as a member.
+ */
+ public function validate_failovermaster(string $value): string {
+ # Always allow the `auto` value
+ if ($value === 'auto') {
+ return $value;
+ }
+
+ # Throw a validation error if the requested failover master is not a member
+ if (!in_array($value, $this->members->value)) {
+ throw new ValidationError(
+ message: "Failover master interface `$value` is not a member of the LAGG.",
+ response_id: 'INTERFACE_LAGG_MASTER_NOT_MEMBER',
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Extends the default _create method to capture the LAGG interface name of the newly created LAGG.
+ */
+ public function _create(): void {
+ $this->laggif->value = interface_lagg_configure($this->to_internal());
+ parent::_create();
+ }
+
+ /**
+ * Extends the default _update method to reconfigure the updated LAGG.
+ */
+ public function _update(): void {
+ interface_lagg_configure($this->to_internal());
+ parent::_update();
+ }
+
+ /**
+ * Extends the default _delete method to prevent deletion of the LAGG interface if it is in use.
+ */
+ public function _delete(): void {
+ # Query for an interface using this lagg
+ $if_q = NetworkInterface::query(if: $this->laggif->value);
+ if ($if_q->exists()) {
+ throw new ConflictError(
+ message: "Cannot delete LAGG interface `{$this->laggif->value}` because it is in use by interface " .
+ "with ID `{$if_q->first()->id}`.",
+ response_id: 'INTERFACE_LAGG_CANNOT_BE_DELETED_WHILE_IN_USE',
+ );
+ }
+
+ parent::_delete();
+ }
+
+ /**
+ * Reconfigures VLANs and interfaces using this LAGG after successful creation or update.
+ */
+ public function apply(): void {
+ # Check for interfaces using this LAGG and reconfigure them
+ $if_q = NetworkInterface::query(if: $this->laggif->value);
+ foreach ($if_q->model_objects as $if) {
+ interface_configure($if->id);
+ }
+
+ # Check for VLANs using this LAGG and reconfigure them
+ $vlan_q = InterfaceVLAN::query(if: $this->laggif->value);
+ foreach ($vlan_q->model_objects as $vlan) {
+ interface_vlan_configure($vlan->to_internal());
+ }
+ }
+
+ /**
+ * Applies the deletion of this LAGG interface.
+ */
+ public function apply_delete(): void {
+ pfSense_interface_destroy($this->laggif->value);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc
index aec1ef5b2..e65976c9c 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGateway.inc
@@ -207,8 +207,8 @@ class RoutingGateway extends Model {
# Ensure the gateway cannot be disabled while it is in use by a system DNS server
# TODO: replace with a Model query when the SystemDNSServer Model is developed
$dnsgw_counter = 1;
- while (isset($config['system']["dns{$dnsgw_counter}gw"])) {
- if ($this->name->value == $config['system']["dns{$dnsgw_counter}gw"]) {
+ while ($this->get_config("system/dns{$dnsgw_counter}gw")) {
+ if ($this->name->value == $this->get_config("system/dns{$dnsgw_counter}gw")) {
throw new ValidationError(
message: 'Gateway cannot be disabled because it is in use by a system DNS ' .
"server with ID `$dnsgw_counter`",
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc
index 022140a65..25edefe66 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RoutingGatewayGroup.inc
@@ -68,7 +68,16 @@ class RoutingGatewayGroup extends Model {
protected function from_internal_ipprotocol(): string {
# Check the IP protocol of the gateways in this group
foreach ($this->priorities->value as $priority) {
- $gw_obj = RoutingGateway::query(name: $priority['gateway'])->first();
+ # Query for the gateway related to this priority
+ $gw_q = RoutingGateway::query(name: $priority['gateway']);
+
+ # Skip this priority if the gateway does not exist. This can happen if the gateway is not in config.
+ if (!$gw_q->exists()) {
+ continue;
+ }
+
+ # Check the IP protocol of the gateway
+ $gw_obj = $gw_q->first();
if ($gw_obj->ipprotocol->value === 'inet') {
return 'inet';
} elseif ($gw_obj->ipprotocol->value === 'inet6') {
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc
index a4e402de9..020342fb5 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/VirtualIP.inc
@@ -31,6 +31,8 @@ class VirtualIP extends Model {
public IntegerField $advskew;
public StringField $password;
public StringField $carp_status;
+ public StringField $carp_mode;
+ public StringField $carp_peer;
public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) {
# Define model attributes
@@ -122,6 +124,20 @@ class VirtualIP extends Model {
help_text: 'The current CARP status of this virtual IP. This will display show whether this CARP node ' .
'is the primary or backup peer.',
);
+ $this->carp_mode = new StringField(
+ default: 'mcast',
+ choices: ['mcast', 'ucast'],
+ conditions: ['mode' => 'carp'],
+ help_text: 'The CARP mode to use for this virtual IP. Please note this field is exclusive to ' .
+ 'pfSense Plus and has no effect on CE.',
+ );
+ $this->carp_peer = new StringField(
+ required: true,
+ conditions: ['carp_mode' => 'ucast'],
+ validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)],
+ help_text: 'The IP address of the CARP peer. Please note this field is exclusive to pfSense Plus and ' .
+ 'has no effect on CE.',
+ );
parent::__construct($id, $parent_id, $data, ...$options);
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc
index bff7c7cf5..4a67f54b5 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/OpenAPISchema.inc
@@ -350,8 +350,8 @@ class OpenAPISchema extends Schema {
'explode' => true,
'description' =>
'The arbitrary query parameters to include in the request.
' .
- 'Note: This does not define an real parameter, rather it allows for any arbitrary query ' .
- 'parameters to be included in the request.',
+ 'Note: This does not define a real parameter (e.g. there is no `query` parameter), ' .
+ 'rather it allows for any arbitrary query parameters to be included in the request.',
'schema' => [
'type' => 'object',
'default' => new stdClass(),
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc
index e2d0d8411..306928de6 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreModelTestCase.inc
@@ -7,10 +7,13 @@ use RESTAPI;
use RESTAPI\Caches\RESTAPIVersionReleasesCache;
use RESTAPI\Core\Auth;
use RESTAPI\Core\Model;
+use RESTAPI\Models\DHCPServer;
+use RESTAPI\Models\DHCPServerStaticMapping;
use RESTAPI\Models\FirewallAlias;
use RESTAPI\Models\InterfaceVLAN;
use RESTAPI\Models\SystemStatus;
use RESTAPI\Models\Test;
+use function RESTAPI\Models\DHCPServerStaticMapping;
class APICoreModelTestCase extends RESTAPI\Core\TestCase {
/**
@@ -1197,4 +1200,52 @@ class APICoreModelTestCase extends RESTAPI\Core\TestCase {
# Remove the write lock
unlink(Model::WRITE_LOCK_FILE);
}
+
+ /**
+ * Ensure the copy() method creates an unlinked copy of the Model object.
+ */
+ public function test_copy(): void {
+ # Create a new FirewallAlias model to test with
+ $alias = new FirewallAlias(name: 'test_alias', type: 'host', address: ['1.2.3.4']);
+ $alias->id = 5;
+ $alias_copy = $alias->copy();
+
+ # Ensure the values are equal
+ $this->assert_equals($alias->to_internal(), $alias_copy->to_internal());
+ $this->assert_equals($alias_copy->id, 5);
+ $this->assert_equals($alias->to_representation(), $alias_copy->to_representation());
+
+ # Change the copy's values and ensure the original values are not changed
+ $alias_copy->name->value = 'new_name';
+ $this->assert_not_equals($alias->name->value, $alias_copy->name->value);
+ }
+
+ /**
+ * Ensure the copy() method does not use excessive memory in larger datasets. This is a regression test for #617.
+ */
+ public function test_copy_memory_usage(): void {
+ # Generate lots of DHCP Server Static Mappings
+ $static_mappings = [];
+ $used_macs = [];
+ while (count($static_mappings) < 450) {
+ # Generate a unique mac address for each static mapping
+ $mac = RESTAPI\Core\Tools\generate_mac_address();
+ if (in_array($mac, $used_macs)) {
+ continue;
+ }
+ $static_mappings[] = ['mac' => $mac];
+ }
+
+ # Update the DHCP server with our static mappings
+ $dhcp_server = new DHCPServer(id: 'lan', staticmap: $static_mappings);
+ $dhcp_server->update();
+
+ # Read a static mapping and copy it
+ $static_mapping = new DHCPServerStaticMapping(id: 50, parent_id: 'lan');
+ $static_mapping->copy();
+
+ # Remove the static mappings
+ $dhcp_server->staticmap->value = [];
+ $dhcp_server->update(apply: true);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc
index 00779f693..f62b220ac 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreToolsTestCase.inc
@@ -6,6 +6,7 @@ require_once 'RESTAPI/autoloader.inc';
use RESTAPI\Core\TestCase;
use function RESTAPI\Core\Tools\bandwidth_to_bits;
+use function RESTAPI\Core\Tools\generate_mac_address;
use function RESTAPI\Core\Tools\is_assoc_array;
use function RESTAPI\Core\Tools\to_upper_camel_case;
@@ -52,4 +53,13 @@ class APICoreToolsTestCase extends TestCase {
$this->assert_equals(to_upper_camel_case('this is a test'), 'ThisIsATest');
$this->assert_equals(to_upper_camel_case('_this is-a test'), 'ThisIsATest');
}
+
+ /**
+ * Checks that the generate_mac_address() function correctly generates a random MAC address.
+ */
+ public function test_generate_mac_address(): void {
+ $mac_address = generate_mac_address();
+ $this->assert_equals(strlen($mac_address), 17);
+ $this->assert_is_true(preg_match('/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/', $mac_address) === 1);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc
index 56f09d2ea..b724b5c7b 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsACMECertificateTestCase.inc
@@ -86,7 +86,7 @@ class APIModelsACMECertificateTestCase extends TestCase {
$renew = new ACMECertificateRenew(certificate: $cert->name->value, async: false);
$renew->create();
$this->assert_equals($renew->status->value, 'completed');
- $this->assert_str_contains($renew->result_log->value, "Renew: '{$cert->a_domainlist->value[0]['name']}'");
+ $this->assert_str_contains($renew->result_log->value, "Renewing: '{$cert->a_domainlist->value[0]['name']}'");
# Delete the ACME certificate
$cert->delete();
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc
index 71f6ba483..a5e5245d9 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateTestCase.inc
@@ -4,6 +4,8 @@ namespace RESTAPI\Tests;
use RESTAPI\Core\TestCase;
use RESTAPI\Models\Certificate;
+use RESTAPI\Models\CertificateAuthorityGenerate;
+use RESTAPI\Models\CertificateGenerate;
class APIModelsCertificateTestCase extends TestCase {
const EXAMPLE_CRT = "-----BEGIN CERTIFICATE-----
@@ -110,4 +112,56 @@ R02Pul8ulWQ8Kl3Q3pou8As7W1mMzA2DxQ==
},
);
}
+
+ /**
+ * Checks that certificates are relinked to their CAs (if found) when they are created/imported.
+ */
+ public function test_certificate_is_relinked_to_ca_on_create(): void {
+ # Create a CA we can use to test the relinking
+ $ca = new CertificateAuthorityGenerate(
+ descr: 'test_ca',
+ trust: true,
+ randomserial: true,
+ is_intermediate: false,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ );
+ $ca->always_apply = false; # Disable always_apply so we can test the create method without overloading cpu
+ $ca->create();
+
+ # Generate a new certificates using the CA
+ $cert = new CertificateGenerate(
+ descr: 'testcert',
+ caref: $ca->refid->value,
+ keytype: 'RSA',
+ keylen: 2048,
+ digest_alg: 'sha256',
+ lifetime: 3650,
+ type: 'user',
+ dn_country: 'US',
+ dn_state: 'UT',
+ dn_city: 'Salt Lake City',
+ dn_organization: 'ACME Org',
+ dn_organizationalunit: 'IT',
+ dn_commonname: 'testcert.example.com',
+ );
+ $cert->create();
+
+ # Capture the crt and prv values of the certificate and delete it
+ $crt = $cert->crt->value;
+ $prv = $cert->prv->value;
+ $cert->delete();
+
+ # Import the certificate and ensure it is automatically relinked to the CA
+ $cert = new Certificate(descr: 'testcert', type: 'user', crt: $crt, prv: $prv);
+ $cert->create();
+ $this->assert_equals($ca->refid->value, $cert->caref->value);
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc
index 21c5591c4..c98d8e425 100644
--- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallAliasTestCase.inc
@@ -144,4 +144,22 @@ class APIModelsFirewallAliasTestCase extends TestCase {
},
);
}
+
+ /**
+ * Checks that we can reference a nested alias during replace_all() calls. This is regression test for #619.
+ */
+ public function test_nested_alias_reference_in_replace_all(): void {
+ # Ensure we can reference a nested alias during replace_all() calls without an error being thrown
+ $this->assert_does_not_throw(
+ callable: function () {
+ $alias = new FirewallAlias();
+ $alias->replace_all(
+ data: [
+ ['name' => 'test_alias1', 'type' => 'host', 'address' => []],
+ ['name' => 'test_alias2', 'type' => 'host', 'address' => ['test_alias1']],
+ ],
+ );
+ },
+ );
+ }
}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceGRETestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceGRETestCase.inc
new file mode 100644
index 000000000..f7e431427
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceGRETestCase.inc
@@ -0,0 +1,100 @@
+assert_throws_response(
+ response_id: 'INTERFACE_GRE_HAS_NO_LOCAL_ADDRESS',
+ code: 400,
+ callable: function () {
+ $gre = new InterfaceGRE();
+ $gre->validate_extra();
+ },
+ );
+
+ # Ensure no error is thrown in a tunnel_local_addr is assigned
+ $this->assert_does_not_throw(
+ callable: function () {
+ $gre = new InterfaceGRE(tunnel_local_addr: '1.2.3.4');
+ $gre->validate_extra();
+ },
+ );
+
+ # Ensure no error is thrown in a tunnel_local_addr6 is assigned
+ $this->assert_does_not_throw(
+ callable: function () {
+ $gre = new InterfaceGRE(tunnel_local_addr6: '1234::1');
+ $gre->validate_extra();
+ },
+ );
+ }
+
+ /**
+ * Ensure an existing GRE cannot be deleted if it is in use.
+ */
+ public function test_delete_in_use(): void {
+ # Ensure an error is thrown if the GRE is in use
+ $this->assert_throws_response(
+ response_id: 'INTERFACE_GRE_CANNOT_DELETE_WHILE_IN_USE',
+ code: 409,
+ callable: function () {
+ $gre = new InterfaceGRE(
+ if: 'lan',
+ remote_addr: '1.2.3.4',
+ tunnel_local_addr: '4.3.2.1',
+ tunnel_remote_addr: '1.1.2.2',
+ tunnel_remote_net: 32,
+ );
+ $gre->create();
+
+ # Mock a GRE interface in use
+ InterfaceGRE::set_config('interfaces/opt99', ['if' => $gre->greif->value]);
+ $gre->delete();
+ },
+ );
+
+ # Remove all GRE interfaces used in the test
+ InterfaceGRE::del_config('interfaces/opt99');
+ InterfaceGRE::delete_all();
+ }
+
+ /**
+ * Ensure we can create, read, update and delete a GRE interface.
+ */
+ public function test_crud(): void {
+ # Create a new GRE tunnel and ensure it's interface is seen in ifconfig
+ $gre = new InterfaceGRE(
+ if: 'lan',
+ remote_addr: '127.1.2.3',
+ tunnel_local_addr: '127.3.2.1',
+ tunnel_remote_addr: '127.5.5.5',
+ tunnel_remote_net: 32,
+ );
+ $gre->create();
+ $ifconfig_output = new Command('ifconfig');
+ $this->assert_str_contains($ifconfig_output->output, $gre->greif->value);
+ $this->assert_str_contains($ifconfig_output->output, 'tunnel inet 192.168.1.1 --> 127.1.2.3');
+ $this->assert_str_contains($ifconfig_output->output, 'inet 127.3.2.1 --> 127.5.5.5 netmask 0xffffffff');
+
+ # Update the GRE tunnel and ensure the changes are reflected in ifconfig
+ $gre->remote_addr->value = '2.3.4.5';
+ $gre->update();
+ $ifconfig_output = new Command('ifconfig');
+ $this->assert_str_does_not_contain($ifconfig_output->output, 'tunnel inet 192.168.1.1 --> 127.1.2.3');
+ $this->assert_str_contains($ifconfig_output->output, 'tunnel inet 192.168.1.1 --> 2.3.4.5');
+
+ # Delete the GRE tunnel and ensure it's interface is no longer seen in ifconfig
+ $gre->delete();
+ $ifconfig_output = new Command('ifconfig');
+ $this->assert_str_does_not_contain($ifconfig_output->output, $gre->greif->value);
+ }
+}
diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceLAGGTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceLAGGTestCase.inc
new file mode 100644
index 000000000..84ee7ea79
--- /dev/null
+++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsInterfaceLAGGTestCase.inc
@@ -0,0 +1,108 @@
+assert_throws_response(
+ response_id: 'INTERFACE_LAGG_MEMBER_DOES_NOT_EXIST',
+ code: 400,
+ callable: function () {
+ $lagg = new InterfaceLAGG();
+ $lagg->validate_members('nonexistent');
+ },
+ );
+
+ # Ensure no error is thrown if the member interface exists
+ $this->assert_does_not_throw(
+ callable: function () {
+ $lagg = new InterfaceLAGG();
+ $lagg->validate_members($this->env['PFREST_OPT1_IF']);
+ },
+ );
+ }
+
+ /**
+ * Checks that the `failovermaster` field must be a member of the LAGG.
+ */
+ public function test_failovermaster_field_must_be_member_of_lagg(): void {
+ # Ensure an error is thrown if the failover master is not a member
+ $this->assert_throws_response(
+ response_id: 'INTERFACE_LAGG_MASTER_NOT_MEMBER',
+ code: 400,
+ callable: function () {
+ $lagg = new InterfaceLAGG();
+ $lagg->members->value = [$this->env['PFREST_OPT1_IF']];
+ $lagg->validate_failovermaster('nonexistent');
+ },
+ );
+
+ # Ensure no error is thrown if the failover master is a member
+ $this->assert_does_not_throw(
+ callable: function () {
+ $lagg = new InterfaceLAGG();
+ $lagg->members->value = [$this->env['PFREST_OPT1_IF']];
+ $lagg->validate_failovermaster($this->env['PFREST_OPT1_IF']);
+ },
+ );
+ }
+
+ /**
+ * Ensure that we can create, read, update and delete a LAGG interface.
+ */
+ public function test_crud(): void {
+ # Create a new LAGG interface
+ $lagg = new InterfaceLAGG(members: [$this->env['PFREST_OPT1_IF']], proto: 'lacp');
+ $lagg->create();
+ $this->assert_is_not_empty($lagg->laggif->value);
+ $ifconfig_output = new Command("ifconfig {$lagg->laggif->value}");
+ $this->assert_str_contains($ifconfig_output->output, 'laggproto lacp');
+ $this->assert_str_contains($ifconfig_output->output, "laggport: {$this->env['PFREST_OPT1_IF']}");
+
+ # Update the LAGG interface
+ $lagg->proto->value = 'failover';
+ $lagg->update();
+ $ifconfig_output = new Command("ifconfig {$lagg->laggif->value}");
+ $this->assert_str_contains($ifconfig_output->output, 'laggproto failover');
+ $this->assert_str_does_not_contain($ifconfig_output->output, 'laggproto lacp');
+
+ # Delete the LAGG interface
+ $lagg->delete();
+ $ifconfig_output = new Command("ifconfig {$lagg->laggif->value}");
+ $this->assert_str_contains($ifconfig_output->output, 'does not exist');
+ }
+
+ /**
+ * Ensure that we cannot delete a LAGG that is in use.
+ */
+ public function test_cannot_delete_lagg_in_use(): void {
+ # Create a new LAGG
+ $lagg = new InterfaceLAGG(members: [$this->env['PFREST_OPT1_IF']], proto: 'lacp');
+ $lagg->create();
+
+ # Mock an interface that is using the LAGG
+ InterfaceLAGG::set_config('interfaces/opt99', ['if' => $lagg->laggif->value]);
+
+ # Ensure we cannot delete the LAGG
+ $this->assert_throws_response(
+ response_id: 'INTERFACE_LAGG_CANNOT_BE_DELETED_WHILE_IN_USE',
+ code: 409,
+ callable: function () use ($lagg) {
+ # Try to delete the LAGG
+ $lagg->delete();
+ },
+ );
+
+ # Remove the mock interface and actually delete the LAGG
+ InterfaceLAGG::del_config('interfaces/opt99');
+ $lagg->delete();
+ }
+}
diff --git a/requirements.txt b/requirements.txt
index eed3ea924..cc5659e41 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,3 @@ jinja2~=3.1.4
pylint~=3.3.1
black~=24.10.0
mkdocs~=1.6.1
-boto3~=1.35.54
\ No newline at end of file
diff --git a/tools/make_package.py b/tools/make_package.py
index 42d52959c..ec58dba39 100644
--- a/tools/make_package.py
+++ b/tools/make_package.py
@@ -140,7 +140,7 @@ def build_on_remote_host(self):
"mkdir -p ~/build/",
f"rm -rf ~/build/{REPO_NAME}",
f"git clone https://github.com/{REPO_OWNER}/{REPO_NAME}.git ~/build/{REPO_NAME}/",
- f"git -C ~/build/{REPO_NAME} checkout " + self.args.branch,
+ f"git -C ~/build/{REPO_NAME} checkout '{self.args.branch}'",
f"composer install --working-dir ~/build/{REPO_NAME}",
f"cp -r ~/build/{REPO_NAME}/vendor/* {includes_dir}",
f"python3 ~/build/{REPO_NAME}/tools/make_package.py --tag {self.args.tag} {notests}",