From 52d3bb1c831fa1214320da5117a00f88addcef1e Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Wed, 28 May 2025 13:59:06 +0300 Subject: [PATCH 01/62] Basic API to OpenVPN Client Export Utility. Initial commit. --- .../VPNOpenVPNClientExportEndpoint.inc | 24 + .../RESTAPI/Models/OpenVPNClientExport.inc | 604 ++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc new file mode 100644 index 00000000..675db843 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/openvpn/clientexport'; + $this->model_name = 'OpenVPNClientExport'; + $this->request_method_options = ['GET', 'POST']; + $this->many = false; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc new file mode 100644 index 00000000..86833da8 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc @@ -0,0 +1,604 @@ +packages = ['pfSense-pkg-openvpn-client-export']; + $this->package_includes = ['openvpn-client-export.inc']; + $this->always_apply = true; + $this->many = false; + + + # + # some internal vars + # + $this->want_cert = false; + $this->nokeys = false; + + # + # Set model fields + # + $this->act = new StringField( + required: true, + choices: ['confinline', 'confzip' ], + help_text: 'Export format' + ); + + $this->vpnid = new ForeignModelField( + model_name: 'OpenVPNServer', + model_field: 'vpnid', + required: true, + help_text: 'The VPN ID of the OpenVPN server this client export corresponds to.', + ); + + $this->uid = new ForeignModelField( + model_name: 'User', + model_field: 'uid', + required: false, + default: null, + allow_null: true, + help_text: 'User ID' + ); + $this->crtref = new ForeignModelField( + model_name: 'Certificate', + model_field: 'refid', + required: false, + default: null, + allow_null: true, + help_text: 'Certificate refid' + ); + + # + # Client Connection Behavior + # + $this->useaddr = new StringField( + required: false, + default: 'serveraddr', + help_text: 'Host Name Resolution' + ); + $this->verifyservercn = new StringField( + default: 'auto', + choices: ['auto', 'none'], + help_text: 'Verify Server CN' + ); + $this->blockoutsidedns = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Block Outside DNS' + ); + $this->legacy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Legacy Client' + ); + $this->silent = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Silent Installer' + ); + $this->bindmode = new StringField( + default: 'nobind', + choices: ['nobind', 'lport0', 'bind'], + help_text: 'Bind Mode' + ); + + # + # Certificate Export Options + # + $this->usepkcs11 = new StringField( + choices: ['yes', 'no'], + default: 'no', + help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.' + ); + $this->pkcs11providers = new StringField( + default: '', + conditions: ['usepkcs11' => 'yes'], + ); + $this->pkcs11id = new StringField( + default: '', + conditions: ['usepkcs11' => 'yes'], + ); + $this->usetoken = new StringField( + choices: ['yes', 'no'], + default: 'no', + help_text: 'Use Microsoft Certificate Storage instead of local files.' + ); + $this->usepass = new StringField( + default: '', + choices: ['yes', 'no'], + help_text: 'Password Protect Certificate' + ); + $this->pass = new StringField( + default: '', + sensitive: true, + conditions: ['usepass' => 'yes'], + help_text: 'Certificate Password' + ); + $this->p12encryption = new StringField( + default: 'high', + choices: ['high', 'low', 'legacy'], + help_text: 'PKCS#12 Encryption' + ); + + # + # Proxy Options + # + $this->useproxy = new StringField( + default: 'no', + choices: ['yes', 'no'], + help_text: 'Use A Proxy' + ); + $this->useproxytype = new StringField( + default: 'http', + choices: ['http', 'socks'], + conditions: ['useproxy' => 'yes'], + help_text: 'Proxy Type' + ); + $this->proxyaddr = new StringField( + required: true, + allow_empty: false, + default: null, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + conditions: ['useproxy' => 'yes'], + help_text: 'Proxy IP Address', + ); + $this->proxyport = new PortField( + required: true, + unique: true, + default: null, + allow_alias: false, + allow_range: false, + conditions: ['useproxy' => 'yes'], + help_text: 'Proxy Port', + ); + $this->useproxypass = new StringField( + required: true, + default: null, + choices: ['none', 'basic', 'ntlm'], + conditions: ['useproxy' => true], + help_text: 'Proxy Authentication' + ); + $this->proxyuser = new StringField( + required: true, + default: null, + conditions: ['useproxy' => 'yes', 'useproxypass' => ['basic', 'ntlm']], + help_text: 'Proxy Username' + ); + $this->proxypass = new StringField( + required: true, + conditions: ['useproxy' => 'yes', 'useproxypass' => ['basic', 'ntlm']], + help_text: 'Proxy Password' + ); + + # + # Advanced + # + $this->advancedoptions = new StringField( + required: false, + default: '', + allow_empty: true, + help_text: 'Additional options to add to the OpenVPN client export configuration.', + ); + + # + $this->clientconfig = new Base64Field( + default: null, + allow_null: true, + read_only: true, + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + + + /** + * many logic/code copied from /usr/local/www/vpn_openvpn_export.php. + */ + public function _create(): void { + global $config, $input_errors; + + $act = $this->act->value; + + $srvid = $this->vpnid->value; + + $useaddr = $this->useaddr->value; + + $verifyservercn = $this->verifyservercn->value; + $blockoutsidedns = $this->blockoutsidedns->value; + $legacy = $this->legacy->value; + $silent = $this->silent->value; + $bindmode = $this->bindmode->value; + + $usepkcs11 = $this->usepkcs11->value; + if ( $usepkcs11 === "yes" ) { + $usepkcs11 = true; + } else { + $usepkcs11 = false; + } + $pkcs11providers = $this->pkcs11providers->value; + $pkcs11id = $this->pkcs11id->value; + $usetoken = $this->usetoken->value; + if ( $usetoken === "yes" ) { + $usetoken = true; + } else { + $usetoken = false; + } + + $password = $this->pass->value; + $p12encryption = $this->p12encryption->value; + + $advancedoptions = $this->advancedoptions->value; + + + # + # + # + $srvcfg = get_openvpnserver_by_id($srvid); + + $want_cert = $this->want_cert; + $nokeys = $this->nokeys; + + $cert = $this->cert; + $crtid = $this->crtid; + $usrid = $this->usrid; + + if (($srvcfg['mode'] != "server_user") && + !$usepkcs11 && + !$usetoken && + empty($cert['prv'])) { + throw new ServerError( + message: 'A private key cannot be empty if PKCS#11 or Microsoft Certificate Storage is not used.', + response_id: 'FIELD_INVALID_CHOICE' + ); + } + + + $proxy = ""; + $useproxy = $this->useproxy->value; + if ( $useproxy == "yes" ) { + $proxy = array(); + $proxy['ip'] = $this->proxy_addr->value; + $proxy['port'] = $this->proxy_port->value; + $proxy['proxy_type'] = $this->proxy_type->value; + $proxy['proxy_authtype'] = $this->proxy_authtype->value; + if ( $proxy['proxy_authtype'] != 'none' ) { + $proxy['user'] = $this->proxy_user->value; + $proxy['password'] = $this->proxy_password->value; + } + } + + + $exp_name = openvpn_client_export_prefix($srvid, $usrid, $crtid); + + if (substr($act, 0, 4) == "conf") { + switch ($act) { + case "confzip": + $exp_name = urlencode($exp_name . "-config.zip"); + $expformat = "zip"; + break; + case "conf_yealink_t28": + $exp_name = urlencode("client.tar"); + $expformat = "yealink_t28"; + break; + case "conf_yealink_t38g": + $exp_name = urlencode("client.tar"); + $expformat = "yealink_t38g"; + break; + case "conf_yealink_t38g2": + $exp_name = urlencode("client.tar"); + $expformat = "yealink_t38g2"; + break; + case "conf_snom": + $exp_name = urlencode("vpnclient.tar"); + $expformat = "snom"; + break; + case "confinline": + $exp_name = urlencode($exp_name . "-config.ovpn"); + $expformat = "inline"; + break; + case "confinlinedroid": + $exp_name = urlencode($exp_name . "-android-config.ovpn"); + $expformat = "inlinedroid"; + break; + case "confinlineconnect": + $exp_name = urlencode($exp_name . "-connect-config.ovpn"); + $expformat = "inlineconnect"; + break; + case "confinlinevisc": + $exp_name = urlencode($exp_name . "-viscosity-config.ovpn"); + $expformat = "inlinevisc"; + break; + default: + $exp_name = urlencode($exp_name . "-config.ovpn"); + $expformat = "baseconf"; + } + $exp_path = openvpn_client_export_config($srvid, $usrid, $crtid, $useaddr, $verifyservercn, $blockoutsidedns, $legacy, $bindmode, $usetoken, $nokeys, $proxy, $expformat, $password, $p12encryption, false, false, $advancedoptions, $usepkcs11, $pkcs11providers, $pkcs11id); + } + + if ($act == "visc") { + $exp_name = urlencode($exp_name . "-Viscosity.visc.zip"); + $exp_path = viscosity_openvpn_client_config_exporter($srvid, $usrid, $crtid, $useaddr, $verifyservercn, $blockoutsidedns, $legacy, $bindmode, $usetoken, $password, $p12encryption, $proxy, $advancedoptions, $usepkcs11, $pkcs11providers, $pkcs11id); + } + + if (substr($act, 0, 4) == "inst") { + $openvpn_version = substr($act, 5); + $exp_name = "openvpn-{$exp_name}-install-"; + switch ($openvpn_version) { + case "Win7": + $legacy = true; + $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win7.exe"; + break; + case "Win10": + $legacy = true; + $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win10.exe"; + break; + case "x86-previous": + $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-x86.exe"; + break; + case "x64-previous": + $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-amd64.exe"; + break; + case "x86-current": + $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-x86.exe"; + break; + case "x64-current": + default: + $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-amd64.exe"; + break; + } + + $exp_name = urlencode($exp_name); + $exp_path = openvpn_client_export_installer($srvid, $usrid, $crtid, $useaddr, $verifyservercn, $blockoutsidedns, $legacy, $bindmode, $usetoken, $password, $p12encryption, $proxy, $advancedoptions, substr($act, 5), $usepkcs11, $pkcs11providers, $pkcs11id, $silent); + } + + /* pfSense >= 2.5.0 with OpenVPN >= 2.5.0 has ciphers not compatible with + * legacy clients, check for those and warn */ + if ($legacy) { + global $legacy_incompatible_ciphers; + $settings = get_openvpnserver_by_id($srvid); + if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { + $input_errors[] = gettext("The Fallback Data Encryption Algorithm for the selected server is not compatible with Legacy clients."); + } + } + + if (!$exp_path) { + $input_errors[] = "Failed to export config files!"; + } + + if (empty($input_errors)) { + if (($act == "conf") || (substr($act, 0, 10) == "confinline")) { + $exp_size = strlen($exp_path); + } else { + $exp_size = filesize($exp_path); + } + header('Pragma: '); + header('Cache-Control: '); + header("Content-Type: application/octet-stream"); + header("Content-Disposition: attachment; filename={$exp_name}"); + header("Content-Length: $exp_size"); + if (($act == "conf") || (substr($act, 0, 10) == "confinline")) { + echo $exp_path; + } else { + readfile($exp_path); + @unlink($exp_path); + } + exit; + } else { + throw new ServerError( + message: 'Some errors occured: ' . $input_errors[0], + response_id: 'UNKNONW_ERROR' + ); + } + + + + } + + + /** + * + */ + private function find_usrid_by_uid($uid) { + global $config, $input_errors; + + foreach ( $config['system']['user'] as $idx => $u ) { + if ( intval($u['uid']) == intval($uid) ) { + return $idx; + } + } + + return -1; + } + + + + /** + * + */ + private function find_crtid_by_crtref($crtref) { + global $config, $input_errors; + + foreach ( $config['cert'] as $idx => $cert ){ + if ( $cert['refid'] == $crtref && $cert['type'] == 'user' ) { + return $idx; + } + } + + return -1; + } + + + /** + * + */ + public function validate_useaddr(string $useaddr): string { + + if (!(is_ipaddr($useaddr) || is_hostname($useaddr) || + in_array($useaddr, array("serveraddr", "servermagic", "servermagichost", "serverhostname")))) { + throw new ValidationError( + message: "An IP address or hostname must be specified.", + response_id: 'useaddr error' + ); + } + + return $useaddr; + } + + + /** + * + */ + public function validate_extra(): void { + + $srv = $this->vpnid->get_related_model(); + $u = $this->uid->value; + $crtref = $this->crtref->value; + + # + # check if Certificate required for this VPN-instance + # + if ( $srv->mode->value != 'server_user' ) { + $this->want_cert = true; + + if ( ! isset($crtref) || empty($crtref) || is_null($crtref) ) { + throw new ValidationError( + message: "certref must be specified for this vpnid", + response_id: 'CERTREF_REQUIRED_FOR_THIS_SERVER' + ); + } + + $cert_model = $this->crtref->get_related_model(); + if ( $cert_model->type->value != 'user' ) { + throw new ServerError( + message: "Bad cert type for this crtref", + response_id: 'FIELD_INVALID_CHOICE' + ); + } + + $this->cert = array( + 'crt' => $cert_model->crt->value, + 'prv' => $cert_model->prv->value, + ); + + $this->crtid = $this->find_crtid_by_crtref($crtref); + } + + + if ( $srv->mode->value != 'server_user' ) { + $this->nokeys = true; + } + + # + # check if User required for this VPN-instance + # + if ( isset($srv->authmode->value) && in_array('Local Database', $srv->authmode->value) ) { + + if ( ! isset($u) || empty($u) || is_null($u) ) { + throw new ValidationError( + message: "uid must be specified for this vpnid", + response_id: 'UID_REQUIRED_FOR_THIS_SERVER' + ); + } + + $user_model = $this->uid->get_related_model(); + + $this->usrid = $this->find_usrid_by_uid($user_model->uid->value); + + # + # check if this Crtref velongs to this User + # + $crtref_founded = 0; + foreach ( $user_model->cert->value as $idx => $c ) { + if ( $c == $this->crtref->value ) { + $crtref_founded = 1; + $this->crtid = $idx; // openvpn-client-export.inc require crtid as index of user certificates not all certificates + break; + } + } + if ( $crtref_founded != 1 ) { + throw new ValidationError( + message: "Can't find this crtref for this uid", + response_id: 'CRTREF_UID_MISMATCH' + ); + } + + } + + $usetoken = $this->usetoken->value; + $act = $this->act->value; + if ( ($usetoken == "yes") && ($act == "confinline") ) { + throw new ValidationError( + message: 'Microsoft Certificate Storage cannot be used with an Inline configuration.', + response_id: 'FIELD_INVALID_CHOICE' + ); + } + if ( ($usetoken == "yes") + && (($act == "conf_yealink_t28") || ($act == "conf_yealink_t38g") || ($act == "conf_yealink_t38g2") || ($act == "conf_snom"))) { + throw new ValidationError( + message: 'Microsoft Certificate Storage cannot be used with a Yealink or SNOM configuration.', + response_id: 'FIELD_INVALID_CHOICE' + ); + } + } + +} From a5507818543cd3b4b9c16f8915181e49716f3694 Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Thu, 29 May 2025 12:14:15 +0300 Subject: [PATCH 02/62] Only POST method allowed --- .../pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc index 675db843..794b5c81 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -15,7 +15,7 @@ class VPNOpenVPNClientExportEndpoint extends Endpoint { # Set Endpoint attributes $this->url = '/api/v2/vpn/openvpn/clientexport'; $this->model_name = 'OpenVPNClientExport'; - $this->request_method_options = ['GET', 'POST']; + $this->request_method_options = [ 'POST' ]; $this->many = false; # Construct the parent Endpoint object From 0fbf460259ff00563efe3fe2134e96bd3c5d257f Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Mon, 2 Jun 2025 22:07:02 +0300 Subject: [PATCH 03/62] Configure FreeRADIUS Interfaces --- .../ServicesFreeRADIUSInterfaceEndpoint.inc | 27 ++++ .../ServicesFreeRADIUSInterfacesEndpoint.inc | 27 ++++ .../RESTAPI/Models/FreeRADIUSInterface.inc | 133 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc new file mode 100644 index 00000000..bf1bdd93 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc @@ -0,0 +1,27 @@ +url = '/api/v2/services/freeradius/interface'; + $this->model_name = 'FreeRADIUSInterface'; + $this->many = false; + $this->request_method_options = ['GET', 'POST', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc new file mode 100644 index 00000000..aa9011ff --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc @@ -0,0 +1,27 @@ +url = '/api/v2/services/freeradius/interfaces'; + $this->model_name = 'FreeRADIUSInterface'; + $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/Models/FreeRADIUSInterface.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc new file mode 100644 index 00000000..d5a86375 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc @@ -0,0 +1,133 @@ +packages = ['pfSense-pkg-freeradius3']; + $this->package_includes = ['freeradius.inc']; + $this->config_path = 'installedpackages/freeradiusinterfaces/config'; + $this->many = true; + $this->always_apply = true; + $this->unique_together_fields = ['addr', 'port', 'ipv']; + + # + # Set model fields + # + $this->addr = new StringField( + required: true, +// unique: true, + internal_name: 'varinterfaceip', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_keywords: ['*'])], + help_text: 'The IP address of the listening interface. If you choose * then it means all interfaces.' + ); + $this->port = new PortField( + required: false, + internal_name: 'varinterfaceport', + allow_alias: false, + allow_range: false, + default: '1812', + help_text: 'The port number of the listening interface. Different interface types need different ports.' + ); + $this->type = new StringField( + required: false, + internal_name: 'varinterfacetype', + choices: [ 'auth', 'acct' ], + default: 'auth', + help_text: 'The type of the listening interface: Authentication/Accounting.' + ); + $this->ipv = new StringField( + internal_name: 'varinterfaceipversion', + choices: [ 'ipaddr', 'ipv6addr' ], + allow_empty: true, + default: 'ipaddr', + help_text: 'The IP version of the listening interface.' + ); + $this->description = new StringField( + required: false, + allow_empty: true, + default: "", + validators: [ + new RegexValidator(pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", error_msg: 'Value contains invalid characters.'), + ], + help_text: 'The description for this interface.' + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + + /** + * Perform additional validation on the Model's fields and data. + */ + public function validate_extra(): void { + $input_errors = []; + + $iface_addr = $this->addr->value; + if ( $iface_addr != '*' ) { + if ( is_ipaddrv4($iface_addr) ) { + $this->ipv->value = 'ipaddr'; + } elseif ( is_ipaddrv6($iface_addr) ) { + $this->ipv->value = 'ipv6addr'; + } else { + // we don't must be here because Model validator for $this->addr + $input_errors[] = "Cann't recognize IP-address={$iface_addr}"; + } + } + + # Run service level validations + $iface = $this->to_internal(); + freeradius_validate_interfaces($iface, $input_errors); + + # If there were validation errors that were not caught by the model fields, throw a ValidationError. + # Ideally the Model should catch all validation errors itself so prompt the user to report this error + if (!empty($input_errors)) { + throw new ValidationError( + message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . + 'https://github.com/jaredhendrickson13/pfsense-api/issues/new', + response_id: 'FREERADIUS_USER_UNEXPECTED_VALIDATION_ERROR', + ); + } + } + + + /** + * Apply the creation of this User. + */ + public function apply() { + freeradius_settings_resync(); + } +} From beaf443a8da156bb6d52be4a40a999ba52b6ae1b Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Mon, 2 Jun 2025 22:13:09 +0300 Subject: [PATCH 04/62] comment fixed --- .../RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc index aa9011ff..e61434c5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc @@ -7,7 +7,7 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Endpoint; /** - * Defines an Endpoint for interacting with a single interface Model object at + * Defines an Endpoint for interacting with a many interface Model object at * /api/v2/services/freeradius/interfaces */ class ServicesFreeRADIUSInterfacesEndpoint extends Endpoint { From e2e279576777eae1c972dd35f96f340d5aab12fe Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Tue, 3 Jun 2025 19:41:17 +0300 Subject: [PATCH 05/62] Change construction for $this->ipv --- .../pkg/RESTAPI/Models/FreeRADIUSInterface.inc | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc index d5a86375..6d7ee769 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc @@ -48,23 +48,22 @@ class FreeRADIUSInterface extends Model { # Set model fields # $this->addr = new StringField( - required: true, -// unique: true, internal_name: 'varinterfaceip', + required: true, validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_keywords: ['*'])], help_text: 'The IP address of the listening interface. If you choose * then it means all interfaces.' ); $this->port = new PortField( - required: false, internal_name: 'varinterfaceport', + required: false, allow_alias: false, allow_range: false, default: '1812', help_text: 'The port number of the listening interface. Different interface types need different ports.' ); $this->type = new StringField( - required: false, internal_name: 'varinterfacetype', + required: false, choices: [ 'auth', 'acct' ], default: 'auth', help_text: 'The type of the listening interface: Authentication/Accounting.' @@ -73,7 +72,8 @@ class FreeRADIUSInterface extends Model { internal_name: 'varinterfaceipversion', choices: [ 'ipaddr', 'ipv6addr' ], allow_empty: true, - default: 'ipaddr', + required: true, + conditions: [ 'addr' => '*' ], help_text: 'The IP version of the listening interface.' ); $this->description = new StringField( @@ -118,14 +118,14 @@ class FreeRADIUSInterface extends Model { throw new ValidationError( message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . 'https://github.com/jaredhendrickson13/pfsense-api/issues/new', - response_id: 'FREERADIUS_USER_UNEXPECTED_VALIDATION_ERROR', + response_id: 'FREERADIUS_INTERFACE_UNEXPECTED_VALIDATION_ERROR', ); } } /** - * Apply the creation of this User. + * Apply the action on Interface(s) */ public function apply() { freeradius_settings_resync(); From 6b19ba786dd3f95f95a14b66f3667155f1393c49 Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Tue, 3 Jun 2025 19:49:41 +0300 Subject: [PATCH 06/62] Configure FreeRADIUS Client(s) --- .../ServicesFreeRADIUSClientEndpoint.inc | 27 +++ .../ServicesFreeRADIUSClientsEndpoint.inc | 27 +++ .../pkg/RESTAPI/Models/FreeRADIUSClient.inc | 175 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc new file mode 100644 index 00000000..9f7673ab --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc @@ -0,0 +1,27 @@ +url = '/api/v2/services/freeradius/client'; + $this->model_name = 'FreeRADIUSClient'; + $this->many = false; + $this->request_method_options = ['GET', 'POST', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} + diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc new file mode 100644 index 00000000..f2182b5d --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc @@ -0,0 +1,27 @@ +url = '/api/v2/services/freeradius/clients'; + $this->model_name = 'FreeRADIUSClient'; + $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/Models/FreeRADIUSClient.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc new file mode 100644 index 00000000..7c0d439d --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc @@ -0,0 +1,175 @@ +packages = ['pfSense-pkg-freeradius3']; + $this->package_includes = ['freeradius.inc']; + $this->config_path = 'installedpackages/freeradiusclients/config'; + $this->many = true; + $this->always_apply = true; + + # + # Set model fields + # + $this->addr = new StringField( + internal_name: 'varclientip', + required: true, + unique: true, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], + help_text: 'The IP address or network of the RADIUS client(s) in CIDR notation. This is the IP of the NAS (switch, access point, firewall, router, etc.)' + ); + $this->ipv = new StringField( + internal_name: 'varclientipversion', + choices: [ 'ipaddr', 'ipv6addr' ], + allow_empty: true, + default: 'ipaddr', + help_text: 'The IP version of the this Client.' + ); + $this->shortname = new StringField( + internal_name: 'varclientshortname', + required: true, + allow_null: false, + help_text: 'A short name for the client. This is generally the hostname of the NAS.' + ); + $this->secret = new StringField( + internal_name: 'varclientsharedsecret', + required: true, + sensitive: true, + allow_empty: false, + help_text: 'This is the shared secret (password) which the NAS (switch, accesspoint, etc.) needs to communicate with the RADIUS server.' + ); + + $this->proto = new StringField( + internal_name: 'varclientproto', + choices: ['udp', 'tcp'], + allow_empty: true, + default: 'udp', + help_text: 'The protocol the client uses. (Default: udp)' + ); + $this->nastype = new StringField( + internal_name: 'varclientnastype', + choices: ['cisco', 'cvx', 'computone', 'digitro', 'livingston', 'juniper', 'max40xx', 'mikrotik', 'mikrotik_snmp', 'dot1x', 'other'], + allow_empty: true, + default: 'other', + help_text: 'The NAS type of the client. This is used by checkrad.pl for simultaneous use checks. (Default: other)' + ); + $this->msgauth = new StringField( + internal_name: 'varrequiremessageauthenticator', + choices: ['yes', 'no'], + default: 'no', + help_text: 'RFC5080 requires Message-Authenticator in Access-Request. But older NAS (switches or accesspoints) do not include that. (Default: no)' + ); + $this->maxconn = new IntegerField( + internal_name: 'varclientmaxconnections', + minimum: 1, + maximum: 32, + default: 16, + help_text: 'Takes only effect if you use TCP as protocol. Limits the number of simultaneous TCP connections from a client. (max=32)' + ); + $this->naslogin = new StringField( + internal_name: 'varclientlogininput', + allow_empty: true, + default: '', + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose (s)radutmp. (Default: empty) ' + ); + $this->naspassword= new StringField( + internal_name: 'varclientpasswordinput', + allow_empty: true, + default: '', + sensitive: true, + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose +(s)radutmp. (Default: empty) ' + ); + + $this->description = new StringField( + required: false, + allow_empty: true, + default: "", + validators: [ + new RegexValidator(pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", error_msg: 'Value contains invalid characters.'), + ], + help_text: 'The description for this interface.' + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + + /** + * Perform additional validation on the Model's fields and data. + */ + public function validate_extra(): void { + $input_errors = []; + +/* +*/ + $iface_addr = $this->addr->value; + if ( $iface_addr != '*' ) { + if ( is_ipaddrv4($iface_addr) ) { + $this->ipv->value = 'ipaddr'; + } elseif ( is_ipaddrv6($iface_addr) ) { + $this->ipv->value = 'ipv6addr'; + } else { + // we don't must be here because Model validator for $this->addr + $input_errors[] = "Cann't recognize IP-address={$iface_addr}"; + } + } + + # Run service level validations + $client = $this->to_internal(); + freeradius_validate_clients($iface, $input_errors); + + # If there were validation errors that were not caught by the model fields, throw a ValidationError. + # Ideally the Model should catch all validation errors itself so prompt the user to report this error + if (!empty($input_errors)) { + throw new ValidationError( + message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . + 'https://github.com/jaredhendrickson13/pfsense-api/issues/new', + response_id: 'FREERADIUS_USER_UNEXPECTED_VALIDATION_ERROR', + ); + } + } + + + /** + * Apply specific action on Client(s) + */ + public function apply() { + freeradius_clients_resync(); + } +} From d86f1130cdb117839f1377844dd22196590d15cf Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 8 Jun 2025 14:02:29 -0600 Subject: [PATCH 07/62] style: run prettier on new files --- .../ServicesFreeRADIUSClientEndpoint.inc | 1 - .../ServicesFreeRADIUSClientsEndpoint.inc | 1 - .../ServicesFreeRADIUSInterfaceEndpoint.inc | 1 - .../ServicesFreeRADIUSInterfacesEndpoint.inc | 1 - .../VPNOpenVPNClientExportEndpoint.inc | 2 +- .../pkg/RESTAPI/Models/FreeRADIUSClient.inc | 60 ++- .../RESTAPI/Models/FreeRADIUSInterface.inc | 32 +- .../RESTAPI/Models/OpenVPNClientExport.inc | 501 +++++++++--------- 8 files changed, 312 insertions(+), 287 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc index 9f7673ab..37b66148 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc @@ -24,4 +24,3 @@ class ServicesFreeRADIUSClientEndpoint extends Endpoint { parent::__construct(); } } - diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc index f2182b5d..b20aac1a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc @@ -24,4 +24,3 @@ class ServicesFreeRADIUSClientsEndpoint extends Endpoint { parent::__construct(); } } - diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc index bf1bdd93..c090c0a2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc @@ -24,4 +24,3 @@ class ServicesFreeRADIUSInterfaceEndpoint extends Endpoint { parent::__construct(); } } - diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc index e61434c5..69f393f4 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc @@ -24,4 +24,3 @@ class ServicesFreeRADIUSInterfacesEndpoint extends Endpoint { parent::__construct(); } } - diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc index 794b5c81..732978c5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -15,7 +15,7 @@ class VPNOpenVPNClientExportEndpoint extends Endpoint { # Set Endpoint attributes $this->url = '/api/v2/vpn/openvpn/clientexport'; $this->model_name = 'OpenVPNClientExport'; - $this->request_method_options = [ 'POST' ]; + $this->request_method_options = ['POST']; $this->many = false; # Construct the parent Endpoint object diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc index 7c0d439d..426af1b4 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc @@ -23,7 +23,6 @@ use RESTAPI\Validators\RegexValidator; * Defines a Model that represents FreeRADIUS Interfaces */ class FreeRADIUSClient extends Model { - public StringField $addr; public PortField $port; public StringField $type; @@ -51,27 +50,27 @@ class FreeRADIUSClient extends Model { required: true, unique: true, validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], - help_text: 'The IP address or network of the RADIUS client(s) in CIDR notation. This is the IP of the NAS (switch, access point, firewall, router, etc.)' + help_text: 'The IP address or network of the RADIUS client(s) in CIDR notation. This is the IP of the NAS (switch, access point, firewall, router, etc.)', ); $this->ipv = new StringField( internal_name: 'varclientipversion', - choices: [ 'ipaddr', 'ipv6addr' ], + choices: ['ipaddr', 'ipv6addr'], allow_empty: true, default: 'ipaddr', - help_text: 'The IP version of the this Client.' + help_text: 'The IP version of the this Client.', ); $this->shortname = new StringField( internal_name: 'varclientshortname', required: true, allow_null: false, - help_text: 'A short name for the client. This is generally the hostname of the NAS.' + help_text: 'A short name for the client. This is generally the hostname of the NAS.', ); $this->secret = new StringField( internal_name: 'varclientsharedsecret', required: true, sensitive: true, allow_empty: false, - help_text: 'This is the shared secret (password) which the NAS (switch, accesspoint, etc.) needs to communicate with the RADIUS server.' + help_text: 'This is the shared secret (password) which the NAS (switch, accesspoint, etc.) needs to communicate with the RADIUS server.', ); $this->proto = new StringField( @@ -79,70 +78,84 @@ class FreeRADIUSClient extends Model { choices: ['udp', 'tcp'], allow_empty: true, default: 'udp', - help_text: 'The protocol the client uses. (Default: udp)' + help_text: 'The protocol the client uses. (Default: udp)', ); $this->nastype = new StringField( internal_name: 'varclientnastype', - choices: ['cisco', 'cvx', 'computone', 'digitro', 'livingston', 'juniper', 'max40xx', 'mikrotik', 'mikrotik_snmp', 'dot1x', 'other'], + choices: [ + 'cisco', + 'cvx', + 'computone', + 'digitro', + 'livingston', + 'juniper', + 'max40xx', + 'mikrotik', + 'mikrotik_snmp', + 'dot1x', + 'other', + ], allow_empty: true, default: 'other', - help_text: 'The NAS type of the client. This is used by checkrad.pl for simultaneous use checks. (Default: other)' + help_text: 'The NAS type of the client. This is used by checkrad.pl for simultaneous use checks. (Default: other)', ); $this->msgauth = new StringField( internal_name: 'varrequiremessageauthenticator', choices: ['yes', 'no'], default: 'no', - help_text: 'RFC5080 requires Message-Authenticator in Access-Request. But older NAS (switches or accesspoints) do not include that. (Default: no)' + help_text: 'RFC5080 requires Message-Authenticator in Access-Request. But older NAS (switches or accesspoints) do not include that. (Default: no)', ); $this->maxconn = new IntegerField( internal_name: 'varclientmaxconnections', minimum: 1, maximum: 32, default: 16, - help_text: 'Takes only effect if you use TCP as protocol. Limits the number of simultaneous TCP connections from a client. (max=32)' + help_text: 'Takes only effect if you use TCP as protocol. Limits the number of simultaneous TCP connections from a client. (max=32)', ); $this->naslogin = new StringField( internal_name: 'varclientlogininput', allow_empty: true, default: '', - help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose (s)radutmp. (Default: empty) ' + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose (s)radutmp. (Default: empty) ', ); - $this->naspassword= new StringField( + $this->naspassword = new StringField( internal_name: 'varclientpasswordinput', allow_empty: true, default: '', sensitive: true, help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose -(s)radutmp. (Default: empty) ' +(s)radutmp. (Default: empty) ', ); $this->description = new StringField( required: false, allow_empty: true, - default: "", + default: '', validators: [ - new RegexValidator(pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", error_msg: 'Value contains invalid characters.'), + new RegexValidator( + pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", + error_msg: 'Value contains invalid characters.', + ), ], - help_text: 'The description for this interface.' + help_text: 'The description for this interface.', ); parent::__construct($id, $parent_id, $data, ...$options); } - /** * Perform additional validation on the Model's fields and data. */ public function validate_extra(): void { $input_errors = []; -/* -*/ + /* + */ $iface_addr = $this->addr->value; - if ( $iface_addr != '*' ) { - if ( is_ipaddrv4($iface_addr) ) { + if ($iface_addr != '*') { + if (is_ipaddrv4($iface_addr)) { $this->ipv->value = 'ipaddr'; - } elseif ( is_ipaddrv6($iface_addr) ) { + } elseif (is_ipaddrv6($iface_addr)) { $this->ipv->value = 'ipv6addr'; } else { // we don't must be here because Model validator for $this->addr @@ -165,7 +178,6 @@ class FreeRADIUSClient extends Model { } } - /** * Apply specific action on Client(s) */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc index 6d7ee769..b47689c3 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc @@ -23,7 +23,6 @@ use RESTAPI\Validators\RegexValidator; * Defines a Model that represents FreeRADIUS Interfaces */ class FreeRADIUSInterface extends Model { - public StringField $addr; public PortField $port; public StringField $type; @@ -51,7 +50,7 @@ class FreeRADIUSInterface extends Model { internal_name: 'varinterfaceip', required: true, validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_keywords: ['*'])], - help_text: 'The IP address of the listening interface. If you choose * then it means all interfaces.' + help_text: 'The IP address of the listening interface. If you choose * then it means all interfaces.', ); $this->port = new PortField( internal_name: 'varinterfaceport', @@ -59,37 +58,39 @@ class FreeRADIUSInterface extends Model { allow_alias: false, allow_range: false, default: '1812', - help_text: 'The port number of the listening interface. Different interface types need different ports.' + help_text: 'The port number of the listening interface. Different interface types need different ports.', ); $this->type = new StringField( internal_name: 'varinterfacetype', required: false, - choices: [ 'auth', 'acct' ], + choices: ['auth', 'acct'], default: 'auth', - help_text: 'The type of the listening interface: Authentication/Accounting.' + help_text: 'The type of the listening interface: Authentication/Accounting.', ); $this->ipv = new StringField( internal_name: 'varinterfaceipversion', - choices: [ 'ipaddr', 'ipv6addr' ], + choices: ['ipaddr', 'ipv6addr'], allow_empty: true, required: true, - conditions: [ 'addr' => '*' ], - help_text: 'The IP version of the listening interface.' + conditions: ['addr' => '*'], + help_text: 'The IP version of the listening interface.', ); $this->description = new StringField( required: false, allow_empty: true, - default: "", + default: '', validators: [ - new RegexValidator(pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", error_msg: 'Value contains invalid characters.'), + new RegexValidator( + pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", + error_msg: 'Value contains invalid characters.', + ), ], - help_text: 'The description for this interface.' + help_text: 'The description for this interface.', ); parent::__construct($id, $parent_id, $data, ...$options); } - /** * Perform additional validation on the Model's fields and data. */ @@ -97,10 +98,10 @@ class FreeRADIUSInterface extends Model { $input_errors = []; $iface_addr = $this->addr->value; - if ( $iface_addr != '*' ) { - if ( is_ipaddrv4($iface_addr) ) { + if ($iface_addr != '*') { + if (is_ipaddrv4($iface_addr)) { $this->ipv->value = 'ipaddr'; - } elseif ( is_ipaddrv6($iface_addr) ) { + } elseif (is_ipaddrv6($iface_addr)) { $this->ipv->value = 'ipv6addr'; } else { // we don't must be here because Model validator for $this->addr @@ -123,7 +124,6 @@ class FreeRADIUSInterface extends Model { } } - /** * Apply the action on Interface(s) */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc index 86833da8..57197935 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc @@ -29,7 +29,6 @@ use RESTAPI\Validators\IPAddressValidator; * */ class OpenVPNClientExport extends Model { - public ForeignModelField $vpnid; public StringField $useaddr; public StringField $verifyservercn; @@ -64,7 +63,6 @@ class OpenVPNClientExport extends Model { $this->always_apply = true; $this->many = false; - # # some internal vars # @@ -74,11 +72,7 @@ class OpenVPNClientExport extends Model { # # Set model fields # - $this->act = new StringField( - required: true, - choices: ['confinline', 'confzip' ], - help_text: 'Export format' - ); + $this->act = new StringField(required: true, choices: ['confinline', 'confzip'], help_text: 'Export format'); $this->vpnid = new ForeignModelField( model_name: 'OpenVPNServer', @@ -93,7 +87,7 @@ class OpenVPNClientExport extends Model { required: false, default: null, allow_null: true, - help_text: 'User ID' + help_text: 'User ID', ); $this->crtref = new ForeignModelField( model_name: 'Certificate', @@ -101,44 +95,40 @@ class OpenVPNClientExport extends Model { required: false, default: null, allow_null: true, - help_text: 'Certificate refid' + help_text: 'Certificate refid', ); # # Client Connection Behavior # - $this->useaddr = new StringField( - required: false, - default: 'serveraddr', - help_text: 'Host Name Resolution' - ); + $this->useaddr = new StringField(required: false, default: 'serveraddr', help_text: 'Host Name Resolution'); $this->verifyservercn = new StringField( default: 'auto', choices: ['auto', 'none'], - help_text: 'Verify Server CN' + help_text: 'Verify Server CN', ); $this->blockoutsidedns = new BooleanField( default: false, indicates_true: 'yes', indicates_false: '', - help_text: 'Block Outside DNS' + help_text: 'Block Outside DNS', ); $this->legacy = new BooleanField( default: false, indicates_true: 'yes', indicates_false: '', - help_text: 'Legacy Client' + help_text: 'Legacy Client', ); $this->silent = new BooleanField( default: false, indicates_true: 'yes', indicates_false: '', - help_text: 'Silent Installer' + help_text: 'Silent Installer', ); $this->bindmode = new StringField( default: 'nobind', choices: ['nobind', 'lport0', 'bind'], - help_text: 'Bind Mode' + help_text: 'Bind Mode', ); # @@ -147,51 +137,41 @@ class OpenVPNClientExport extends Model { $this->usepkcs11 = new StringField( choices: ['yes', 'no'], default: 'no', - help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.' - ); - $this->pkcs11providers = new StringField( - default: '', - conditions: ['usepkcs11' => 'yes'], - ); - $this->pkcs11id = new StringField( - default: '', - conditions: ['usepkcs11' => 'yes'], + help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.', ); + $this->pkcs11providers = new StringField(default: '', conditions: ['usepkcs11' => 'yes']); + $this->pkcs11id = new StringField(default: '', conditions: ['usepkcs11' => 'yes']); $this->usetoken = new StringField( choices: ['yes', 'no'], default: 'no', - help_text: 'Use Microsoft Certificate Storage instead of local files.' + help_text: 'Use Microsoft Certificate Storage instead of local files.', ); $this->usepass = new StringField( default: '', choices: ['yes', 'no'], - help_text: 'Password Protect Certificate' + help_text: 'Password Protect Certificate', ); $this->pass = new StringField( default: '', sensitive: true, conditions: ['usepass' => 'yes'], - help_text: 'Certificate Password' + help_text: 'Certificate Password', ); $this->p12encryption = new StringField( default: 'high', choices: ['high', 'low', 'legacy'], - help_text: 'PKCS#12 Encryption' + help_text: 'PKCS#12 Encryption', ); # # Proxy Options # - $this->useproxy = new StringField( - default: 'no', - choices: ['yes', 'no'], - help_text: 'Use A Proxy' - ); + $this->useproxy = new StringField(default: 'no', choices: ['yes', 'no'], help_text: 'Use A Proxy'); $this->useproxytype = new StringField( default: 'http', choices: ['http', 'socks'], conditions: ['useproxy' => 'yes'], - help_text: 'Proxy Type' + help_text: 'Proxy Type', ); $this->proxyaddr = new StringField( required: true, @@ -215,18 +195,18 @@ class OpenVPNClientExport extends Model { default: null, choices: ['none', 'basic', 'ntlm'], conditions: ['useproxy' => true], - help_text: 'Proxy Authentication' + help_text: 'Proxy Authentication', ); $this->proxyuser = new StringField( required: true, default: null, conditions: ['useproxy' => 'yes', 'useproxypass' => ['basic', 'ntlm']], - help_text: 'Proxy Username' + help_text: 'Proxy Username', ); $this->proxypass = new StringField( required: true, conditions: ['useproxy' => 'yes', 'useproxypass' => ['basic', 'ntlm']], - help_text: 'Proxy Password' + help_text: 'Proxy Password', ); # @@ -240,17 +220,11 @@ class OpenVPNClientExport extends Model { ); # - $this->clientconfig = new Base64Field( - default: null, - allow_null: true, - read_only: true, - ); + $this->clientconfig = new Base64Field(default: null, allow_null: true, read_only: true); parent::__construct($id, $parent_id, $data, ...$options); } - - /** * many logic/code copied from /usr/local/www/vpn_openvpn_export.php. */ @@ -270,190 +244,236 @@ class OpenVPNClientExport extends Model { $bindmode = $this->bindmode->value; $usepkcs11 = $this->usepkcs11->value; - if ( $usepkcs11 === "yes" ) { - $usepkcs11 = true; + if ($usepkcs11 === 'yes') { + $usepkcs11 = true; } else { - $usepkcs11 = false; + $usepkcs11 = false; } - $pkcs11providers = $this->pkcs11providers->value; - $pkcs11id = $this->pkcs11id->value; + $pkcs11providers = $this->pkcs11providers->value; + $pkcs11id = $this->pkcs11id->value; $usetoken = $this->usetoken->value; - if ( $usetoken === "yes" ) { - $usetoken = true; + if ($usetoken === 'yes') { + $usetoken = true; } else { - $usetoken = false; + $usetoken = false; } - $password = $this->pass->value; + $password = $this->pass->value; $p12encryption = $this->p12encryption->value; $advancedoptions = $this->advancedoptions->value; - # # # $srvcfg = get_openvpnserver_by_id($srvid); - $want_cert = $this->want_cert; + $want_cert = $this->want_cert; $nokeys = $this->nokeys; $cert = $this->cert; $crtid = $this->crtid; $usrid = $this->usrid; - if (($srvcfg['mode'] != "server_user") && - !$usepkcs11 && - !$usetoken && - empty($cert['prv'])) { + if ($srvcfg['mode'] != 'server_user' && !$usepkcs11 && !$usetoken && empty($cert['prv'])) { throw new ServerError( message: 'A private key cannot be empty if PKCS#11 or Microsoft Certificate Storage is not used.', - response_id: 'FIELD_INVALID_CHOICE' + response_id: 'FIELD_INVALID_CHOICE', ); - } - + } - $proxy = ""; + $proxy = ''; $useproxy = $this->useproxy->value; - if ( $useproxy == "yes" ) { - $proxy = array(); + if ($useproxy == 'yes') { + $proxy = []; $proxy['ip'] = $this->proxy_addr->value; $proxy['port'] = $this->proxy_port->value; $proxy['proxy_type'] = $this->proxy_type->value; $proxy['proxy_authtype'] = $this->proxy_authtype->value; - if ( $proxy['proxy_authtype'] != 'none' ) { - $proxy['user'] = $this->proxy_user->value; - $proxy['password'] = $this->proxy_password->value; + if ($proxy['proxy_authtype'] != 'none') { + $proxy['user'] = $this->proxy_user->value; + $proxy['password'] = $this->proxy_password->value; } } - - - $exp_name = openvpn_client_export_prefix($srvid, $usrid, $crtid); - - if (substr($act, 0, 4) == "conf") { - switch ($act) { - case "confzip": - $exp_name = urlencode($exp_name . "-config.zip"); - $expformat = "zip"; - break; - case "conf_yealink_t28": - $exp_name = urlencode("client.tar"); - $expformat = "yealink_t28"; - break; - case "conf_yealink_t38g": - $exp_name = urlencode("client.tar"); - $expformat = "yealink_t38g"; - break; - case "conf_yealink_t38g2": - $exp_name = urlencode("client.tar"); - $expformat = "yealink_t38g2"; - break; - case "conf_snom": - $exp_name = urlencode("vpnclient.tar"); - $expformat = "snom"; - break; - case "confinline": - $exp_name = urlencode($exp_name . "-config.ovpn"); - $expformat = "inline"; - break; - case "confinlinedroid": - $exp_name = urlencode($exp_name . "-android-config.ovpn"); - $expformat = "inlinedroid"; - break; - case "confinlineconnect": - $exp_name = urlencode($exp_name . "-connect-config.ovpn"); - $expformat = "inlineconnect"; - break; - case "confinlinevisc": - $exp_name = urlencode($exp_name . "-viscosity-config.ovpn"); - $expformat = "inlinevisc"; - break; - default: - $exp_name = urlencode($exp_name . "-config.ovpn"); - $expformat = "baseconf"; - } - $exp_path = openvpn_client_export_config($srvid, $usrid, $crtid, $useaddr, $verifyservercn, $blockoutsidedns, $legacy, $bindmode, $usetoken, $nokeys, $proxy, $expformat, $password, $p12encryption, false, false, $advancedoptions, $usepkcs11, $pkcs11providers, $pkcs11id); - } - - if ($act == "visc") { - $exp_name = urlencode($exp_name . "-Viscosity.visc.zip"); - $exp_path = viscosity_openvpn_client_config_exporter($srvid, $usrid, $crtid, $useaddr, $verifyservercn, $blockoutsidedns, $legacy, $bindmode, $usetoken, $password, $p12encryption, $proxy, $advancedoptions, $usepkcs11, $pkcs11providers, $pkcs11id); - } - - if (substr($act, 0, 4) == "inst") { - $openvpn_version = substr($act, 5); - $exp_name = "openvpn-{$exp_name}-install-"; - switch ($openvpn_version) { - case "Win7": - $legacy = true; - $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win7.exe"; - break; - case "Win10": - $legacy = true; - $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win10.exe"; - break; - case "x86-previous": - $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-x86.exe"; - break; - case "x64-previous": - $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-amd64.exe"; - break; - case "x86-current": - $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-x86.exe"; - break; - case "x64-current": - default: - $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-amd64.exe"; - break; - } - - $exp_name = urlencode($exp_name); - $exp_path = openvpn_client_export_installer($srvid, $usrid, $crtid, $useaddr, $verifyservercn, $blockoutsidedns, $legacy, $bindmode, $usetoken, $password, $p12encryption, $proxy, $advancedoptions, substr($act, 5), $usepkcs11, $pkcs11providers, $pkcs11id, $silent); - } - - /* pfSense >= 2.5.0 with OpenVPN >= 2.5.0 has ciphers not compatible with - * legacy clients, check for those and warn */ - if ($legacy) { - global $legacy_incompatible_ciphers; - $settings = get_openvpnserver_by_id($srvid); - if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { - $input_errors[] = gettext("The Fallback Data Encryption Algorithm for the selected server is not compatible with Legacy clients."); - } - } - - if (!$exp_path) { - $input_errors[] = "Failed to export config files!"; - } - - if (empty($input_errors)) { - if (($act == "conf") || (substr($act, 0, 10) == "confinline")) { - $exp_size = strlen($exp_path); - } else { - $exp_size = filesize($exp_path); - } - header('Pragma: '); - header('Cache-Control: '); - header("Content-Type: application/octet-stream"); - header("Content-Disposition: attachment; filename={$exp_name}"); - header("Content-Length: $exp_size"); - if (($act == "conf") || (substr($act, 0, 10) == "confinline")) { - echo $exp_path; - } else { - readfile($exp_path); - @unlink($exp_path); - } - exit; - } else { - throw new ServerError( - message: 'Some errors occured: ' . $input_errors[0], - response_id: 'UNKNONW_ERROR' + + $exp_name = openvpn_client_export_prefix($srvid, $usrid, $crtid); + + if (substr($act, 0, 4) == 'conf') { + switch ($act) { + case 'confzip': + $exp_name = urlencode($exp_name . '-config.zip'); + $expformat = 'zip'; + break; + case 'conf_yealink_t28': + $exp_name = urlencode('client.tar'); + $expformat = 'yealink_t28'; + break; + case 'conf_yealink_t38g': + $exp_name = urlencode('client.tar'); + $expformat = 'yealink_t38g'; + break; + case 'conf_yealink_t38g2': + $exp_name = urlencode('client.tar'); + $expformat = 'yealink_t38g2'; + break; + case 'conf_snom': + $exp_name = urlencode('vpnclient.tar'); + $expformat = 'snom'; + break; + case 'confinline': + $exp_name = urlencode($exp_name . '-config.ovpn'); + $expformat = 'inline'; + break; + case 'confinlinedroid': + $exp_name = urlencode($exp_name . '-android-config.ovpn'); + $expformat = 'inlinedroid'; + break; + case 'confinlineconnect': + $exp_name = urlencode($exp_name . '-connect-config.ovpn'); + $expformat = 'inlineconnect'; + break; + case 'confinlinevisc': + $exp_name = urlencode($exp_name . '-viscosity-config.ovpn'); + $expformat = 'inlinevisc'; + break; + default: + $exp_name = urlencode($exp_name . '-config.ovpn'); + $expformat = 'baseconf'; + } + $exp_path = openvpn_client_export_config( + $srvid, + $usrid, + $crtid, + $useaddr, + $verifyservercn, + $blockoutsidedns, + $legacy, + $bindmode, + $usetoken, + $nokeys, + $proxy, + $expformat, + $password, + $p12encryption, + false, + false, + $advancedoptions, + $usepkcs11, + $pkcs11providers, + $pkcs11id, ); } + if ($act == 'visc') { + $exp_name = urlencode($exp_name . '-Viscosity.visc.zip'); + $exp_path = viscosity_openvpn_client_config_exporter( + $srvid, + $usrid, + $crtid, + $useaddr, + $verifyservercn, + $blockoutsidedns, + $legacy, + $bindmode, + $usetoken, + $password, + $p12encryption, + $proxy, + $advancedoptions, + $usepkcs11, + $pkcs11providers, + $pkcs11id, + ); + } + if (substr($act, 0, 4) == 'inst') { + $openvpn_version = substr($act, 5); + $exp_name = "openvpn-{$exp_name}-install-"; + switch ($openvpn_version) { + case 'Win7': + $legacy = true; + $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win7.exe"; + break; + case 'Win10': + $legacy = true; + $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win10.exe"; + break; + case 'x86-previous': + $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-x86.exe"; + break; + case 'x64-previous': + $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-amd64.exe"; + break; + case 'x86-current': + $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-x86.exe"; + break; + case 'x64-current': + default: + $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-amd64.exe"; + break; + } - } + $exp_name = urlencode($exp_name); + $exp_path = openvpn_client_export_installer( + $srvid, + $usrid, + $crtid, + $useaddr, + $verifyservercn, + $blockoutsidedns, + $legacy, + $bindmode, + $usetoken, + $password, + $p12encryption, + $proxy, + $advancedoptions, + substr($act, 5), + $usepkcs11, + $pkcs11providers, + $pkcs11id, + $silent, + ); + } + /* pfSense >= 2.5.0 with OpenVPN >= 2.5.0 has ciphers not compatible with + * legacy clients, check for those and warn */ + if ($legacy) { + global $legacy_incompatible_ciphers; + $settings = get_openvpnserver_by_id($srvid); + if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { + $input_errors[] = gettext( + 'The Fallback Data Encryption Algorithm for the selected server is not compatible with Legacy clients.', + ); + } + } + + if (!$exp_path) { + $input_errors[] = 'Failed to export config files!'; + } + + if (empty($input_errors)) { + if ($act == 'conf' || substr($act, 0, 10) == 'confinline') { + $exp_size = strlen($exp_path); + } else { + $exp_size = filesize($exp_path); + } + header('Pragma: '); + header('Cache-Control: '); + header('Content-Type: application/octet-stream'); + header("Content-Disposition: attachment; filename={$exp_name}"); + header("Content-Length: $exp_size"); + if ($act == 'conf' || substr($act, 0, 10) == 'confinline') { + echo $exp_path; + } else { + readfile($exp_path); + @unlink($exp_path); + } + exit(); + } else { + throw new ServerError(message: 'Some errors occured: ' . $input_errors[0], response_id: 'UNKNONW_ERROR'); + } + } /** * @@ -461,8 +481,8 @@ class OpenVPNClientExport extends Model { private function find_usrid_by_uid($uid) { global $config, $input_errors; - foreach ( $config['system']['user'] as $idx => $u ) { - if ( intval($u['uid']) == intval($uid) ) { + foreach ($config['system']['user'] as $idx => $u) { + if (intval($u['uid']) == intval($uid)) { return $idx; } } @@ -470,16 +490,14 @@ class OpenVPNClientExport extends Model { return -1; } - - /** * */ private function find_crtid_by_crtref($crtref) { global $config, $input_errors; - foreach ( $config['cert'] as $idx => $cert ){ - if ( $cert['refid'] == $crtref && $cert['type'] == 'user' ) { + foreach ($config['cert'] as $idx => $cert) { + if ($cert['refid'] == $crtref && $cert['type'] == 'user') { return $idx; } } @@ -487,29 +505,30 @@ class OpenVPNClientExport extends Model { return -1; } - /** * */ public function validate_useaddr(string $useaddr): string { - - if (!(is_ipaddr($useaddr) || is_hostname($useaddr) || - in_array($useaddr, array("serveraddr", "servermagic", "servermagichost", "serverhostname")))) { + if ( + !( + is_ipaddr($useaddr) || + is_hostname($useaddr) || + in_array($useaddr, ['serveraddr', 'servermagic', 'servermagichost', 'serverhostname']) + ) + ) { throw new ValidationError( - message: "An IP address or hostname must be specified.", - response_id: 'useaddr error' + message: 'An IP address or hostname must be specified.', + response_id: 'useaddr error', ); - } + } return $useaddr; } - /** * */ public function validate_extra(): void { - $srv = $this->vpnid->get_related_model(); $u = $this->uid->value; $crtref = $this->crtref->value; @@ -517,46 +536,41 @@ class OpenVPNClientExport extends Model { # # check if Certificate required for this VPN-instance # - if ( $srv->mode->value != 'server_user' ) { + if ($srv->mode->value != 'server_user') { $this->want_cert = true; - if ( ! isset($crtref) || empty($crtref) || is_null($crtref) ) { + if (!isset($crtref) || empty($crtref) || is_null($crtref)) { throw new ValidationError( - message: "certref must be specified for this vpnid", - response_id: 'CERTREF_REQUIRED_FOR_THIS_SERVER' + message: 'certref must be specified for this vpnid', + response_id: 'CERTREF_REQUIRED_FOR_THIS_SERVER', ); } $cert_model = $this->crtref->get_related_model(); - if ( $cert_model->type->value != 'user' ) { - throw new ServerError( - message: "Bad cert type for this crtref", - response_id: 'FIELD_INVALID_CHOICE' - ); + if ($cert_model->type->value != 'user') { + throw new ServerError(message: 'Bad cert type for this crtref', response_id: 'FIELD_INVALID_CHOICE'); } - $this->cert = array( - 'crt' => $cert_model->crt->value, - 'prv' => $cert_model->prv->value, - ); - + $this->cert = [ + 'crt' => $cert_model->crt->value, + 'prv' => $cert_model->prv->value, + ]; + $this->crtid = $this->find_crtid_by_crtref($crtref); } - - if ( $srv->mode->value != 'server_user' ) { + if ($srv->mode->value != 'server_user') { $this->nokeys = true; } # # check if User required for this VPN-instance # - if ( isset($srv->authmode->value) && in_array('Local Database', $srv->authmode->value) ) { - - if ( ! isset($u) || empty($u) || is_null($u) ) { + if (isset($srv->authmode->value) && in_array('Local Database', $srv->authmode->value)) { + if (!isset($u) || empty($u) || is_null($u)) { throw new ValidationError( - message: "uid must be specified for this vpnid", - response_id: 'UID_REQUIRED_FOR_THIS_SERVER' + message: 'uid must be specified for this vpnid', + response_id: 'UID_REQUIRED_FOR_THIS_SERVER', ); } @@ -568,37 +582,40 @@ class OpenVPNClientExport extends Model { # check if this Crtref velongs to this User # $crtref_founded = 0; - foreach ( $user_model->cert->value as $idx => $c ) { - if ( $c == $this->crtref->value ) { + foreach ($user_model->cert->value as $idx => $c) { + if ($c == $this->crtref->value) { $crtref_founded = 1; - $this->crtid = $idx; // openvpn-client-export.inc require crtid as index of user certificates not all certificates + $this->crtid = $idx; // openvpn-client-export.inc require crtid as index of user certificates not all certificates break; } } - if ( $crtref_founded != 1 ) { + if ($crtref_founded != 1) { throw new ValidationError( message: "Can't find this crtref for this uid", - response_id: 'CRTREF_UID_MISMATCH' + response_id: 'CRTREF_UID_MISMATCH', ); } - } $usetoken = $this->usetoken->value; $act = $this->act->value; - if ( ($usetoken == "yes") && ($act == "confinline") ) { + if ($usetoken == 'yes' && $act == 'confinline') { throw new ValidationError( message: 'Microsoft Certificate Storage cannot be used with an Inline configuration.', - response_id: 'FIELD_INVALID_CHOICE' + response_id: 'FIELD_INVALID_CHOICE', ); } - if ( ($usetoken == "yes") - && (($act == "conf_yealink_t28") || ($act == "conf_yealink_t38g") || ($act == "conf_yealink_t38g2") || ($act == "conf_snom"))) { + if ( + $usetoken == 'yes' && + ($act == 'conf_yealink_t28' || + $act == 'conf_yealink_t38g' || + $act == 'conf_yealink_t38g2' || + $act == 'conf_snom') + ) { throw new ValidationError( message: 'Microsoft Certificate Storage cannot be used with a Yealink or SNOM configuration.', - response_id: 'FIELD_INVALID_CHOICE' + response_id: 'FIELD_INVALID_CHOICE', ); } } - } From d24e8d88971221be74f31c9491daca2578805789 Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Tue, 10 Jun 2025 20:01:48 +0300 Subject: [PATCH 08/62] Allow to set Timezone name --- .../Endpoints/SystemTimezoneEndpoint.inc | 25 +++++++++ .../pkg/RESTAPI/Models/SystemTimezone.inc | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc new file mode 100644 index 00000000..c149bca1 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemTimezoneEndpoint.inc @@ -0,0 +1,25 @@ +url = '/api/v2/system/timezone'; + $this->model_name = 'SystemTimezone'; + $this->request_method_options = ['GET', 'PATCH']; + $this->get_help_text = 'Reads the current system timezone.'; + $this->patch_help_text = 'Updates the system timezone.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc new file mode 100644 index 00000000..74fe8a96 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc @@ -0,0 +1,55 @@ +config_path = 'system'; + $this->update_strategy = 'merge'; + $this->always_apply = true; + + # Set model Fields + $this->timezone = new StringField( + default: 'Etc/GMT', + allow_empty: false, + allow_null: false, + many: false, + help_text: 'Set geographic region name (Continent/Location) to determine the timezone for the firewall.' + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Apply these Timezone changes to the system. + */ + public function apply() { + system_timezone_configure(); + } + + /* + * + */ + public function validate_extra(): void { + $tzlist = system_get_timezone_list(); + + $tz = $this->timezone->value; + + if ( ! in_array($tz, $tzlist) ) { + throw new ValidationError( + message: "Unknown timezone={$tz}", + response_id: 'TIMEZONE_UNKNOWN_TIMEZONE', + ); + } + } +} From 85f69ca7e0f74ce716af563959ccd86f1bc5d98b Mon Sep 17 00:00:00 2001 From: Victor Gamov Date: Wed, 11 Jun 2025 11:12:18 +0300 Subject: [PATCH 09/62] Instead of using the validate_extra method for this, create a method named get_timezone_choices that simply returns system_get_timezone_list(). Then use 'get_timezone_choices' as the choices_callable parameter for the timezone field. This will automatically perform the choices validation and ensures the package knows this field uses an enum for schema generation purposes. Thanks to @jaredhendrickson13 --- .../local/pkg/RESTAPI/Models/SystemTimezone.inc | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc index 74fe8a96..aa2bfa37 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc @@ -24,6 +24,7 @@ class SystemTimezone extends Model { allow_empty: false, allow_null: false, many: false, + choices_callable: 'get_timezone_choices', help_text: 'Set geographic region name (Continent/Location) to determine the timezone for the firewall.' ); @@ -40,16 +41,7 @@ class SystemTimezone extends Model { /* * */ - public function validate_extra(): void { - $tzlist = system_get_timezone_list(); - - $tz = $this->timezone->value; - - if ( ! in_array($tz, $tzlist) ) { - throw new ValidationError( - message: "Unknown timezone={$tz}", - response_id: 'TIMEZONE_UNKNOWN_TIMEZONE', - ); - } + public function get_timezone_choices(): array { + return system_get_timezone_list(); } } From 17f47363633fd4c025cae9ba1051f0e246a1c8a8 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 12:23:42 -0600 Subject: [PATCH 10/62] feat(Schemas): add NativeSchema definition #725 --- .../pkg/RESTAPI/Schemas/NativeSchema.inc | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc new file mode 100644 index 00000000..75efcf1a --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Schemas/NativeSchema.inc @@ -0,0 +1,148 @@ + $field->name, + 'class' => $field->get_class_shortname(), + 'verbose_name' => $field->verbose_name, + 'verbose_name_plural' => $field->verbose_name_plural, + 'type' => $field->type, + 'required' => $field->required, + 'default' => $field->default, + 'choices' => $field->choices, + 'unique' => $field->unique, + 'allow_empty' => $field->allow_empty, + 'allow_null' => $field->allow_null, + 'editable' => $field->editable, + 'sensitive' => $field->sensitive, + 'read_only' => $field->read_only, + 'write_only' => $field->write_only, + 'many' => $field->many, + 'many_minimum' => $field->many_minimum, + 'many_maximum' => $field->many_maximum, + 'minimum' => property_exists($field, 'minimum') ? $field->minimum : null, + 'maximum' => property_exists($field, 'maximum') ? $field->maximum : null, + 'minimum_length' => property_exists($field, 'minimum_length') ? $field->minimum_length : null, + 'maximum_length' => property_exists($field, 'maximum_length') ? $field->maximum_length : null, + 'internal_name' => $field->internal_name, + 'internal_namespace' => $field->internal_namespace, + 'referenced_by' => $field->referenced_by, + 'nested_model_class' => $field instanceof NestedModelField ? $field->model->get_class_shortname() : null, + 'foreign_model_class' => $field instanceof ForeignModelField ? $field->model_name : null, + 'foreign_model_field' => $field instanceof ForeignModelField ? $field->model_field : null, + 'conditions' => $field->conditions, + 'help_text' => $field->help_text, + ]; + } + + /** + * Extracts all applicable metadata from a given model object + * @param Model $model The model object to extract metadata from + * @return array An associative array containing metadata about the model. + */ + public function model_to_metadata(Model $model): array { + # Set base metadata + $metadata = [ + 'class' => $model->get_class_shortname(), + 'id_type' => $model->many ? $model->id_type : null, + 'parent_model_class' => $model->parent_model_class ?? null, + 'parent_id_type' => $model->parent_model_class ? $model->parent_id_type : null, + 'verbose_name' => $model->verbose_name, + 'verbose_name_plural' => $model->verbose_name_plural, + 'many' => $model->many, + 'many_minimum' => $model->many_minimum, + 'many_maximum' => $model->many_maximum, + 'packages' => $model->packages, + 'unique_together_fields' => $model->unique_together_fields, + 'always_apply' => $model->always_apply, + 'subsystem' => $model->subsystem, + 'fields' => [], + ]; + + # Convert each field to metadata + foreach ($model->get_fields() as $field_name) { + $metadata['fields'][$field_name] = $this->field_to_metadata($model->$field_name); + } + + return $metadata; + } + + /** + * Extracts all applicable metadata from a given endpoint object + * @param Endpoint $endpoint The endpoint object to extract metadata from + * @return array An associative array containing metadata about the endpoint. + */ + public function endpoint_to_metadata(Endpoint $endpoint): array { + # Set base metadata + return [ + 'url' => $endpoint->url, + 'class' => $endpoint->get_class_shortname(), + 'model_class' => $endpoint->model_name, + 'tag' => $endpoint->tag, + 'deprecated' => $endpoint->deprecated, + 'many' => $endpoint->many, + 'request_method_options' => $endpoint->request_method_options, + 'requires_auth' => $endpoint->requires_auth, + 'auth_methods' => $endpoint->auth_methods, + 'get_privileges' => $endpoint->get_privileges, + 'post_privileges' => $endpoint->post_privileges, + 'patch_privileges' => $endpoint->patch_privileges, + 'put_privileges' => $endpoint->put_privileges, + 'delete_privileges' => $endpoint->delete_privileges, + 'get_help_text' => $endpoint->get_help_text, + 'post_help_text' => $endpoint->post_help_text, + 'patch_help_text' => $endpoint->patch_help_text, + 'put_help_text' => $endpoint->put_help_text, + 'delete_help_text' => $endpoint->delete_help_text, + ]; + } + + /** + * Obtains the full schema string for this Schema class in JSON format + * @return string The full schema string for this Schema class in JSON format + */ + public function get_schema_str(): string { + $schema = ['endpoints' => [], 'models' => []]; + + # Get all endpoint metadata + foreach (get_classes_from_namespace('RESTAPI\Endpoints') as $endpoint_class) { + $endpoint = new $endpoint_class(); + $schema['endpoints'][$endpoint->url] = $this->endpoint_to_metadata($endpoint); + } + + # Get all model metadata + foreach (get_classes_from_namespace('RESTAPI\Models') as $model_class) { + $model = new $model_class(skip_init: true); + $schema['models'][$model->get_class_shortname()] = $this->model_to_metadata($model); + } + + return json_encode($schema); + } +} From 09ebb79ff24b9aa50ea997a371d3e34053fe1d07 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 12:23:54 -0600 Subject: [PATCH 11/62] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc index aa2bfa37..6cfb18f2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc @@ -25,7 +25,7 @@ class SystemTimezone extends Model { allow_null: false, many: false, choices_callable: 'get_timezone_choices', - help_text: 'Set geographic region name (Continent/Location) to determine the timezone for the firewall.' + help_text: 'Set geographic region name (Continent/Location) to determine the timezone for the firewall.', ); parent::__construct($id, $parent_id, $data, ...$options); From 49608533934a892938577218b223ba7f3e44732b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 16:52:49 -0600 Subject: [PATCH 12/62] chore: cleanup SystemTimezone model --- .../usr/local/pkg/RESTAPI/Models/SystemTimezone.inc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc index 6cfb18f2..c4291ac7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemTimezone.inc @@ -4,7 +4,6 @@ namespace RESTAPI\Models; use RESTAPI\Core\Model; use RESTAPI\Fields\StringField; -use RESTAPI\Responses\ValidationError; /** * Defines a Model that represents the Timezone configuration on this system. @@ -20,11 +19,11 @@ class SystemTimezone extends Model { # Set model Fields $this->timezone = new StringField( - default: 'Etc/GMT', + default: 'UTC', + choices_callable: 'get_timezone_choices', allow_empty: false, allow_null: false, many: false, - choices_callable: 'get_timezone_choices', help_text: 'Set geographic region name (Continent/Location) to determine the timezone for the firewall.', ); @@ -38,8 +37,9 @@ class SystemTimezone extends Model { system_timezone_configure(); } - /* - * + /** + * Obtains the list of available timezones. + * @return array The list of timezones available to the system */ public function get_timezone_choices(): array { return system_get_timezone_list(); From 0a38948c708d14b40806673b2d49b91bcb1ec05b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 17:06:17 -0600 Subject: [PATCH 13/62] test(SystemTimezone): add tests for SystemTimezone model #711 --- .../Tests/APIModelsSystemTimezoneTestCase.inc | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc new file mode 100644 index 00000000..2f83e2e8 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc @@ -0,0 +1,46 @@ +update(); + + # Ensure the timezone was updated correctly + $zoneinfo = file_get_contents('/var/db/zoneinfo'); + $this->assert_equals($zoneinfo, 'America/Denver'); + + # Update the timezone again + $timezone = new SystemTimezone(timezone: 'UTC'); + $timezone->update(); + + # Ensure the timezone was updated correctly + $zoneinfo = file_get_contents('/var/db/zoneinfo'); + $this->assert_equals($zoneinfo, 'UTC'); + } + + /** + * Checks that the get_timezone_choices method returns a valid list of timezones. + */ + public function test_get_timezone_choices(): void { + $timezone = new SystemTimezone(); + $choices = $timezone->get_timezone_choices(); + $expected_choices = ['America/Denver', 'America/New_York', 'UTC']; + + # Ensure the expected choices are in the list + foreach ($expected_choices as $choice) { + $this->assert_is_true( + in_array($choice, $choices), + message: "Expected timezone '$choice' not found in choices.", + ); + } + } +} From 67c5404e3d3ec9edbbc7218bee90ec8f2da297c2 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 19:53:44 -0600 Subject: [PATCH 14/62] chore(FreeRADIUSInterface): cleanup FreeRADIUSInterface model --- .../RESTAPI/Models/FreeRADIUSInterface.inc | 94 +++++++++---------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc index b47689c3..0a147af7 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSInterface.inc @@ -5,17 +5,9 @@ namespace RESTAPI\Models; require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; -use RESTAPI\Fields\Base64Field; -use RESTAPI\Fields\BooleanField; -use RESTAPI\Fields\ForeignModelField; -use RESTAPI\Fields\IntegerField; use RESTAPI\Fields\PortField; -use RESTAPI\Fields\ObjectField; use RESTAPI\Fields\StringField; -use RESTAPI\Responses\ConflictError; use RESTAPI\Responses\ValidationError; -use RESTAPI\Responses\ServerError; -use RESTAPI\Validators\HostnameValidator; use RESTAPI\Validators\IPAddressValidator; use RESTAPI\Validators\RegexValidator; @@ -26,59 +18,50 @@ class FreeRADIUSInterface extends Model { public StringField $addr; public PortField $port; public StringField $type; - public StringField $ipv; + public StringField $ip_version; public StringField $description; - /** - * - */ public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { - # # Set model attributes - # $this->packages = ['pfSense-pkg-freeradius3']; $this->package_includes = ['freeradius.inc']; $this->config_path = 'installedpackages/freeradiusinterfaces/config'; $this->many = true; $this->always_apply = true; - $this->unique_together_fields = ['addr', 'port', 'ipv']; + $this->unique_together_fields = ['addr', 'port', 'ip_version']; - # # Set model fields - # $this->addr = new StringField( - internal_name: 'varinterfaceip', required: true, + internal_name: 'varinterfaceip', validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_keywords: ['*'])], help_text: 'The IP address of the listening interface. If you choose * then it means all interfaces.', ); $this->port = new PortField( - internal_name: 'varinterfaceport', required: false, + default: '1812', allow_alias: false, allow_range: false, - default: '1812', + internal_name: 'varinterfaceport', help_text: 'The port number of the listening interface. Different interface types need different ports.', ); + $this->ip_version = new StringField( + required: true, + choices: ['ipaddr', 'ipv6addr'], + internal_name: 'varinterfaceipversion', + help_text: 'The IP version of the listening interface.', + ); $this->type = new StringField( - internal_name: 'varinterfacetype', required: false, - choices: ['auth', 'acct'], default: 'auth', + choices: ['auth', 'acct', 'proxy', 'detail', 'status', 'coa'], + internal_name: 'varinterfacetype', help_text: 'The type of the listening interface: Authentication/Accounting.', ); - $this->ipv = new StringField( - internal_name: 'varinterfaceipversion', - choices: ['ipaddr', 'ipv6addr'], - allow_empty: true, - required: true, - conditions: ['addr' => '*'], - help_text: 'The IP version of the listening interface.', - ); $this->description = new StringField( required: false, - allow_empty: true, default: '', + allow_empty: true, validators: [ new RegexValidator( pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", @@ -92,29 +75,44 @@ class FreeRADIUSInterface extends Model { } /** - * Perform additional validation on the Model's fields and data. + * Perform extra validation on the Model's 'addr' field. + * @param string $value The value to validate. + * @returns string The validated value. + * @throws ValidationError If the value does not match IP version specified in the 'ip_version' field. */ - public function validate_extra(): void { - $input_errors = []; + public function validate_addr(string $value): string { + # Asterisk (*) is always a valid value for the addr field, so return it without further validation + if ($value === '*') { + return $value; + } - $iface_addr = $this->addr->value; - if ($iface_addr != '*') { - if (is_ipaddrv4($iface_addr)) { - $this->ipv->value = 'ipaddr'; - } elseif (is_ipaddrv6($iface_addr)) { - $this->ipv->value = 'ipv6addr'; - } else { - // we don't must be here because Model validator for $this->addr - $input_errors[] = "Cann't recognize IP-address={$iface_addr}"; - } + # Do not allow the value to be an IPv4 address if ip_version is 'ipv6addr' + if ($this->addr->has_label('is_ipaddrv4') and $this->ip_version->value === 'ipv6addr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv4 address when `ip_version` is set to `ipv6addr`, received `$value`.", + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV4_NOT_ALLOWED', + ); } - # Run service level validations - $iface = $this->to_internal(); - freeradius_validate_interfaces($iface, $input_errors); + # Do not allow the value to be an IPv6 address if ip_version is 'ipaddr' + if ($this->addr->has_label('is_ipaddrv6') and $this->ip_version->value === 'ipaddr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv6 address when `ip_version` is set to `ipaddr`, received `$value`.", + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV6_NOT_ALLOWED', + ); + } + return $value; + } + + /** + * Perform additional validation on the Model's fields and data. + */ + public function validate_extra(): void { # If there were validation errors that were not caught by the model fields, throw a ValidationError. # Ideally the Model should catch all validation errors itself so prompt the user to report this error + $input_errors = []; + freeradius_validate_interfaces($this->to_internal(), $input_errors); if (!empty($input_errors)) { throw new ValidationError( message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . @@ -127,7 +125,7 @@ class FreeRADIUSInterface extends Model { /** * Apply the action on Interface(s) */ - public function apply() { + public function apply(): void { freeradius_settings_resync(); } } From 15ab63c586d11d8cc29a347c30aa2bff24813c8c Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 20:07:02 -0600 Subject: [PATCH 15/62] chore(FreeRADIUSClient): cleanup FreeRADIUSClient model --- .../pkg/RESTAPI/Models/FreeRADIUSClient.inc | 145 ++++++++++-------- 1 file changed, 77 insertions(+), 68 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc index 426af1b4..2d80b48f 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc @@ -5,83 +5,77 @@ namespace RESTAPI\Models; require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; -use RESTAPI\Fields\Base64Field; use RESTAPI\Fields\BooleanField; -use RESTAPI\Fields\ForeignModelField; use RESTAPI\Fields\IntegerField; use RESTAPI\Fields\PortField; -use RESTAPI\Fields\ObjectField; use RESTAPI\Fields\StringField; -use RESTAPI\Responses\ConflictError; use RESTAPI\Responses\ValidationError; -use RESTAPI\Responses\ServerError; -use RESTAPI\Validators\HostnameValidator; use RESTAPI\Validators\IPAddressValidator; use RESTAPI\Validators\RegexValidator; /** - * Defines a Model that represents FreeRADIUS Interfaces + * Defines a Model that represents FreeRADIUS Clients */ class FreeRADIUSClient extends Model { public StringField $addr; public PortField $port; public StringField $type; - public StringField $ipv; + public StringField $ip_version; public StringField $description; + public StringField $shortname; + public StringField $secret; + public StringField $proto; + public StringField $nastype; + public BooleanField $msgauth; + public IntegerField $maxconn; + public StringField $naslogin; + public StringField $naspassword; - /** - * - */ public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { - # # Set model attributes - # $this->packages = ['pfSense-pkg-freeradius3']; $this->package_includes = ['freeradius.inc']; $this->config_path = 'installedpackages/freeradiusclients/config'; $this->many = true; $this->always_apply = true; - # # Set model fields - # $this->addr = new StringField( - internal_name: 'varclientip', required: true, unique: true, + internal_name: 'varclientip', validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], - help_text: 'The IP address or network of the RADIUS client(s) in CIDR notation. This is the IP of the NAS (switch, access point, firewall, router, etc.)', + help_text: 'The IP address or network of the RADIUS client(s) in CIDR notation. This is the IP of the ' . + 'NAS (switch, access point, firewall, router, etc.)', ); - $this->ipv = new StringField( - internal_name: 'varclientipversion', - choices: ['ipaddr', 'ipv6addr'], - allow_empty: true, + $this->ip_version = new StringField( default: 'ipaddr', + choices: ['ipaddr', 'ipv6addr'], + internal_name: 'varclientipversion', help_text: 'The IP version of the this Client.', ); $this->shortname = new StringField( - internal_name: 'varclientshortname', required: true, - allow_null: false, + internal_name: 'varclientshortname', help_text: 'A short name for the client. This is generally the hostname of the NAS.', ); $this->secret = new StringField( - internal_name: 'varclientsharedsecret', required: true, sensitive: true, - allow_empty: false, - help_text: 'This is the shared secret (password) which the NAS (switch, accesspoint, etc.) needs to communicate with the RADIUS server.', + maximum_length: 31, + internal_name: 'varclientsharedsecret', + help_text: 'This is the shared secret (password) which the NAS (switch, accesspoint, etc.) needs to ' . + 'communicate with the RADIUS server.', ); $this->proto = new StringField( - internal_name: 'varclientproto', - choices: ['udp', 'tcp'], - allow_empty: true, default: 'udp', - help_text: 'The protocol the client uses. (Default: udp)', + choices: ['udp', 'tcp'], + internal_name: 'varclientproto', + help_text: 'The protocol the client uses.', ); $this->nastype = new StringField( - internal_name: 'varclientnastype', + default: 'other', choices: [ 'cisco', 'cvx', @@ -96,41 +90,45 @@ class FreeRADIUSClient extends Model { 'other', ], allow_empty: true, - default: 'other', - help_text: 'The NAS type of the client. This is used by checkrad.pl for simultaneous use checks. (Default: other)', + internal_name: 'varclientnastype', + help_text: 'The NAS type of the client. This is used by checkrad.pl for simultaneous use checks.', ); - $this->msgauth = new StringField( + $this->msgauth = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: 'no', internal_name: 'varrequiremessageauthenticator', - choices: ['yes', 'no'], - default: 'no', - help_text: 'RFC5080 requires Message-Authenticator in Access-Request. But older NAS (switches or accesspoints) do not include that. (Default: no)', + help_text: 'RFC5080 requires Message-Authenticator in Access-Request. But older NAS (switches or ' . + 'accesspoints) do not include that.', ); $this->maxconn = new IntegerField( - internal_name: 'varclientmaxconnections', + default: 16, minimum: 1, maximum: 32, - default: 16, - help_text: 'Takes only effect if you use TCP as protocol. Limits the number of simultaneous TCP connections from a client. (max=32)', + internal_name: 'varclientmaxconnections', + help_text: 'Takes only effect if you use TCP as protocol. Limits the number of simultaneous TCP + connections from a client.', ); $this->naslogin = new StringField( - internal_name: 'varclientlogininput', - allow_empty: true, default: '', - help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose (s)radutmp. (Default: empty) ', + allow_empty: true, + internal_name: 'varclientlogininput', + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of ' . + '(s)radutmp file and accounting. Leave empty to choose (s)radutmp.', ); $this->naspassword = new StringField( - internal_name: 'varclientpasswordinput', - allow_empty: true, default: '', + allow_empty: true, sensitive: true, - help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of (s)radutmp file and accounting. Leave empty to choose -(s)radutmp. (Default: empty) ', + internal_name: 'varclientpasswordinput', + help_text: 'If supported by your NAS, you can use SNMP or finger for simultaneous-use checks instead of ' . + '(s)radutmp file and accounting. Leave empty to choose (s)radutmp.', ); $this->description = new StringField( required: false, - allow_empty: true, default: '', + allow_empty: true, validators: [ new RegexValidator( pattern: "/^[a-zA-Z0-9 _,.;:+=()-]*$/", @@ -144,36 +142,47 @@ class FreeRADIUSClient extends Model { } /** - * Perform additional validation on the Model's fields and data. + * Perform extra validation on the Model's 'addr' field. + * @param string $value The value to validate. + * @returns string The validated value. + * @throws ValidationError If the value does not match IP version specified in the 'ip_version' field. */ - public function validate_extra(): void { - $input_errors = []; + public function validate_addr(string $value): string { + # Do not allow the value to be an IPv4 address if ip_version is 'ipv6addr' + if ($this->addr->has_label('is_ipaddrv4') and $this->ip_version->value === 'ipv6addr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv4 address when `ip_version` is set to `ipv6addr`, received `$value`.", + response_id: 'FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED', + ); + } - /* - */ - $iface_addr = $this->addr->value; - if ($iface_addr != '*') { - if (is_ipaddrv4($iface_addr)) { - $this->ipv->value = 'ipaddr'; - } elseif (is_ipaddrv6($iface_addr)) { - $this->ipv->value = 'ipv6addr'; - } else { - // we don't must be here because Model validator for $this->addr - $input_errors[] = "Cann't recognize IP-address={$iface_addr}"; - } + # Do not allow the value to be an IPv6 address if ip_version is 'ipaddr' + if ($this->addr->has_label('is_ipaddrv6') and $this->ip_version->value === 'ipaddr') { + throw new ValidationError( + message: "Field `addr` cannot be an IPv6 address when `ip_version` is set to `ipaddr`, received `$value`.", + response_id: 'FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED', + ); } - # Run service level validations - $client = $this->to_internal(); - freeradius_validate_clients($iface, $input_errors); + return $value; + } + /** + * Perform additional validation on the Model's fields and data. + */ + /** + * Perform additional validation on the Model's fields and data. + */ + public function validate_extra(): void { # If there were validation errors that were not caught by the model fields, throw a ValidationError. # Ideally the Model should catch all validation errors itself so prompt the user to report this error + $input_errors = []; + freeradius_validate_clients($this->to_internal(), $input_errors); if (!empty($input_errors)) { throw new ValidationError( message: "An unexpected validation error has occurred: $input_errors[0]. Please report this issue at " . 'https://github.com/jaredhendrickson13/pfsense-api/issues/new', - response_id: 'FREERADIUS_USER_UNEXPECTED_VALIDATION_ERROR', + response_id: 'FREERADIUS_CLIENTS_UNEXPECTED_VALIDATION_ERROR', ); } } @@ -181,7 +190,7 @@ class FreeRADIUSClient extends Model { /** * Apply specific action on Client(s) */ - public function apply() { + public function apply(): void { freeradius_clients_resync(); } } From 058325a877c80b88785d0f247939d8b3eb04bacf Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 20:59:03 -0600 Subject: [PATCH 16/62] ci: add builds for pfSense CE 2.8.1 and Plus 25.07 --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71b6ac90..d1d51b57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,8 +28,12 @@ jobs: # Note: The first item in this matrix must use env.DEFAULT_PFSENSE_VERSION as the PFSENSE_VERSION! - FREEBSD_VERSION: FreeBSD-15.0-CURRENT PFSENSE_VERSION: "2.8.0" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "2.8.1" - FREEBSD_VERSION: FreeBSD-15.0-CURRENT PFSENSE_VERSION: "24.11" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "25.07" steps: - uses: actions/checkout@v4 From 6b6deda119256426aefb0aab46cf5c2fb569873b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 20:59:19 -0600 Subject: [PATCH 17/62] ci: build and test on pfSense CE 2.8.1 --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c58de7b4..c05e6b5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,8 @@ jobs: include: - PFSENSE_VERSION: pfSense-2.8.0-RELEASE FREEBSD_ID: freebsd15 + - PFSENSE_VERSION: pfSense-2.8.1-RELEASE + FREEBSD_ID: freebsd15 steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 @@ -105,6 +107,8 @@ jobs: include: - PFSENSE_VERSION: pfSense-2.8.0-RELEASE FREEBSD_ID: freebsd15 + - PFSENSE_VERSION: pfSense-2.8.1-RELEASE + FREEBSD_ID: freebsd15 steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 @@ -132,6 +136,8 @@ jobs: include: - PFSENSE_VERSION: pfSense-2.8.0-RELEASE FREEBSD_ID: freebsd15 + - PFSENSE_VERSION: pfSense-2.8.1-RELEASE + FREEBSD_ID: freebsd15 steps: - uses: actions/checkout@v4 From 06161840743f9390b7d117ec24923d57f08dfc25 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 19 Jul 2025 21:01:53 -0600 Subject: [PATCH 18/62] chore(Endpoints): expand request method options for new FreeRADIUS endpoints --- .../RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc | 6 ++---- .../RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc | 6 ++---- .../Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc | 6 ++---- .../Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc | 6 ++---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc index 37b66148..da0fb20e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientEndpoint.inc @@ -12,13 +12,11 @@ use RESTAPI\Core\Endpoint; */ class ServicesFreeRADIUSClientEndpoint extends Endpoint { public function __construct() { - /** - * Set Endpoint attributes - */ + # Set Endpoint attributes $this->url = '/api/v2/services/freeradius/client'; $this->model_name = 'FreeRADIUSClient'; $this->many = false; - $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/ServicesFreeRADIUSClientsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc index b20aac1a..144d07f0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSClientsEndpoint.inc @@ -12,13 +12,11 @@ use RESTAPI\Core\Endpoint; */ class ServicesFreeRADIUSClientsEndpoint extends Endpoint { public function __construct() { - /** - * Set Endpoint attributes - */ + # Set Endpoint attributes $this->url = '/api/v2/services/freeradius/clients'; $this->model_name = 'FreeRADIUSClient'; $this->many = true; - $this->request_method_options = ['GET']; + $this->request_method_options = ['GET', 'PUT', 'DELETE']; # Construct the parent Endpoint object parent::__construct(); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc index c090c0a2..cfe55040 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfaceEndpoint.inc @@ -12,13 +12,11 @@ use RESTAPI\Core\Endpoint; */ class ServicesFreeRADIUSInterfaceEndpoint extends Endpoint { public function __construct() { - /** - * Set Endpoint attributes - */ + # Set Endpoint attributes $this->url = '/api/v2/services/freeradius/interface'; $this->model_name = 'FreeRADIUSInterface'; $this->many = false; - $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/ServicesFreeRADIUSInterfacesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc index 69f393f4..41d2b564 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc @@ -12,13 +12,11 @@ use RESTAPI\Core\Endpoint; */ class ServicesFreeRADIUSInterfacesEndpoint extends Endpoint { public function __construct() { - /** - * Set Endpoint attributes - */ + # Set Endpoint attributes $this->url = '/api/v2/services/freeradius/interfaces'; $this->model_name = 'FreeRADIUSInterface'; $this->many = true; - $this->request_method_options = ['GET']; + $this->request_method_options = ['GET', 'PUT', 'DELETE']; # Construct the parent Endpoint object parent::__construct(); From 27c905fa5ed30c03820b64226a151c6a52688029 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 20 Jul 2025 11:22:44 -0600 Subject: [PATCH 19/62] fix(FreeRADIUSClient): remove unnecessary port field --- .../files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc | 2 -- 1 file changed, 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc index 2d80b48f..93393476 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/FreeRADIUSClient.inc @@ -7,7 +7,6 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; use RESTAPI\Fields\BooleanField; use RESTAPI\Fields\IntegerField; -use RESTAPI\Fields\PortField; use RESTAPI\Fields\StringField; use RESTAPI\Responses\ValidationError; use RESTAPI\Validators\IPAddressValidator; @@ -18,7 +17,6 @@ use RESTAPI\Validators\RegexValidator; */ class FreeRADIUSClient extends Model { public StringField $addr; - public PortField $port; public StringField $type; public StringField $ip_version; public StringField $description; From a22f1563cd4060f64c815985de01b11ef52274ef Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 20 Jul 2025 11:22:59 -0600 Subject: [PATCH 20/62] test(FreeRADIUSClient): add tests for FreeRADIUSClient model --- .../APIModelsFreeRADIUSClientTestCase.inc | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc new file mode 100644 index 00000000..c608f99e --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc @@ -0,0 +1,119 @@ +create(); + $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); + $this->assert_str_contains($raddb, 'client "testclient" {'); + $this->assert_str_contains($raddb, 'ipaddr = 1.2.3.4'); + $this->assert_str_contains($raddb, "proto = udp"); + $this->assert_str_contains($raddb, "nas_type = dot1x"); + $this->assert_str_contains($raddb, "login = testlogin"); + $this->assert_str_contains($raddb, "password = testpassword"); + $this->assert_str_contains($raddb, "require_message_authenticator = no"); + $this->assert_str_contains($raddb, 'max_connections = 16'); + + + # Ensure we can read the created user from the config + $read_client = new FreeRADIUSClient(id: $client->id); + $this->assert_equals($read_client->addr->value, "1.2.3.4"); + $this->assert_equals($read_client->ip_version->value, "ipaddr"); + $this->assert_equals($read_client->shortname->value, "testclient"); + $this->assert_str_contains($read_client->proto->value, "udp"); + $this->assert_str_contains($read_client->nastype->value, "dot1x"); + $this->assert_equals($read_client->msgauth->value, false); + $this->assert_equals($read_client->maxconn->value, 16); + $this->assert_equals($read_client->naslogin->value, "testlogin"); + $this->assert_equals($read_client->naspassword->value, "testpassword"); + $this->assert_equals($read_client->description->value, "Test client"); + + # Ensure we can update the user + $client = new FreeRADIUSClient( + id: $read_client->id, + addr: '4321::1', + ip_version: "ipv6addr", + shortname: "newtestclient", + secret: "newtestsecret", + proto: 'tcp', + nastype: 'cisco', + msgauth: true, + maxconn: 32, + naslogin: 'newtestlogin', + naspassword: 'newtestpassword', + description: 'New test client', + ); + $client->update(); + $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); + $this->assert_str_does_not_contain($raddb, 'client "testclient" {'); + $this->assert_str_contains($raddb, 'client "newtestclient" {'); + $this->assert_str_contains($raddb, 'ipaddr = 4321::1'); + $this->assert_str_contains($raddb, "proto = tcp"); + $this->assert_str_contains($raddb, "nas_type = cisco"); + $this->assert_str_contains($raddb, "login = newtestlogin"); + $this->assert_str_contains($raddb, "password = newtestpassword"); + $this->assert_str_contains($raddb, "require_message_authenticator = yes"); + $this->assert_str_contains($raddb, 'max_connections = 32'); + + # Delete the user and ensure it is removed from the database + $client->delete(); + $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); + $this->assert_str_does_not_contain($raddb, 'client "newtestclient" {'); + } + + /** + * Checks that an error is thrown if the ip_version does not match the value provided + * in the addr field. + */ + public function test_ip_version_mismatch(): void { + $this->assert_throws_response( + response_id: "FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED", + code: 400, + callable: function () { + $client = new FreeRADIUSClient(ip_version: "ipv6addr"); + $client->validate_addr("1.2.3.4"); + } + ); + $this->assert_throws_response( + response_id: "FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED", + code: 400, + callable: function () { + $client = new FreeRADIUSClient(ip_version: "ipaddr"); + $client->validate_addr("1234::1"); + } + ); + + # Ensure * is always allowed + $this->assert_does_not_throw( + callable: function () { + $client = new FreeRADIUSClient(ip_version: "ipaddr"); + $client->validate_addr("*"); + $client = new FreeRADIUSClient(ip_version: "ipv6addr"); + $client->validate_addr("*"); + } + ); + } +} From feb0bfb0720950e2c8dbecdd6664bf6b101e89ac Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 20 Jul 2025 11:24:50 -0600 Subject: [PATCH 21/62] test(SystemTimezone): add newlines to expected zoneinfo output --- .../pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc index 2f83e2e8..ce2a264b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc @@ -16,7 +16,7 @@ class APIModelsSystemTimezoneTestCase extends TestCase { # Ensure the timezone was updated correctly $zoneinfo = file_get_contents('/var/db/zoneinfo'); - $this->assert_equals($zoneinfo, 'America/Denver'); + $this->assert_equals($zoneinfo, 'America/Denver'.PHP_EOL); # Update the timezone again $timezone = new SystemTimezone(timezone: 'UTC'); @@ -24,7 +24,7 @@ class APIModelsSystemTimezoneTestCase extends TestCase { # Ensure the timezone was updated correctly $zoneinfo = file_get_contents('/var/db/zoneinfo'); - $this->assert_equals($zoneinfo, 'UTC'); + $this->assert_equals($zoneinfo, 'UTC'.PHP_EOL); } /** From 0cc3b0408d817013e2f53c6ecbdc0387b81a034d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 20 Jul 2025 11:25:41 -0600 Subject: [PATCH 22/62] style: run prettier on changed files --- .../ServicesFreeRADIUSInterfacesEndpoint.inc | 2 +- .../APIModelsFreeRADIUSClientTestCase.inc | 75 +++++++++---------- .../Tests/APIModelsSystemTimezoneTestCase.inc | 4 +- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc index 41d2b564..d37946fd 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/ServicesFreeRADIUSInterfacesEndpoint.inc @@ -12,7 +12,7 @@ use RESTAPI\Core\Endpoint; */ class ServicesFreeRADIUSInterfacesEndpoint extends Endpoint { public function __construct() { - # Set Endpoint attributes + # Set Endpoint attributes $this->url = '/api/v2/services/freeradius/interfaces'; $this->model_name = 'FreeRADIUSInterface'; $this->many = true; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc index c608f99e..0e8df6a8 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc @@ -15,9 +15,9 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { # Create a new FreeRADIUSClient $client = new FreeRADIUSClient( addr: '1.2.3.4', - ip_version: "ipaddr", - shortname: "testclient", - secret: "testsecret", + ip_version: 'ipaddr', + shortname: 'testclient', + secret: 'testsecret', proto: 'udp', nastype: 'dot1x', msgauth: false, @@ -30,34 +30,33 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); $this->assert_str_contains($raddb, 'client "testclient" {'); $this->assert_str_contains($raddb, 'ipaddr = 1.2.3.4'); - $this->assert_str_contains($raddb, "proto = udp"); - $this->assert_str_contains($raddb, "nas_type = dot1x"); - $this->assert_str_contains($raddb, "login = testlogin"); - $this->assert_str_contains($raddb, "password = testpassword"); - $this->assert_str_contains($raddb, "require_message_authenticator = no"); + $this->assert_str_contains($raddb, 'proto = udp'); + $this->assert_str_contains($raddb, 'nas_type = dot1x'); + $this->assert_str_contains($raddb, 'login = testlogin'); + $this->assert_str_contains($raddb, 'password = testpassword'); + $this->assert_str_contains($raddb, 'require_message_authenticator = no'); $this->assert_str_contains($raddb, 'max_connections = 16'); - # Ensure we can read the created user from the config $read_client = new FreeRADIUSClient(id: $client->id); - $this->assert_equals($read_client->addr->value, "1.2.3.4"); - $this->assert_equals($read_client->ip_version->value, "ipaddr"); - $this->assert_equals($read_client->shortname->value, "testclient"); - $this->assert_str_contains($read_client->proto->value, "udp"); - $this->assert_str_contains($read_client->nastype->value, "dot1x"); + $this->assert_equals($read_client->addr->value, '1.2.3.4'); + $this->assert_equals($read_client->ip_version->value, 'ipaddr'); + $this->assert_equals($read_client->shortname->value, 'testclient'); + $this->assert_str_contains($read_client->proto->value, 'udp'); + $this->assert_str_contains($read_client->nastype->value, 'dot1x'); $this->assert_equals($read_client->msgauth->value, false); $this->assert_equals($read_client->maxconn->value, 16); - $this->assert_equals($read_client->naslogin->value, "testlogin"); - $this->assert_equals($read_client->naspassword->value, "testpassword"); - $this->assert_equals($read_client->description->value, "Test client"); + $this->assert_equals($read_client->naslogin->value, 'testlogin'); + $this->assert_equals($read_client->naspassword->value, 'testpassword'); + $this->assert_equals($read_client->description->value, 'Test client'); # Ensure we can update the user $client = new FreeRADIUSClient( id: $read_client->id, addr: '4321::1', - ip_version: "ipv6addr", - shortname: "newtestclient", - secret: "newtestsecret", + ip_version: 'ipv6addr', + shortname: 'newtestclient', + secret: 'newtestsecret', proto: 'tcp', nastype: 'cisco', msgauth: true, @@ -71,11 +70,11 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { $this->assert_str_does_not_contain($raddb, 'client "testclient" {'); $this->assert_str_contains($raddb, 'client "newtestclient" {'); $this->assert_str_contains($raddb, 'ipaddr = 4321::1'); - $this->assert_str_contains($raddb, "proto = tcp"); - $this->assert_str_contains($raddb, "nas_type = cisco"); - $this->assert_str_contains($raddb, "login = newtestlogin"); - $this->assert_str_contains($raddb, "password = newtestpassword"); - $this->assert_str_contains($raddb, "require_message_authenticator = yes"); + $this->assert_str_contains($raddb, 'proto = tcp'); + $this->assert_str_contains($raddb, 'nas_type = cisco'); + $this->assert_str_contains($raddb, 'login = newtestlogin'); + $this->assert_str_contains($raddb, 'password = newtestpassword'); + $this->assert_str_contains($raddb, 'require_message_authenticator = yes'); $this->assert_str_contains($raddb, 'max_connections = 32'); # Delete the user and ensure it is removed from the database @@ -90,30 +89,30 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { */ public function test_ip_version_mismatch(): void { $this->assert_throws_response( - response_id: "FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED", + response_id: 'FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED', code: 400, callable: function () { - $client = new FreeRADIUSClient(ip_version: "ipv6addr"); - $client->validate_addr("1.2.3.4"); - } + $client = new FreeRADIUSClient(ip_version: 'ipv6addr'); + $client->validate_addr('1.2.3.4'); + }, ); $this->assert_throws_response( - response_id: "FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED", + response_id: 'FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED', code: 400, callable: function () { - $client = new FreeRADIUSClient(ip_version: "ipaddr"); - $client->validate_addr("1234::1"); - } + $client = new FreeRADIUSClient(ip_version: 'ipaddr'); + $client->validate_addr('1234::1'); + }, ); # Ensure * is always allowed $this->assert_does_not_throw( callable: function () { - $client = new FreeRADIUSClient(ip_version: "ipaddr"); - $client->validate_addr("*"); - $client = new FreeRADIUSClient(ip_version: "ipv6addr"); - $client->validate_addr("*"); - } + $client = new FreeRADIUSClient(ip_version: 'ipaddr'); + $client->validate_addr('*'); + $client = new FreeRADIUSClient(ip_version: 'ipv6addr'); + $client->validate_addr('*'); + }, ); } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc index ce2a264b..6775ba19 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsSystemTimezoneTestCase.inc @@ -16,7 +16,7 @@ class APIModelsSystemTimezoneTestCase extends TestCase { # Ensure the timezone was updated correctly $zoneinfo = file_get_contents('/var/db/zoneinfo'); - $this->assert_equals($zoneinfo, 'America/Denver'.PHP_EOL); + $this->assert_equals($zoneinfo, 'America/Denver' . PHP_EOL); # Update the timezone again $timezone = new SystemTimezone(timezone: 'UTC'); @@ -24,7 +24,7 @@ class APIModelsSystemTimezoneTestCase extends TestCase { # Ensure the timezone was updated correctly $zoneinfo = file_get_contents('/var/db/zoneinfo'); - $this->assert_equals($zoneinfo, 'UTC'.PHP_EOL); + $this->assert_equals($zoneinfo, 'UTC' . PHP_EOL); } /** From 3ec45ee3f964f2ff308dbc1aaff8243f65cba610 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 20 Jul 2025 12:16:37 -0600 Subject: [PATCH 23/62] test(FreeRADIUSClient): correctly check for ipv6 address --- .../pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc index 0e8df6a8..efc5c807 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc @@ -69,7 +69,7 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { $raddb = file_get_contents('/usr/local/etc/raddb/clients.conf'); $this->assert_str_does_not_contain($raddb, 'client "testclient" {'); $this->assert_str_contains($raddb, 'client "newtestclient" {'); - $this->assert_str_contains($raddb, 'ipaddr = 4321::1'); + $this->assert_str_contains($raddb, 'ipv6addr = 4321::1'); $this->assert_str_contains($raddb, 'proto = tcp'); $this->assert_str_contains($raddb, 'nas_type = cisco'); $this->assert_str_contains($raddb, 'login = newtestlogin'); From 2de99788c22c751c209320f1a9954cff677c511a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sun, 20 Jul 2025 14:38:18 -0600 Subject: [PATCH 24/62] test(FreeRADIUSClient): validate entire addr field so labels are populated This fixes an issue where the labels referenced by validate_addr were missing and prevented validation from working correctly. --- .../pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc index efc5c807..8f59e099 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc @@ -92,7 +92,8 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { response_id: 'FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED', code: 400, callable: function () { - $client = new FreeRADIUSClient(ip_version: 'ipv6addr'); + $client = new FreeRADIUSClient(addr: "1.2.3.4", ip_version: 'ipv6addr'); + $client->addr->validate(); # Validate to so labels are populated $client->validate_addr('1.2.3.4'); }, ); @@ -100,7 +101,8 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { response_id: 'FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED', code: 400, callable: function () { - $client = new FreeRADIUSClient(ip_version: 'ipaddr'); + $client = new FreeRADIUSClient(addr: "1234::1", ip_version: 'ipaddr'); + $client->addr->validate(); # Validate to so labels are populated $client->validate_addr('1234::1'); }, ); From 969954ce66d5ffb09c6288b823c0f087facad832 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 21 Jul 2025 20:45:10 -0600 Subject: [PATCH 25/62] test(FreeRADIUSInterface): add tests for FreeRADIUSInterface model --- .../APIModelsFreeRADIUSInterfaceTestCase.inc | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc new file mode 100644 index 00000000..0893f7b9 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc @@ -0,0 +1,96 @@ +create(); + $raddb = file_get_contents('/usr/local/etc/raddb/sites-enabled/default'); + $this->assert_str_contains($raddb, 'type = auth'); + $this->assert_str_contains($raddb, 'ipv6addr = *'); + $this->assert_str_contains($raddb, 'port = 1812'); + + # Ensure we can read the created user from the config + $read_fr_interface = new FreeRADIUSInterface(id: $fr_interface->id); + $this->assert_equals($read_fr_interface->addr->value, '*'); + $this->assert_equals($read_fr_interface->port->value, '1812'); + $this->assert_equals($read_fr_interface->type->value, 'auth'); + $this->assert_equals($read_fr_interface->ip_version->value, 'ipv6addr'); + $this->assert_equals($read_fr_interface->description->value, 'Test interface'); + + # Ensure we can update the user + $fr_interface = new FreeRADIUSInterface( + id: $fr_interface->id, + addr: '127.0.0.1', + port: '1813', + type: 'acct', + ip_version: 'ipaddr', + description: 'New test interface', + ); + $fr_interface->update(); + $raddb = file_get_contents('/usr/local/etc/raddb/sites-enabled/default'); + $this->assert_str_does_not_contain($raddb, 'type = auth'); + $this->assert_str_does_not_contain($raddb, 'ipv6addr = *'); + $this->assert_str_does_not_contain($raddb, 'port = 1812'); + $this->assert_str_contains($raddb, 'type = acct'); + $this->assert_str_contains($raddb, 'ipaddr = 127.0.0.1'); + $this->assert_str_contains($raddb, 'port = 1813'); + + # Delete the user and ensure it is removed from the database + $fr_interface->delete(); + $raddb = file_get_contents('/usr/local/etc/raddb/sites-enabled/default'); + $this->assert_str_does_not_contain($raddb, 'listen {'); + } + + /** + * Checks that an error is thrown if the ip_version does not match the value provided + * in the addr field. + */ + public function test_ip_version_mismatch(): void { + $this->assert_throws_response( + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV4_NOT_ALLOWED', + code: 400, + callable: function () { + $fr_interface = new FreeRADIUSInterface(addr: '1.2.3.4', ip_version: 'ipv6addr'); + $fr_interface->addr->validate(); # Validate to so labels are populated + $fr_interface->validate_addr('1.2.3.4'); + }, + ); + $this->assert_throws_response( + response_id: 'FREERADIUS_INTERFACE_ADDR_IPV6_NOT_ALLOWED', + code: 400, + callable: function () { + $fr_interface = new FreeRADIUSInterface(addr: '1234::1', ip_version: 'ipaddr'); + $fr_interface->addr->validate(); # Validate to so labels are populated + $fr_interface->validate_addr('1234::1'); + }, + ); + + # Ensure * is always allowed + $this->assert_does_not_throw( + callable: function () { + $fr_interface = new FreeRADIUSInterface(ip_version: 'ipaddr'); + $fr_interface->validate_addr('*'); + $fr_interface = new FreeRADIUSInterface(ip_version: 'ipv6addr'); + $fr_interface->validate_addr('*'); + }, + ); + } +} From 130127793b94949a7aa0eec67a1a560501821f51 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 21 Jul 2025 20:45:51 -0600 Subject: [PATCH 26/62] chore: remove unnecessary use statement --- .../pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc | 1 - 1 file changed, 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc index 0893f7b9..13ab4fff 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSInterfaceTestCase.inc @@ -3,7 +3,6 @@ namespace RESTAPI\Tests; use RESTAPI\Core\TestCase; -use RESTAPI\Models\FreeRADIUSfr_interface; use RESTAPI\Models\FreeRADIUSInterface; class APIModelsFreeRADIUSInterfaceTestCase extends TestCase { From daf17a8ddc1a49bd491635c245d7b3f2cd1bd20d Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Mon, 21 Jul 2025 20:46:03 -0600 Subject: [PATCH 27/62] style: run prettier on changed files --- .../pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc index 8f59e099..ff7a9a6b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFreeRADIUSClientTestCase.inc @@ -92,7 +92,7 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { response_id: 'FREERADIUS_CLIENT_ADDR_IPV4_NOT_ALLOWED', code: 400, callable: function () { - $client = new FreeRADIUSClient(addr: "1.2.3.4", ip_version: 'ipv6addr'); + $client = new FreeRADIUSClient(addr: '1.2.3.4', ip_version: 'ipv6addr'); $client->addr->validate(); # Validate to so labels are populated $client->validate_addr('1.2.3.4'); }, @@ -101,7 +101,7 @@ class APIModelsFreeRADIUSClientTestCase extends TestCase { response_id: 'FREERADIUS_CLIENT_ADDR_IPV6_NOT_ALLOWED', code: 400, callable: function () { - $client = new FreeRADIUSClient(addr: "1234::1", ip_version: 'ipaddr'); + $client = new FreeRADIUSClient(addr: '1234::1', ip_version: 'ipaddr'); $client->addr->validate(); # Validate to so labels are populated $client->validate_addr('1234::1'); }, From 605a60329a7dd2bce17188a9226eb01e36efd205 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 22 Jul 2025 23:05:17 -0600 Subject: [PATCH 28/62] refactor: separate config logic from business logic in OpenVPNClientExport models #368 --- .../RESTAPI/Models/OpenVPNClientExport.inc | 621 ------------------ .../Models/OpenVPNClientExportConfig.inc | 216 ++++++ 2 files changed, 216 insertions(+), 621 deletions(-) delete mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc deleted file mode 100644 index 57197935..00000000 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc +++ /dev/null @@ -1,621 +0,0 @@ -packages = ['pfSense-pkg-openvpn-client-export']; - $this->package_includes = ['openvpn-client-export.inc']; - $this->always_apply = true; - $this->many = false; - - # - # some internal vars - # - $this->want_cert = false; - $this->nokeys = false; - - # - # Set model fields - # - $this->act = new StringField(required: true, choices: ['confinline', 'confzip'], help_text: 'Export format'); - - $this->vpnid = new ForeignModelField( - model_name: 'OpenVPNServer', - model_field: 'vpnid', - required: true, - help_text: 'The VPN ID of the OpenVPN server this client export corresponds to.', - ); - - $this->uid = new ForeignModelField( - model_name: 'User', - model_field: 'uid', - required: false, - default: null, - allow_null: true, - help_text: 'User ID', - ); - $this->crtref = new ForeignModelField( - model_name: 'Certificate', - model_field: 'refid', - required: false, - default: null, - allow_null: true, - help_text: 'Certificate refid', - ); - - # - # Client Connection Behavior - # - $this->useaddr = new StringField(required: false, default: 'serveraddr', help_text: 'Host Name Resolution'); - $this->verifyservercn = new StringField( - default: 'auto', - choices: ['auto', 'none'], - help_text: 'Verify Server CN', - ); - $this->blockoutsidedns = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Block Outside DNS', - ); - $this->legacy = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Legacy Client', - ); - $this->silent = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Silent Installer', - ); - $this->bindmode = new StringField( - default: 'nobind', - choices: ['nobind', 'lport0', 'bind'], - help_text: 'Bind Mode', - ); - - # - # Certificate Export Options - # - $this->usepkcs11 = new StringField( - choices: ['yes', 'no'], - default: 'no', - help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.', - ); - $this->pkcs11providers = new StringField(default: '', conditions: ['usepkcs11' => 'yes']); - $this->pkcs11id = new StringField(default: '', conditions: ['usepkcs11' => 'yes']); - $this->usetoken = new StringField( - choices: ['yes', 'no'], - default: 'no', - help_text: 'Use Microsoft Certificate Storage instead of local files.', - ); - $this->usepass = new StringField( - default: '', - choices: ['yes', 'no'], - help_text: 'Password Protect Certificate', - ); - $this->pass = new StringField( - default: '', - sensitive: true, - conditions: ['usepass' => 'yes'], - help_text: 'Certificate Password', - ); - $this->p12encryption = new StringField( - default: 'high', - choices: ['high', 'low', 'legacy'], - help_text: 'PKCS#12 Encryption', - ); - - # - # Proxy Options - # - $this->useproxy = new StringField(default: 'no', choices: ['yes', 'no'], help_text: 'Use A Proxy'); - $this->useproxytype = new StringField( - default: 'http', - choices: ['http', 'socks'], - conditions: ['useproxy' => 'yes'], - help_text: 'Proxy Type', - ); - $this->proxyaddr = new StringField( - required: true, - allow_empty: false, - default: null, - validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], - conditions: ['useproxy' => 'yes'], - help_text: 'Proxy IP Address', - ); - $this->proxyport = new PortField( - required: true, - unique: true, - default: null, - allow_alias: false, - allow_range: false, - conditions: ['useproxy' => 'yes'], - help_text: 'Proxy Port', - ); - $this->useproxypass = new StringField( - required: true, - default: null, - choices: ['none', 'basic', 'ntlm'], - conditions: ['useproxy' => true], - help_text: 'Proxy Authentication', - ); - $this->proxyuser = new StringField( - required: true, - default: null, - conditions: ['useproxy' => 'yes', 'useproxypass' => ['basic', 'ntlm']], - help_text: 'Proxy Username', - ); - $this->proxypass = new StringField( - required: true, - conditions: ['useproxy' => 'yes', 'useproxypass' => ['basic', 'ntlm']], - help_text: 'Proxy Password', - ); - - # - # Advanced - # - $this->advancedoptions = new StringField( - required: false, - default: '', - allow_empty: true, - help_text: 'Additional options to add to the OpenVPN client export configuration.', - ); - - # - $this->clientconfig = new Base64Field(default: null, allow_null: true, read_only: true); - - parent::__construct($id, $parent_id, $data, ...$options); - } - - /** - * many logic/code copied from /usr/local/www/vpn_openvpn_export.php. - */ - public function _create(): void { - global $config, $input_errors; - - $act = $this->act->value; - - $srvid = $this->vpnid->value; - - $useaddr = $this->useaddr->value; - - $verifyservercn = $this->verifyservercn->value; - $blockoutsidedns = $this->blockoutsidedns->value; - $legacy = $this->legacy->value; - $silent = $this->silent->value; - $bindmode = $this->bindmode->value; - - $usepkcs11 = $this->usepkcs11->value; - if ($usepkcs11 === 'yes') { - $usepkcs11 = true; - } else { - $usepkcs11 = false; - } - $pkcs11providers = $this->pkcs11providers->value; - $pkcs11id = $this->pkcs11id->value; - $usetoken = $this->usetoken->value; - if ($usetoken === 'yes') { - $usetoken = true; - } else { - $usetoken = false; - } - - $password = $this->pass->value; - $p12encryption = $this->p12encryption->value; - - $advancedoptions = $this->advancedoptions->value; - - # - # - # - $srvcfg = get_openvpnserver_by_id($srvid); - - $want_cert = $this->want_cert; - $nokeys = $this->nokeys; - - $cert = $this->cert; - $crtid = $this->crtid; - $usrid = $this->usrid; - - if ($srvcfg['mode'] != 'server_user' && !$usepkcs11 && !$usetoken && empty($cert['prv'])) { - throw new ServerError( - message: 'A private key cannot be empty if PKCS#11 or Microsoft Certificate Storage is not used.', - response_id: 'FIELD_INVALID_CHOICE', - ); - } - - $proxy = ''; - $useproxy = $this->useproxy->value; - if ($useproxy == 'yes') { - $proxy = []; - $proxy['ip'] = $this->proxy_addr->value; - $proxy['port'] = $this->proxy_port->value; - $proxy['proxy_type'] = $this->proxy_type->value; - $proxy['proxy_authtype'] = $this->proxy_authtype->value; - if ($proxy['proxy_authtype'] != 'none') { - $proxy['user'] = $this->proxy_user->value; - $proxy['password'] = $this->proxy_password->value; - } - } - - $exp_name = openvpn_client_export_prefix($srvid, $usrid, $crtid); - - if (substr($act, 0, 4) == 'conf') { - switch ($act) { - case 'confzip': - $exp_name = urlencode($exp_name . '-config.zip'); - $expformat = 'zip'; - break; - case 'conf_yealink_t28': - $exp_name = urlencode('client.tar'); - $expformat = 'yealink_t28'; - break; - case 'conf_yealink_t38g': - $exp_name = urlencode('client.tar'); - $expformat = 'yealink_t38g'; - break; - case 'conf_yealink_t38g2': - $exp_name = urlencode('client.tar'); - $expformat = 'yealink_t38g2'; - break; - case 'conf_snom': - $exp_name = urlencode('vpnclient.tar'); - $expformat = 'snom'; - break; - case 'confinline': - $exp_name = urlencode($exp_name . '-config.ovpn'); - $expformat = 'inline'; - break; - case 'confinlinedroid': - $exp_name = urlencode($exp_name . '-android-config.ovpn'); - $expformat = 'inlinedroid'; - break; - case 'confinlineconnect': - $exp_name = urlencode($exp_name . '-connect-config.ovpn'); - $expformat = 'inlineconnect'; - break; - case 'confinlinevisc': - $exp_name = urlencode($exp_name . '-viscosity-config.ovpn'); - $expformat = 'inlinevisc'; - break; - default: - $exp_name = urlencode($exp_name . '-config.ovpn'); - $expformat = 'baseconf'; - } - $exp_path = openvpn_client_export_config( - $srvid, - $usrid, - $crtid, - $useaddr, - $verifyservercn, - $blockoutsidedns, - $legacy, - $bindmode, - $usetoken, - $nokeys, - $proxy, - $expformat, - $password, - $p12encryption, - false, - false, - $advancedoptions, - $usepkcs11, - $pkcs11providers, - $pkcs11id, - ); - } - - if ($act == 'visc') { - $exp_name = urlencode($exp_name . '-Viscosity.visc.zip'); - $exp_path = viscosity_openvpn_client_config_exporter( - $srvid, - $usrid, - $crtid, - $useaddr, - $verifyservercn, - $blockoutsidedns, - $legacy, - $bindmode, - $usetoken, - $password, - $p12encryption, - $proxy, - $advancedoptions, - $usepkcs11, - $pkcs11providers, - $pkcs11id, - ); - } - - if (substr($act, 0, 4) == 'inst') { - $openvpn_version = substr($act, 5); - $exp_name = "openvpn-{$exp_name}-install-"; - switch ($openvpn_version) { - case 'Win7': - $legacy = true; - $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win7.exe"; - break; - case 'Win10': - $legacy = true; - $exp_name .= "{$legacy_openvpn_version}-I{$legacy_openvpn_version_rev}-Win10.exe"; - break; - case 'x86-previous': - $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-x86.exe"; - break; - case 'x64-previous': - $exp_name .= "{$previous_openvpn_version}-I{$previous_openvpn_version_rev}-amd64.exe"; - break; - case 'x86-current': - $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-x86.exe"; - break; - case 'x64-current': - default: - $exp_name .= "{$current_openvpn_version}-I{$current_openvpn_version_rev}-amd64.exe"; - break; - } - - $exp_name = urlencode($exp_name); - $exp_path = openvpn_client_export_installer( - $srvid, - $usrid, - $crtid, - $useaddr, - $verifyservercn, - $blockoutsidedns, - $legacy, - $bindmode, - $usetoken, - $password, - $p12encryption, - $proxy, - $advancedoptions, - substr($act, 5), - $usepkcs11, - $pkcs11providers, - $pkcs11id, - $silent, - ); - } - - /* pfSense >= 2.5.0 with OpenVPN >= 2.5.0 has ciphers not compatible with - * legacy clients, check for those and warn */ - if ($legacy) { - global $legacy_incompatible_ciphers; - $settings = get_openvpnserver_by_id($srvid); - if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { - $input_errors[] = gettext( - 'The Fallback Data Encryption Algorithm for the selected server is not compatible with Legacy clients.', - ); - } - } - - if (!$exp_path) { - $input_errors[] = 'Failed to export config files!'; - } - - if (empty($input_errors)) { - if ($act == 'conf' || substr($act, 0, 10) == 'confinline') { - $exp_size = strlen($exp_path); - } else { - $exp_size = filesize($exp_path); - } - header('Pragma: '); - header('Cache-Control: '); - header('Content-Type: application/octet-stream'); - header("Content-Disposition: attachment; filename={$exp_name}"); - header("Content-Length: $exp_size"); - if ($act == 'conf' || substr($act, 0, 10) == 'confinline') { - echo $exp_path; - } else { - readfile($exp_path); - @unlink($exp_path); - } - exit(); - } else { - throw new ServerError(message: 'Some errors occured: ' . $input_errors[0], response_id: 'UNKNONW_ERROR'); - } - } - - /** - * - */ - private function find_usrid_by_uid($uid) { - global $config, $input_errors; - - foreach ($config['system']['user'] as $idx => $u) { - if (intval($u['uid']) == intval($uid)) { - return $idx; - } - } - - return -1; - } - - /** - * - */ - private function find_crtid_by_crtref($crtref) { - global $config, $input_errors; - - foreach ($config['cert'] as $idx => $cert) { - if ($cert['refid'] == $crtref && $cert['type'] == 'user') { - return $idx; - } - } - - return -1; - } - - /** - * - */ - public function validate_useaddr(string $useaddr): string { - if ( - !( - is_ipaddr($useaddr) || - is_hostname($useaddr) || - in_array($useaddr, ['serveraddr', 'servermagic', 'servermagichost', 'serverhostname']) - ) - ) { - throw new ValidationError( - message: 'An IP address or hostname must be specified.', - response_id: 'useaddr error', - ); - } - - return $useaddr; - } - - /** - * - */ - public function validate_extra(): void { - $srv = $this->vpnid->get_related_model(); - $u = $this->uid->value; - $crtref = $this->crtref->value; - - # - # check if Certificate required for this VPN-instance - # - if ($srv->mode->value != 'server_user') { - $this->want_cert = true; - - if (!isset($crtref) || empty($crtref) || is_null($crtref)) { - throw new ValidationError( - message: 'certref must be specified for this vpnid', - response_id: 'CERTREF_REQUIRED_FOR_THIS_SERVER', - ); - } - - $cert_model = $this->crtref->get_related_model(); - if ($cert_model->type->value != 'user') { - throw new ServerError(message: 'Bad cert type for this crtref', response_id: 'FIELD_INVALID_CHOICE'); - } - - $this->cert = [ - 'crt' => $cert_model->crt->value, - 'prv' => $cert_model->prv->value, - ]; - - $this->crtid = $this->find_crtid_by_crtref($crtref); - } - - if ($srv->mode->value != 'server_user') { - $this->nokeys = true; - } - - # - # check if User required for this VPN-instance - # - if (isset($srv->authmode->value) && in_array('Local Database', $srv->authmode->value)) { - if (!isset($u) || empty($u) || is_null($u)) { - throw new ValidationError( - message: 'uid must be specified for this vpnid', - response_id: 'UID_REQUIRED_FOR_THIS_SERVER', - ); - } - - $user_model = $this->uid->get_related_model(); - - $this->usrid = $this->find_usrid_by_uid($user_model->uid->value); - - # - # check if this Crtref velongs to this User - # - $crtref_founded = 0; - foreach ($user_model->cert->value as $idx => $c) { - if ($c == $this->crtref->value) { - $crtref_founded = 1; - $this->crtid = $idx; // openvpn-client-export.inc require crtid as index of user certificates not all certificates - break; - } - } - if ($crtref_founded != 1) { - throw new ValidationError( - message: "Can't find this crtref for this uid", - response_id: 'CRTREF_UID_MISMATCH', - ); - } - } - - $usetoken = $this->usetoken->value; - $act = $this->act->value; - if ($usetoken == 'yes' && $act == 'confinline') { - throw new ValidationError( - message: 'Microsoft Certificate Storage cannot be used with an Inline configuration.', - response_id: 'FIELD_INVALID_CHOICE', - ); - } - if ( - $usetoken == 'yes' && - ($act == 'conf_yealink_t28' || - $act == 'conf_yealink_t38g' || - $act == 'conf_yealink_t38g2' || - $act == 'conf_snom') - ) { - throw new ValidationError( - message: 'Microsoft Certificate Storage cannot be used with a Yealink or SNOM configuration.', - response_id: 'FIELD_INVALID_CHOICE', - ); - } - } -} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc new file mode 100644 index 00000000..e774ccbd --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc @@ -0,0 +1,216 @@ +config_path = 'installedpackages/vpn_openvpn_export/serverconfig/item'; + $this->packages = ['pfSense-pkg-openvpn-client-export']; + $this->package_includes = ['openvpn-client-export.inc']; + $this->always_apply = true; + $this->many = true; + + $this->server = new ForeignModelField( + model_name: 'OpenVPNServer', + model_field: 'vpnid', + required: true, + help_text: 'The VPN ID of the OpenVPN server this client export corresponds to.', + ); + $this->useaddr = new StringField( + required: false, + default: 'serveraddr', + choices: ['serveraddr', 'servermagic', 'servermagichost', 'serverhostname', 'other'], + help_text: 'The method to use for the OpenVPN server address listed in the config export.', + ); + $this->useaddr_hostname = new StringField( + required: false, + default: '', + allow_empty: true, + conditions: ['useaddr' => 'other'], + validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)], + help_text: 'The hostname to use for the OpenVPN server address.', + ); + $this->verifyservercn = new StringField( + default: 'auto', + choices: ['auto', 'none'], + help_text: 'Verify the server certificate Common Name (CN) when the client connects.', + ); + $this->blockoutsidedns = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Block access to DNS servers except across OpenVPN while connected, forcing clients to ' . + 'use only VPN DNS servers.', + ); + $this->legacy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Do not include OpenVPN 2.5 and later settings in the client configuration.', + ); + $this->silent = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Create Windows installer for unattended deploy.', + ); + $this->bindmode = new StringField( + default: 'nobind', + choices: ['nobind', 'lport0', 'bind'], + help_text: 'The port binding mode to use. If OpenVPN client binds to the default OpenVPN port (1194), ' . + 'two clients may not run concurrently.', + ); + $this->usepkcs11 = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.', + ); + $this->pkcs11providers = new StringField( + required: true, + many: true, + delimiter: ' ', + conditions: ['usepkcs11' => true], + help_text: 'The client local path to the PKCS#11 provider(s) (DLL, module)', + ); + $this->pkcs11id = new StringField( + required: true, + conditions: ['usepkcs11' => true], + help_text: 'The object\'s ID on the PKCS#11 device.', + ); + $this->usetoken = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use Microsoft Certificate Storage instead of local files.', + ); + $this->usepass = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use a password to protect the PKCS#12 file contents or key in Viscosity bundles.', + ); + $this->pass = new StringField( + required: true, + sensitive: true, + conditions: ['usepass' => true], + help_text: 'Password used to protect the certificate file contents.', + ); + $this->p12encryption = new StringField( + default: 'high', + choices: ['high', 'low', 'legacy'], + help_text: 'The level of encryption to use when exporting a PKCS#12 archive. Encryption support varies ' . + 'by Operating System and program', + ); + $this->useproxy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use proxy to communicate with the OpenVPN server.', + ); + $this->useproxytype = new StringField( + default: 'http', + choices: ['http', 'socks'], + conditions: ['useproxy' => true], + help_text: 'The proxy type to use.', + ); + $this->proxyaddr = new StringField( + required: true, + conditions: ['useproxy' => true], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_fqdn: true)], + help_text: 'The IP address or hostname of the proxy server to use.', + ); + $this->proxyport = new PortField( + required: true, + allow_alias: false, + allow_range: false, + conditions: ['useproxy' => true], + help_text: 'The port where the proxy server is listening.', + ); + $this->useproxypass = new StringField( + required: true, + choices: ['none', 'basic', 'ntlm'], + conditions: ['useproxy' => true], + help_text: 'The type of authentication to use for the proxy server.', + ); + $this->proxyuser = new StringField( + required: true, + conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], + help_text: 'The username to use to authenticate with the proxy server.', + ); + $this->proxypass = new StringField( + required: true, + sensitive: true, + conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], + help_text: 'The password to use to authenticate with the proxy server.', + ); + $this->advancedoptions = new Base64Field( + required: false, + default: '', + allow_empty: true, + help_text: 'Additional options to add to the OpenVPN client export configuration.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Add extra validation to the 'legacy' field. This is used to ensure that legacy ciphers are even + * supported by the OpenVPN server. + */ + public function validate_legacy(bool $legacy): bool { + if ($legacy) { + global $legacy_incompatible_ciphers; + $settings = get_openvpnserver_by_id($this->server->value); + if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { + throw new ConflictError( + message: 'The Fallback Data Encryption Algorithm for the selected server is not compatible with ' . + 'Legacy clients.', + response_id: 'OPENVPN_CLIENT_EXPORT_CONFIG_LEGACY_CIPHER_NOT_COMPATIBLE', + ); + } + } + + return $legacy; + } +} From 9b437fe220fd8f644d08500adfe1c7f27892df2c Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 22 Jul 2025 23:07:34 -0600 Subject: [PATCH 29/62] refactor: setup endpoints for openvpn client export configs --- ... VPNOpenVPNClientExportConfigEndpoint.inc} | 10 ++++---- .../VPNOpenVPNClientExportConfigsEndpoint.inc | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) rename pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/{VPNOpenVPNClientExportEndpoint.inc => VPNOpenVPNClientExportConfigEndpoint.inc} (58%) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc similarity index 58% rename from pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc rename to pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc index 732978c5..63b0b510 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc @@ -7,15 +7,15 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Endpoint; /** - * Defines an Endpoint for interacting with a single OpenVPNExport Model object at + * Defines an Endpoint for interacting with a single OpenVPNExportConfig Model object at * /api/v2/vpn/openvpn/clientexport. */ -class VPNOpenVPNClientExportEndpoint extends Endpoint { +class VPNOpenVPNClientExportConfigEndpoint extends Endpoint { public function __construct() { # Set Endpoint attributes - $this->url = '/api/v2/vpn/openvpn/clientexport'; - $this->model_name = 'OpenVPNClientExport'; - $this->request_method_options = ['POST']; + $this->url = '/api/v2/vpn/openvpn/client_export/config'; + $this->model_name = 'OpenVPNClientExportConfig'; + $this->request_method_options = ['GET', 'POST', "PATCH", 'DELETE']; $this->many = false; # Construct the parent Endpoint object diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc new file mode 100644 index 00000000..99026a82 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigsEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/vpn/openvpn/client_export/configs'; + $this->model_name = 'OpenVPNClientExportConfig'; + $this->many = true; + $this->request_method_options = ['GET', 'PUT', 'DELETE']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} From e447606857895bea664bbfa1f1862699884205e5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 22 Jul 2025 23:10:48 -0600 Subject: [PATCH 30/62] style: run prettier on changed files --- .../RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc index 63b0b510..e630cd8c 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportConfigEndpoint.inc @@ -15,7 +15,7 @@ class VPNOpenVPNClientExportConfigEndpoint extends Endpoint { # Set Endpoint attributes $this->url = '/api/v2/vpn/openvpn/client_export/config'; $this->model_name = 'OpenVPNClientExportConfig'; - $this->request_method_options = ['GET', 'POST', "PATCH", 'DELETE']; + $this->request_method_options = ['GET', 'POST', 'PATCH', 'DELETE']; $this->many = false; # Construct the parent Endpoint object From f9c546e382025d571e45a1a4659879bcfc86c6e3 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 24 Jul 2025 12:26:22 -0600 Subject: [PATCH 31/62] refactor: move OpenVPNClientExport* fields to shared traits This will allow both the OpenVPNClientExport and OpenVPNClientExportConfig models to share the same fields without duplicating code. --- .../ModelTraits/OpenVPNClientExportTraits.inc | 208 ++++++++++++++++++ .../Models/OpenVPNClientExportConfig.inc | 201 +---------------- 2 files changed, 217 insertions(+), 192 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc new file mode 100644 index 00000000..16717e8f --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc @@ -0,0 +1,208 @@ +server = new ForeignModelField( + model_name: 'OpenVPNServer', + model_field: 'vpnid', + model_query: ['disable' => false, 'mode__startswith' => 'server'], + required: true, + help_text: 'The VPN ID of the OpenVPN server this client export corresponds to.', + ); + $this->useaddr = new StringField( + required: false, + default: 'serveraddr', + choices: ['serveraddr', 'servermagic', 'servermagichost', 'serverhostname', 'other'], + help_text: 'The method to use for the OpenVPN server address listed in the config export.', + ); + $this->useaddr_hostname = new StringField( + required: false, + default: '', + allow_empty: true, + conditions: ['useaddr' => 'other'], + validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)], + help_text: 'The hostname to use for the OpenVPN server address.', + ); + $this->verifyservercn = new StringField( + default: 'auto', + choices: ['auto', 'none'], + help_text: 'Verify the server certificate Common Name (CN) when the client connects.', + ); + $this->blockoutsidedns = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Block access to DNS servers except across OpenVPN while connected, forcing clients to ' . + 'use only VPN DNS servers.', + ); + $this->legacy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Do not include OpenVPN 2.5 and later settings in the client configuration.', + ); + $this->silent = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Create Windows installer for unattended deploy.', + ); + $this->bindmode = new StringField( + default: 'nobind', + choices: ['nobind', 'lport0', 'bind'], + help_text: 'The port binding mode to use. If OpenVPN client binds to the default OpenVPN port (1194), ' . + 'two clients may not run concurrently.', + ); + $this->usepkcs11 = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.', + ); + $this->pkcs11providers = new StringField( + required: true, + many: true, + delimiter: ' ', + conditions: ['usepkcs11' => true], + help_text: 'The client local path to the PKCS#11 provider(s) (DLL, module)', + ); + $this->pkcs11id = new StringField( + required: true, + conditions: ['usepkcs11' => true], + help_text: 'The object\'s ID on the PKCS#11 device.', + ); + $this->usetoken = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use Microsoft Certificate Storage instead of local files.', + ); + $this->usepass = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use a password to protect the PKCS#12 file contents or key in Viscosity bundles.', + ); + $this->pass = new StringField( + required: true, + sensitive: true, + conditions: ['usepass' => true], + help_text: 'Password used to protect the certificate file contents.', + ); + $this->p12encryption = new StringField( + default: 'high', + choices: ['high', 'low', 'legacy'], + help_text: 'The level of encryption to use when exporting a PKCS#12 archive. Encryption support varies ' . + 'by Operating System and program', + ); + $this->useproxy = new BooleanField( + default: false, + indicates_true: 'yes', + indicates_false: '', + help_text: 'Use proxy to communicate with the OpenVPN server.', + ); + $this->useproxytype = new StringField( + default: 'http', + choices: ['http', 'socks'], + conditions: ['useproxy' => true], + help_text: 'The proxy type to use.', + ); + $this->proxyaddr = new StringField( + required: true, + conditions: ['useproxy' => true], + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_fqdn: true)], + help_text: 'The IP address or hostname of the proxy server to use.', + ); + $this->proxyport = new PortField( + required: true, + allow_alias: false, + allow_range: false, + conditions: ['useproxy' => true], + help_text: 'The port where the proxy server is listening.', + ); + $this->useproxypass = new StringField( + required: true, + choices: ['none', 'basic', 'ntlm'], + conditions: ['useproxy' => true], + help_text: 'The type of authentication to use for the proxy server.', + ); + $this->proxyuser = new StringField( + required: true, + conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], + help_text: 'The username to use to authenticate with the proxy server.', + ); + $this->proxypass = new StringField( + required: true, + sensitive: true, + conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], + help_text: 'The password to use to authenticate with the proxy server.', + ); + $this->advancedoptions = new Base64Field( + required: false, + default: '', + allow_empty: true, + help_text: 'Additional options to add to the OpenVPN client export configuration.', + ); + } + + /** + * Add extra validation to the 'legacy' field. This is used to ensure that legacy ciphers are even + * supported by the OpenVPN server. + */ + public function validate_legacy(bool $legacy): bool { + if ($legacy) { + global $legacy_incompatible_ciphers; + $settings = get_openvpnserver_by_id($this->server->value); + if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { + throw new ConflictError( + message: 'The Fallback Data Encryption Algorithm for the selected server is not compatible with ' . + 'Legacy clients.', + response_id: 'OPENVPN_CLIENT_EXPORT_CONFIG_LEGACY_CIPHER_NOT_COMPATIBLE', + ); + } + } + + return $legacy; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc index e774ccbd..fd324870 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc @@ -5,42 +5,17 @@ namespace RESTAPI\Models; require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; -use RESTAPI\Fields\Base64Field; -use RESTAPI\Fields\BooleanField; -use RESTAPI\Fields\ForeignModelField; -use RESTAPI\Fields\PortField; -use RESTAPI\Fields\StringField; -use RESTAPI\Responses\ConflictError; -use RESTAPI\Validators\HostnameValidator; -use RESTAPI\Validators\IPAddressValidator; +use RESTAPI\ModelTraits\OpenVPNClientExportTraits; + /** - * Defines a Model that represents an OpenVPN Client Export config. + * Defines a Model that represents an OpenVPN Client Export default config for specific OpenVPN servers. */ class OpenVPNClientExportConfig extends Model { - public ForeignModelField $server; - public StringField $useaddr; - public StringField $useaddr_hostname; - public StringField $verifyservercn; - public BooleanField $blockoutsidedns; - public BooleanField $legacy; - public BooleanField $silent; - public StringField $bindmode; - public BooleanField $usepkcs11; - public StringField $pkcs11providers; - public StringField $pkcs11id; - public BooleanField $usetoken; - public BooleanField $usepass; - public StringField $pass; - public StringField $p12encryption; - public BooleanField $useproxy; - public StringField $useproxytype; - public StringField $proxyaddr; - public PortField $proxyport; - public StringField $useproxypass; - public StringField $proxyuser; - public StringField $proxypass; - public Base64Field $advancedoptions; + # Inherit shared traits (fields and methods) from the OpenVPNClientExportTraits trait. + # This allows the OpenVPNClientExportConfig and OpenVPNClientExport Model classes to share + # the same fields since they are identical in structure, but serve two distinct purposes. + use OpenVPNClientExportTraits; public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { # Set model attributes @@ -50,167 +25,9 @@ class OpenVPNClientExportConfig extends Model { $this->always_apply = true; $this->many = true; - $this->server = new ForeignModelField( - model_name: 'OpenVPNServer', - model_field: 'vpnid', - required: true, - help_text: 'The VPN ID of the OpenVPN server this client export corresponds to.', - ); - $this->useaddr = new StringField( - required: false, - default: 'serveraddr', - choices: ['serveraddr', 'servermagic', 'servermagichost', 'serverhostname', 'other'], - help_text: 'The method to use for the OpenVPN server address listed in the config export.', - ); - $this->useaddr_hostname = new StringField( - required: false, - default: '', - allow_empty: true, - conditions: ['useaddr' => 'other'], - validators: [new HostnameValidator(allow_hostname: true, allow_domain: true, allow_fqdn: true)], - help_text: 'The hostname to use for the OpenVPN server address.', - ); - $this->verifyservercn = new StringField( - default: 'auto', - choices: ['auto', 'none'], - help_text: 'Verify the server certificate Common Name (CN) when the client connects.', - ); - $this->blockoutsidedns = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Block access to DNS servers except across OpenVPN while connected, forcing clients to ' . - 'use only VPN DNS servers.', - ); - $this->legacy = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Do not include OpenVPN 2.5 and later settings in the client configuration.', - ); - $this->silent = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Create Windows installer for unattended deploy.', - ); - $this->bindmode = new StringField( - default: 'nobind', - choices: ['nobind', 'lport0', 'bind'], - help_text: 'The port binding mode to use. If OpenVPN client binds to the default OpenVPN port (1194), ' . - 'two clients may not run concurrently.', - ); - $this->usepkcs11 = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Use PKCS#11 storage device (cryptographic token, HSM, smart card) instead of local files.', - ); - $this->pkcs11providers = new StringField( - required: true, - many: true, - delimiter: ' ', - conditions: ['usepkcs11' => true], - help_text: 'The client local path to the PKCS#11 provider(s) (DLL, module)', - ); - $this->pkcs11id = new StringField( - required: true, - conditions: ['usepkcs11' => true], - help_text: 'The object\'s ID on the PKCS#11 device.', - ); - $this->usetoken = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Use Microsoft Certificate Storage instead of local files.', - ); - $this->usepass = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Use a password to protect the PKCS#12 file contents or key in Viscosity bundles.', - ); - $this->pass = new StringField( - required: true, - sensitive: true, - conditions: ['usepass' => true], - help_text: 'Password used to protect the certificate file contents.', - ); - $this->p12encryption = new StringField( - default: 'high', - choices: ['high', 'low', 'legacy'], - help_text: 'The level of encryption to use when exporting a PKCS#12 archive. Encryption support varies ' . - 'by Operating System and program', - ); - $this->useproxy = new BooleanField( - default: false, - indicates_true: 'yes', - indicates_false: '', - help_text: 'Use proxy to communicate with the OpenVPN server.', - ); - $this->useproxytype = new StringField( - default: 'http', - choices: ['http', 'socks'], - conditions: ['useproxy' => true], - help_text: 'The proxy type to use.', - ); - $this->proxyaddr = new StringField( - required: true, - conditions: ['useproxy' => true], - validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true, allow_fqdn: true)], - help_text: 'The IP address or hostname of the proxy server to use.', - ); - $this->proxyport = new PortField( - required: true, - allow_alias: false, - allow_range: false, - conditions: ['useproxy' => true], - help_text: 'The port where the proxy server is listening.', - ); - $this->useproxypass = new StringField( - required: true, - choices: ['none', 'basic', 'ntlm'], - conditions: ['useproxy' => true], - help_text: 'The type of authentication to use for the proxy server.', - ); - $this->proxyuser = new StringField( - required: true, - conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], - help_text: 'The username to use to authenticate with the proxy server.', - ); - $this->proxypass = new StringField( - required: true, - sensitive: true, - conditions: ['useproxy' => true, 'useproxypass' => ['basic', 'ntlm']], - help_text: 'The password to use to authenticate with the proxy server.', - ); - $this->advancedoptions = new Base64Field( - required: false, - default: '', - allow_empty: true, - help_text: 'Additional options to add to the OpenVPN client export configuration.', - ); + # Setup shared fields defined in OpenVPNClientExportTraits + $this->__setup_fields(); parent::__construct($id, $parent_id, $data, ...$options); } - - /** - * Add extra validation to the 'legacy' field. This is used to ensure that legacy ciphers are even - * supported by the OpenVPN server. - */ - public function validate_legacy(bool $legacy): bool { - if ($legacy) { - global $legacy_incompatible_ciphers; - $settings = get_openvpnserver_by_id($this->server->value); - if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { - throw new ConflictError( - message: 'The Fallback Data Encryption Algorithm for the selected server is not compatible with ' . - 'Legacy clients.', - response_id: 'OPENVPN_CLIENT_EXPORT_CONFIG_LEGACY_CIPHER_NOT_COMPATIBLE', - ); - } - } - - return $legacy; - } } From 37d696e9845793e0720fb496460f08b41926e7a6 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 24 Jul 2025 12:27:45 -0600 Subject: [PATCH 32/62] refactor: redo OpenVPNClientExport model to only handle export logic This separates the default config portion from the actual export logic. This Model now references the default server config, and allows admins to override fields specified in the defaults then generates the client configuration based on those values. --- .../VPNOpenVPNClientExportEndpoint.inc | 32 ++ .../RESTAPI/Models/OpenVPNClientExport.inc | 389 ++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc new file mode 100644 index 00000000..549b55a5 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -0,0 +1,32 @@ +url = '/api/v2/vpn/openvpn/client_export'; + $this->model_name = 'OpenVPNClientExport'; + $this->request_method_options = ['POST']; + $this->encode_content_handlers = ['BinaryContentHandler', 'JSONContentHandler']; + $this->many = false; + + # Set help texts + $this->post_help_text = 'Export an OpenVPN Client configuration. Before using this endpoint, you must define ' . + 'a default export configuration for your OpenVPN server(s) using the the endpoint at ' . + '/api/v2/openvpn/vpn/client_export/config as you will need its ID to use this endpoint. ' . + 'Any specific configurations made to this endpoint will override the default configurations, ' . + 'but will not store them in the pfSense configuration.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc new file mode 100644 index 00000000..9fe53d14 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc @@ -0,0 +1,389 @@ +config_path = 'installedpackages/vpn_openvpn_export/serverconfig/item'; + $this->packages = ['pfSense-pkg-openvpn-client-export']; + $this->package_includes = ['openvpn-client-export.inc']; + $this->auto_create_id = false; // Create is used to initiate the export process, not creating new objects + $this->many = true; + + # Setup shared fields defined in OpenVPNClientExportTraits + $this->__setup_fields(); + + # Set fields specific to this model + $this->type = new StringField( + required: true, + choices: [ + 'confzip', + 'conf_yealink_t28', + 'conf_yealink_t38g', + 'conf_yealink_t38g2', + 'conf_snom', + 'confinline', + 'confinlinedroid', + 'confinlineconnect', + 'confinlinevisc', + 'inst-Win7', + 'inst-Win10', + 'inst-x86-previous', + 'inst-x64-previous', + 'inst-x86-current', + 'inst-x64-current', + 'visc' + ], + help_text: 'The type of OpenVPN client export to generate. This determines the format and content of '. + 'the export file.', + ); + $this->username = new ForeignModelField( + model_name: 'User', + model_field: 'name', + default: null, + allow_null: true, + help_text: 'The username of the user this client export corresponds to. This is only applicable ' . + 'for OpenVPN servers that use the Local Database AND client certificates.', + ); + $this->certref = new ForeignModelField( + model_name: 'Certificate', + model_field: 'refid', + default: null, + allow_null: true, + help_text: 'The reference ID of the certificate to use for this OpenVPN client export. This is only ' . + 'applicable for OpenVPN servers that require client certificates.', + ); + $this->filename = new StringField( + required: false, + default: null, + allow_null: true, + read_only: true, + help_text: 'The filename used when exporting the OpenVPN client export. This value cannot be changed', + ); + $this->binary_data = new StringField( + required: false, + default: '', + allow_empty: true, + read_only: true, + help_text: 'The binary data of the OpenVPN client export. This is used to store the actual ' . + 'exported configuration file content. When the content-type is set to "application/octet-stream", ' . + 'this field will contain the data of the OpenVPN client export download.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Performs extra validation on the server field. This is primarily intended to ensure that the requested + * export parameters are compatible with the OpenVPN server settings. + * @param string $server The OpenVPN server ID to validate. + * @return string The validated OpenVPN server ID. + * @throws ValidationError If the server mode requires a certificate reference but none is provided, + * @throws ValidationError If the server mode requires a username but none is provided. + */ + public function validate_server(string $server): string { + # Obtain the server object + $server_obj = $this->server->get_related_model(); + + # If this server supports client certs, require certref to be set. + if (in_array($server_obj->mode->value, ['server_tls', 'server_tls_user']) and !$this->certref->value) { + throw new ValidationError( + message: "Field 'certref' is required for OpenVPN server mode '{$server_obj->mode->value}'.", + response_id: 'OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED', + ); + } + + # Require a username to be set if the server is only using the Local Database for authentication. + if ($server_obj->authmode->value === ["Local Database"] and !$this->username->value) { + throw new ValidationError( + message: "Field 'username' is required for OpenVPN server mode '{$server_obj->mode->value}' " . + "with authentication mode 'Local Database'.", + response_id: 'OPENVPN_CLIENT_EXPORT_USERNAME_REQUIRED', + ); + } + + return $server; + } + + /** + * Perform extra validation to the type field. This is primarily used to ensure that certain parameters are set + * or not set when a specific type of export is requested. + * @param string $type The type of OpenVPN client export to validate. + * @return string The validated type of OpenVPN client export. + * @throws ValidationError If the type is not compatible with the 'usetoken' field. + */ + public function validate_type(string $type): string + { + $usetoken_forbidden_types = [ + 'confzip', + 'conf_yealink_t28', + 'conf_yealink_t38g', + 'conf_yealink_t38g2', + 'conf_snom', + 'confinline', + 'confinlinedroid', + 'confinlineconnect', + 'confinlinevisc', + ]; + + # Do not allow confinline types to have 'usetoken' enabled + if (in_array($type, $usetoken_forbidden_types) and $this->usetoken->value) { + throw new ValidationError( + message: "Field 'usetoken' cannot be enabled for OpenVPN client export type '$type'.", + response_id: 'OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED', + ); + } + + return $type; + } + + /** + * Perform extra validation to the certref field. This is primarily intended to ensure the specified user is the + * owner of the certificate being referenced. + * @param string $certref The certificate reference ID to validate. + * @return string The validated certificate reference ID. + * @throws NotFoundError If the specified certificate reference ID does not belong to the user. + * @throws NotAcceptableError If the certificate does not have a private key associated with it and is required + */ + public function validate_certref(string $certref): string { + # Skip this validation if no username is specified. + if (!$this->username->value) { + return $certref; + } + + # Ensure the certref is listed in the user's certificates. + if (!in_array($certref, $this->username->get_related_model()->cert->value)) { + throw new NotFoundError( + message: "User '{$this->username->value}' is not assigned a certificate with reference ID '$certref'.", + response_id: 'OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND', + + ); + } + + # Do not allow the referenced cert to not have a private key unless usepkcs11 or usetoken is set + $cert_obj = $this->certref->get_related_model(); + if (!$cert_obj->prv->value and (!$this->usepkcs11->value or !$this->usetoken->value)) { + throw new ValidationError( + message: "Certificate with reference ID '$certref' does not have a private key. " . + "Ensure the certificate is valid and has a private key.", + response_id: 'OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY', + ); + } + + return $certref; + } + + /** + * Generates the OpenVPN client export configuration file based on the provided parameters. + * @throws ServerError If the export fails for an unknown reason + */ + protected function _create(): void { + global $legacy_incompatible_ciphers; + $legacy_incompatible_ciphers = $legacy_incompatible_ciphers ?? []; + # Generate a the file export based on the export type + $export_data = null; + if (str_starts_with($this->type->value, 'conf')) { + $export_data = $this->export_config(); + } + if (str_starts_with($this->type->value, 'inst-')) { + $export_data = $this->export_installer(); + } + if ($this->type->value === 'visc') { + $export_data = $this->export_viscosity(); + } + + # If no valid filepath was given after generating, we know the export failed. Throw an error. + if (!$export_data) { + throw new ServerError( + message: 'The OpenVPN client export could not be created for unknown reasons.', + response_id: 'OPENVPN_CLIENT_EXPORT_CREATION_FAILED', + ); + } + + # When the export data is a filepath, set the binary data to its contents + if (is_file($export_data)) { + $this->filename->value = $this->__get_export_filename(); + $this->binary_data->value = file_get_contents($export_data); + @unlink($export_data); + return; + } + + # Otherwise, just use the value directly + $this->binary_data->value = $export_data; + } + + /** + * Generates the OpenVPN client export configuration file based on this objects properties. + * @return string|null The filepath to the generate OpenVPN client export configuration file, or null if the + * export failed. + * @note If a confinline type is used, the configuration file will be returned as a string instead of a filepath + */ + public function export_config(): string|null { + return openvpn_client_export_config( + srvid: $this->server->value, + usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, + crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + useaddr: $this->useaddr_hostname->value ?? $this->useaddr->value, + verifyservercn: $this->verifyservercn->value, + blockoutsidedns: $this->blockoutsidedns->value, + legacy: $this->legacy->value, + bindmode: $this->bindmode->value, + usetoken: $this->usetoken->value, + nokeys: $this->server->get_related_model()->mode->value === "server_user", + proxy: $this->__get_proxy_config(), + expformat: str_replace(['conf_', 'conf'], '', $this->type->value), + outpass: $this->pass->value, + p12encryption: $this->p12encryption->value, + skiptls: false, + doslines: false, + advancedoptions: $this->advancedoptions->value, + usepkcs11: $this->usepkcs11->value, + pkcs11providers: $this->pkcs11providers->value, + pkcs11id: $this->pkcs11id->value + ); + } + + /** + * Exports the OpenVPN client export installer file based on the provided parameters. + * @return string|null The filepath to the generated OpenVPN client export installer file, or null if the + * export failed. + */ + public function export_installer(): string|null { + return openvpn_client_export_installer( + srvid: $this->server->value, + usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, + crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + useaddr: $this->useaddr_hostnam->value ?? $this->useaddr->value, + verifyservercn: $this->verifyservercn->value, + blockoutsidedns: $this->blockoutsidedns->value, + legacy: $this->legacy->value, + bindmode: $this->bindmode->value, + usetoken: $this->usetoken->value, + outpass: $this->pass->value, + p12encryption: $this->p12encryption->value, + proxy: $this->__get_proxy_config(), + advancedoptions: $this->advancedoptions->value, + openvpn_version: substr($this->type->value, 5), + usepkcs11: $this->usepkcs11->value, + pkcs11providers: $this->pkcs11providers->value, + pkcs11id: $this->pkcs11id->value, + silent: $this->silent->value, + ); + } + + /** + * Exports the OpenVPN client export configuration file for Viscosity based on the provided parameters. + * @return string|null The filepath to the generated OpenVPN client export configuration file for Viscosity, + * or null if the export failed. + */ + public function export_viscosity(): string|null { + return viscosity_openvpn_client_config_exporter( + srvid: $this->server->value, + usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, + crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + useaddr: $this->useaddr_hostname->value ?? $this->useaddr->value, + verifyservercn: $this->verifyservercn->value, + blockoutsidedns: $this->blockoutsidedns->value, + legacy: $this->legacy->value, + bindmode: $this->bindmode->value, + usetoken: $this->usetoken->value, + outpass: $this->pass->value, + p12encryption: $this->p12encryption->value, + proxy: $this->__get_proxy_config(), + advancedoptions: $this->advancedoptions->value, + usepkcs11: $this->usepkcs11->value, + pkcs11providers: $this->pkcs11providers->value, + pkcs11id: $this->pkcs11id->value, + ); + } + + /** + * Obtains the proxy configuration array expected by pfSense functions. + * @return array the proxy configuration array expected by pfSense functions + */ + private function __get_proxy_config(): array { + return [ + "ip" => $this->proxyaddr->value, + "port" => $this->proxyport->value, + "user" => $this->proxyuser->value, + "password" => $this->proxypass->value, + "proxy_type" => $this->useproxytype->value, + "proxy_authtype" => $this->useproxypass->value, + ]; + } + + private function __get_export_filename(): string { + global $current_openvpn_version, $current_openvpn_version_rev; + global $previous_openvpn_version, $previous_openvpn_version_rev; + global $legacy_openvpn_version, $legacy_openvpn_version_rev; + + # Obtain the filename prefix + $filename_prefix = openvpn_client_export_prefix( + srvid: $this->server->value, + usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, + crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + ); + + # Determine the conf file suffix based on the export type + if (str_starts_with($this->type->value, "conf")) { + $filename_suffix = match ($this->type->value) { + "confzip" => "-config.zip", + "conf_yealink_t38g", "conf_yealink_t28", "conf_yealink_t38g2" => "client.tar", + "conf_snom" => "vpnclient.tar", + "confinlinedroid" => "-android-config.ovpn", + "confinlineconnect" => "-connect-config.ovpn", + "confinlinevisc" => "-viscosity-config.ovpn", + default => "-config.ovpn", + }; + } + # Determine the installer file suffix based on the export type + elseif (str_starts_with($this->type->value, "inst-")) { + $filename_suffix = match ($this->type->value) { + "inst-Win7" => "$legacy_openvpn_version}-I$legacy_openvpn_version_rev-Win7.exe", + "inst-Win10" => "$legacy_openvpn_version-I$legacy_openvpn_version_rev-Win10.exe", + "inst-x86-previous" => "$previous_openvpn_version-I$previous_openvpn_version_rev-x86.exe", + "inst-x64-previous" => "$previous_openvpn_version-I$previous_openvpn_version_rev-amd64.exe", + "inst-x86-current" => "$current_openvpn_version-I$current_openvpn_version_rev-x86.exe", + default => "$current_openvpn_version-I$current_openvpn_version_rev-amd64.exe" + }; + } + # If the type is not recognized, throw an error + else { + throw new ServerError( + message: "Could not determine appropriate file name.", + response_id: 'OPENVPN_CLIENT_EXPORT_FILENAME_NOT_DETERMINED', + ); + } + + return urlencode($filename_prefix.$filename_suffix); + } +} From 8ec14a8499f8e4aae6cf0b28584308fe0a17a473 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 24 Jul 2025 12:28:13 -0600 Subject: [PATCH 33/62] style: run prettier on changed files --- .../VPNOpenVPNClientExportEndpoint.inc | 3 +- .../ModelTraits/OpenVPNClientExportTraits.inc | 8 +- .../RESTAPI/Models/OpenVPNClientExport.inc | 81 +++++++++---------- .../Models/OpenVPNClientExportConfig.inc | 1 - 4 files changed, 45 insertions(+), 48 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc index 549b55a5..1f2193cb 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -20,7 +20,8 @@ class VPNOpenVPNClientExportEndpoint extends Endpoint { $this->many = false; # Set help texts - $this->post_help_text = 'Export an OpenVPN Client configuration. Before using this endpoint, you must define ' . + $this->post_help_text = + 'Export an OpenVPN Client configuration. Before using this endpoint, you must define ' . 'a default export configuration for your OpenVPN server(s) using the the endpoint at ' . '/api/v2/openvpn/vpn/client_export/config as you will need its ID to use this endpoint. ' . 'Any specific configurations made to this endpoint will override the default configurations, ' . diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc index 16717e8f..db9f20a0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/OpenVPNClientExportTraits.inc @@ -74,7 +74,7 @@ trait OpenVPNClientExportTraits { indicates_true: 'yes', indicates_false: '', help_text: 'Block access to DNS servers except across OpenVPN while connected, forcing clients to ' . - 'use only VPN DNS servers.', + 'use only VPN DNS servers.', ); $this->legacy = new BooleanField( default: false, @@ -92,7 +92,7 @@ trait OpenVPNClientExportTraits { default: 'nobind', choices: ['nobind', 'lport0', 'bind'], help_text: 'The port binding mode to use. If OpenVPN client binds to the default OpenVPN port (1194), ' . - 'two clients may not run concurrently.', + 'two clients may not run concurrently.', ); $this->usepkcs11 = new BooleanField( default: false, @@ -134,7 +134,7 @@ trait OpenVPNClientExportTraits { default: 'high', choices: ['high', 'low', 'legacy'], help_text: 'The level of encryption to use when exporting a PKCS#12 archive. Encryption support varies ' . - 'by Operating System and program', + 'by Operating System and program', ); $this->useproxy = new BooleanField( default: false, @@ -197,7 +197,7 @@ trait OpenVPNClientExportTraits { if (in_array($settings['data_ciphers_fallback'], $legacy_incompatible_ciphers)) { throw new ConflictError( message: 'The Fallback Data Encryption Algorithm for the selected server is not compatible with ' . - 'Legacy clients.', + 'Legacy clients.', response_id: 'OPENVPN_CLIENT_EXPORT_CONFIG_LEGACY_CIPHER_NOT_COMPATIBLE', ); } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc index 9fe53d14..ace38abb 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc @@ -13,7 +13,6 @@ use RESTAPI\Responses\NotFoundError; use RESTAPI\Responses\ServerError; use RESTAPI\Responses\ValidationError; - /** * Defines a Model that represents an OpenVPN Client Export. This Model is responsible for generating the actual * client export files. It does not define or store default client export configurations in the pfSense @@ -61,9 +60,9 @@ class OpenVPNClientExport extends Model { 'inst-x64-previous', 'inst-x86-current', 'inst-x64-current', - 'visc' + 'visc', ], - help_text: 'The type of OpenVPN client export to generate. This determines the format and content of '. + help_text: 'The type of OpenVPN client export to generate. This determines the format and content of ' . 'the export file.', ); $this->username = new ForeignModelField( @@ -72,7 +71,7 @@ class OpenVPNClientExport extends Model { default: null, allow_null: true, help_text: 'The username of the user this client export corresponds to. This is only applicable ' . - 'for OpenVPN servers that use the Local Database AND client certificates.', + 'for OpenVPN servers that use the Local Database AND client certificates.', ); $this->certref = new ForeignModelField( model_name: 'Certificate', @@ -123,7 +122,7 @@ class OpenVPNClientExport extends Model { } # Require a username to be set if the server is only using the Local Database for authentication. - if ($server_obj->authmode->value === ["Local Database"] and !$this->username->value) { + if ($server_obj->authmode->value === ['Local Database'] and !$this->username->value) { throw new ValidationError( message: "Field 'username' is required for OpenVPN server mode '{$server_obj->mode->value}' " . "with authentication mode 'Local Database'.", @@ -141,8 +140,7 @@ class OpenVPNClientExport extends Model { * @return string The validated type of OpenVPN client export. * @throws ValidationError If the type is not compatible with the 'usetoken' field. */ - public function validate_type(string $type): string - { + public function validate_type(string $type): string { $usetoken_forbidden_types = [ 'confzip', 'conf_yealink_t28', @@ -185,7 +183,6 @@ class OpenVPNClientExport extends Model { throw new NotFoundError( message: "User '{$this->username->value}' is not assigned a certificate with reference ID '$certref'.", response_id: 'OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND', - ); } @@ -194,7 +191,7 @@ class OpenVPNClientExport extends Model { if (!$cert_obj->prv->value and (!$this->usepkcs11->value or !$this->usetoken->value)) { throw new ValidationError( message: "Certificate with reference ID '$certref' does not have a private key. " . - "Ensure the certificate is valid and has a private key.", + 'Ensure the certificate is valid and has a private key.', response_id: 'OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY', ); } @@ -250,15 +247,15 @@ class OpenVPNClientExport extends Model { public function export_config(): string|null { return openvpn_client_export_config( srvid: $this->server->value, - usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, - crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, useaddr: $this->useaddr_hostname->value ?? $this->useaddr->value, verifyservercn: $this->verifyservercn->value, blockoutsidedns: $this->blockoutsidedns->value, legacy: $this->legacy->value, bindmode: $this->bindmode->value, usetoken: $this->usetoken->value, - nokeys: $this->server->get_related_model()->mode->value === "server_user", + nokeys: $this->server->get_related_model()->mode->value === 'server_user', proxy: $this->__get_proxy_config(), expformat: str_replace(['conf_', 'conf'], '', $this->type->value), outpass: $this->pass->value, @@ -268,7 +265,7 @@ class OpenVPNClientExport extends Model { advancedoptions: $this->advancedoptions->value, usepkcs11: $this->usepkcs11->value, pkcs11providers: $this->pkcs11providers->value, - pkcs11id: $this->pkcs11id->value + pkcs11id: $this->pkcs11id->value, ); } @@ -280,8 +277,8 @@ class OpenVPNClientExport extends Model { public function export_installer(): string|null { return openvpn_client_export_installer( srvid: $this->server->value, - usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, - crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, useaddr: $this->useaddr_hostnam->value ?? $this->useaddr->value, verifyservercn: $this->verifyservercn->value, blockoutsidedns: $this->blockoutsidedns->value, @@ -308,8 +305,8 @@ class OpenVPNClientExport extends Model { public function export_viscosity(): string|null { return viscosity_openvpn_client_config_exporter( srvid: $this->server->value, - usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, - crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, useaddr: $this->useaddr_hostname->value ?? $this->useaddr->value, verifyservercn: $this->verifyservercn->value, blockoutsidedns: $this->blockoutsidedns->value, @@ -332,12 +329,12 @@ class OpenVPNClientExport extends Model { */ private function __get_proxy_config(): array { return [ - "ip" => $this->proxyaddr->value, - "port" => $this->proxyport->value, - "user" => $this->proxyuser->value, - "password" => $this->proxypass->value, - "proxy_type" => $this->useproxytype->value, - "proxy_authtype" => $this->useproxypass->value, + 'ip' => $this->proxyaddr->value, + 'port' => $this->proxyport->value, + 'user' => $this->proxyuser->value, + 'password' => $this->proxypass->value, + 'proxy_type' => $this->useproxytype->value, + 'proxy_authtype' => $this->useproxypass->value, ]; } @@ -349,41 +346,41 @@ class OpenVPNClientExport extends Model { # Obtain the filename prefix $filename_prefix = openvpn_client_export_prefix( srvid: $this->server->value, - usrid: ($this->username->value) ? $this->username->get_related_model()->id : null, - crtid: ($this->certref->value) ? $this->certref->get_related_model()->id : null, + usrid: $this->username->value ? $this->username->get_related_model()->id : null, + crtid: $this->certref->value ? $this->certref->get_related_model()->id : null, ); # Determine the conf file suffix based on the export type - if (str_starts_with($this->type->value, "conf")) { + if (str_starts_with($this->type->value, 'conf')) { $filename_suffix = match ($this->type->value) { - "confzip" => "-config.zip", - "conf_yealink_t38g", "conf_yealink_t28", "conf_yealink_t38g2" => "client.tar", - "conf_snom" => "vpnclient.tar", - "confinlinedroid" => "-android-config.ovpn", - "confinlineconnect" => "-connect-config.ovpn", - "confinlinevisc" => "-viscosity-config.ovpn", - default => "-config.ovpn", + 'confzip' => '-config.zip', + 'conf_yealink_t38g', 'conf_yealink_t28', 'conf_yealink_t38g2' => 'client.tar', + 'conf_snom' => 'vpnclient.tar', + 'confinlinedroid' => '-android-config.ovpn', + 'confinlineconnect' => '-connect-config.ovpn', + 'confinlinevisc' => '-viscosity-config.ovpn', + default => '-config.ovpn', }; } # Determine the installer file suffix based on the export type - elseif (str_starts_with($this->type->value, "inst-")) { + elseif (str_starts_with($this->type->value, 'inst-')) { $filename_suffix = match ($this->type->value) { - "inst-Win7" => "$legacy_openvpn_version}-I$legacy_openvpn_version_rev-Win7.exe", - "inst-Win10" => "$legacy_openvpn_version-I$legacy_openvpn_version_rev-Win10.exe", - "inst-x86-previous" => "$previous_openvpn_version-I$previous_openvpn_version_rev-x86.exe", - "inst-x64-previous" => "$previous_openvpn_version-I$previous_openvpn_version_rev-amd64.exe", - "inst-x86-current" => "$current_openvpn_version-I$current_openvpn_version_rev-x86.exe", - default => "$current_openvpn_version-I$current_openvpn_version_rev-amd64.exe" + 'inst-Win7' => "$legacy_openvpn_version}-I$legacy_openvpn_version_rev-Win7.exe", + 'inst-Win10' => "$legacy_openvpn_version-I$legacy_openvpn_version_rev-Win10.exe", + 'inst-x86-previous' => "$previous_openvpn_version-I$previous_openvpn_version_rev-x86.exe", + 'inst-x64-previous' => "$previous_openvpn_version-I$previous_openvpn_version_rev-amd64.exe", + 'inst-x86-current' => "$current_openvpn_version-I$current_openvpn_version_rev-x86.exe", + default => "$current_openvpn_version-I$current_openvpn_version_rev-amd64.exe", }; } # If the type is not recognized, throw an error else { throw new ServerError( - message: "Could not determine appropriate file name.", + message: 'Could not determine appropriate file name.', response_id: 'OPENVPN_CLIENT_EXPORT_FILENAME_NOT_DETERMINED', ); } - return urlencode($filename_prefix.$filename_suffix); + return urlencode($filename_prefix . $filename_suffix); } } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc index fd324870..0e8ecb41 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExportConfig.inc @@ -7,7 +7,6 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; use RESTAPI\ModelTraits\OpenVPNClientExportTraits; - /** * Defines a Model that represents an OpenVPN Client Export default config for specific OpenVPN servers. */ From 01f38757b812881fba27eff198f363ddd950fcf8 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 25 Jul 2025 16:06:08 -0600 Subject: [PATCH 34/62] chore(OpenVPNClientExport): adjust model for testing issues --- .../RESTAPI/Models/OpenVPNClientExport.inc | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc index ace38abb..60506b8e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/OpenVPNClientExport.inc @@ -8,6 +8,7 @@ use RESTAPI\Core\Model; use RESTAPI\Fields\ForeignModelField; use RESTAPI\Fields\StringField; use RESTAPI\ModelTraits\OpenVPNClientExportTraits; +use RESTAPI\Responses\ConflictError; use RESTAPI\Responses\NotAcceptableError; use RESTAPI\Responses\NotFoundError; use RESTAPI\Responses\ServerError; @@ -30,6 +31,18 @@ class OpenVPNClientExport extends Model { public StringField $filename; public StringField $binary_data; + const USETOKEN_FORBIDDEN_TYPES = [ + 'confzip', + 'conf_yealink_t28', + 'conf_yealink_t38g', + 'conf_yealink_t38g2', + 'conf_snom', + 'confinline', + 'confinlinedroid', + 'confinlineconnect', + 'confinlinevisc', + ]; + public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) { # Set model attributes $this->config_path = 'installedpackages/vpn_openvpn_export/serverconfig/item'; @@ -104,12 +117,12 @@ class OpenVPNClientExport extends Model { /** * Performs extra validation on the server field. This is primarily intended to ensure that the requested * export parameters are compatible with the OpenVPN server settings. - * @param string $server The OpenVPN server ID to validate. - * @return string The validated OpenVPN server ID. + * @param int $server The OpenVPN server ID to validate. + * @return int The validated OpenVPN server ID. * @throws ValidationError If the server mode requires a certificate reference but none is provided, * @throws ValidationError If the server mode requires a username but none is provided. */ - public function validate_server(string $server): string { + public function validate_server(int $server): int { # Obtain the server object $server_obj = $this->server->get_related_model(); @@ -141,21 +154,9 @@ class OpenVPNClientExport extends Model { * @throws ValidationError If the type is not compatible with the 'usetoken' field. */ public function validate_type(string $type): string { - $usetoken_forbidden_types = [ - 'confzip', - 'conf_yealink_t28', - 'conf_yealink_t38g', - 'conf_yealink_t38g2', - 'conf_snom', - 'confinline', - 'confinlinedroid', - 'confinlineconnect', - 'confinlinevisc', - ]; - # Do not allow confinline types to have 'usetoken' enabled - if (in_array($type, $usetoken_forbidden_types) and $this->usetoken->value) { - throw new ValidationError( + if (in_array($type, self::USETOKEN_FORBIDDEN_TYPES) and $this->usetoken->value) { + throw new ConflictError( message: "Field 'usetoken' cannot be enabled for OpenVPN client export type '$type'.", response_id: 'OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED', ); @@ -204,8 +205,8 @@ class OpenVPNClientExport extends Model { * @throws ServerError If the export fails for an unknown reason */ protected function _create(): void { - global $legacy_incompatible_ciphers; - $legacy_incompatible_ciphers = $legacy_incompatible_ciphers ?? []; + global $input_errors; + # Generate a the file export based on the export type $export_data = null; if (str_starts_with($this->type->value, 'conf')) { @@ -218,11 +219,19 @@ class OpenVPNClientExport extends Model { $export_data = $this->export_viscosity(); } + # If import errors were found during the export, raise an error + if ($input_errors) { + throw new ServerError( + message: "The OpenVPN client export failed for the following reason: $input_errors[0]", + response_id: 'OPENVPN_CLIENT_EXPORT_CREATION_FAILED_FOR_KNOWN_REASON', + ); + } + # If no valid filepath was given after generating, we know the export failed. Throw an error. if (!$export_data) { throw new ServerError( message: 'The OpenVPN client export could not be created for unknown reasons.', - response_id: 'OPENVPN_CLIENT_EXPORT_CREATION_FAILED', + response_id: 'OPENVPN_CLIENT_EXPORT_CREATION_FAILED_FOR_UNKNOWN_REASON', ); } @@ -275,6 +284,10 @@ class OpenVPNClientExport extends Model { * export failed. */ public function export_installer(): string|null { + # Ensure legacy incompatible ciphers are always an array + global $legacy_incompatible_ciphers; + $legacy_incompatible_ciphers = $legacy_incompatible_ciphers ?? []; + return openvpn_client_export_installer( srvid: $this->server->value, usrid: $this->username->value ? $this->username->get_related_model()->id : null, From 728521cc6b7108aa4fb10961982d58bf355be6db Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 25 Jul 2025 16:06:55 -0600 Subject: [PATCH 35/62] test(OpenVPNClientExport): add tests for OpenVPN client exports #368 --- .../APIModelsOpenVPNClientExportTestCase.inc | 464 ++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc new file mode 100644 index 00000000..8ae43393 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc @@ -0,0 +1,464 @@ +server_ca = new CertificateAuthority( + descr: 'test_ca', + crt: file_get_contents('/usr/local/pkg/RESTAPI/Tests/assets/test_x509_certificate.crt'), + prv: file_get_contents('/usr/local/pkg/RESTAPI/Tests/assets/test_x509_rsa.key'), + ); + $this->server_ca->create(); + + # Create a new server certificate we can test with + $this->server_cert = new CertificateGenerate( + descr: 'test_user_cert', + caref: $this->server_ca->refid->value, + keytype: 'RSA', + keylen: 2048, + digest_alg: 'sha256', + dn_commonname: 'ovpns', + type: 'server', + ); + $this->server_cert->create(); + + # Create a new user certificate we can test with + $this->user_cert = new CertificateGenerate( + descr: 'test_user_cert', + caref: $this->server_ca->refid->value, + keytype: 'RSA', + keylen: 2048, + digest_alg: 'sha256', + dn_commonname: 'ovpntest', + type: 'user', + ); + $this->user_cert->create(); + + # Create a user we can use for testing + $this->user = new User(name: "ovpntest", password: "ovpntest", cert: [$this->user_cert->refid->value]); + $this->user->create(); + + # Create a remote auth server to use for testing + $this->authserver = new AuthServer( + type: 'radius', + name: 'TEST_RADIUS', + host: 'radius.example.com', + radius_auth_port: '1812', + radius_acct_port: '1813', + radius_secret: 'secret', + radius_protocol: 'MSCHAPv2', + radius_timeout: 30, + radius_nasip_attribute: 'wan', + ); + $this->authserver->create(); + + # Create a new OpenVPNServer model object + $this->ovpns = new OpenVPNServer( + mode: 'server_tls', + dev_mode: 'tun', + protocol: 'UDP4', + interface: 'wan', + use_tls: true, + tls: file_get_contents('/usr/local/pkg/RESTAPI/Tests/assets/test_openvpn_tls.key'), + tls_type: 'auth', + dh_length: '2048', + ecdh_curve: 'none', + data_ciphers: ['AES-256-GCM'], + data_ciphers_fallback: 'AES-256-GCM', + digest: 'SHA256', + caref: $this->server_ca->refid->value, + certref: $this->server_cert->refid->value, + async: true, + ); + $this->ovpns->create(apply: true); + $this->ovpns->reload_config(); + + # Create a client export config for the server + $this->ovpnce = new OpenVPNClientExportConfig(server: $this->ovpns->vpnid->value); + $this->ovpnce->create(); + } + + /** + * Remove the CA and cert used for testing after tests complete. + */ + public function teardown(): void + { + $this->user->delete(); + $this->authserver->delete(); + $this->ovpns->delete(apply: true); + $this->user_cert->delete(); + $this->server_cert->delete(); + $this->server_ca->delete(); + } + + /** + * Ensure the 'certref' field is required for OpenVPNServers using the 'server_tls' mode + */ + public function test_certref_required_for_server_tls(): void + { + # Ensure the server_tls server mode requires a certref for export + $this->assert_throws_response( + response_id: "OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED", + code: 400, + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = "server_tls"; + $this->ovpns->update(); + + # Ensure the validate_server method throws an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id); + $export->certref->value = null; + $export->validate_server($this->ovpns->vpnid->value); + } + ); + } + + /** + * Ensure the 'certref' field is required for OpenVPNServers using the 'server_tls_user' mode + */ + public function test_certref_required_for_server_tls_user(): void + { + # Ensure the server_tls_user server mode requires a certref for export + $this->assert_throws_response( + response_id: "OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED", + code: 400, + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = "server_tls_user"; + $this->ovpns->authmode->value = $this->ovpns->authmode->default; + $this->ovpns->update(); + + # Ensure the validate_server method throws an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id); + $export->certref->value = null; + $export->validate_server($this->ovpns->vpnid->value); + } + ); + } + + /** + * Ensure the 'certref' field is not required for OpenVPNServers using the 'server_user' mode + */ + public function test_certref_not_required_for_server_user(): void + { + # Ensure the server_user server mode does not require a certref for export + $this->assert_does_not_throw( + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = "server_user"; + $this->ovpns->authmode->value = $this->ovpns->authmode->default; + $this->ovpns->update(); + + # Ensure the validate_server method does not throw an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id); + $export->certref->value = null; + $export->username->value = $this->user->name->value; # username is required for server_user mode + $export->validate_server($this->ovpns->vpnid->value); + } + ); + } + + /** + * Ensure the username field is required for OpenVPN servers using the 'Local Database' authmode + */ + public function test_username_required_for_local_database_authmode(): void + { + # Ensure a username is required when using the local database authmode + $this->assert_throws_response( + response_id: "OPENVPN_CLIENT_EXPORT_USERNAME_REQUIRED", + code: 400, + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = "server_tls_user"; + $this->ovpns->authmode->value = $this->ovpns->authmode->default; + $this->ovpns->update(); + + # Ensure the validate_server method throws an error for no certref + $export = new OpenVPNClientExport(id: $this->ovpnce->id, certref: $this->user_cert->refid->value); + $export->username->value = null; + $export->validate_server($this->ovpns->vpnid->value); + } + ); + } + + /** + * Ensure the username field is not required for OpenVPN servers using a remote authmode + */ + public function test_username_not_required_for_remote_authmode(): void + { + # Ensure a username is not required when using a remote authmode + $this->assert_does_not_throw( + callable: function () { + # Update the OpenVPNServer mode + $this->ovpns->mode->value = "server_tls_user"; + $this->ovpns->authmode->value = [$this->authserver->name->value]; + $this->ovpns->update(); + + # Ensure the validate_server method does not throw an error for no username + $export = new OpenVPNClientExport(id: $this->ovpnce->id, certref: $this->user_cert->refid->value); + $export->username->value = null; + $export->validate_server($this->ovpns->vpnid->value); + } + ); + } + + /** + * Ensure an error is thrown when 'usetoken' is enabled with a forbidden export 'type' + */ + public function test_usetoken_forbidden_types(): void + { + foreach (OpenVPNClientExport::USETOKEN_FORBIDDEN_TYPES as $forbidden_type) { + $this->assert_throws_response( + response_id: "OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED", + code: 409, + callable: function () use ($forbidden_type) { + # Create a new OpenVPNClientExport with 'usetoken' enabled and a forbidden export 'type' + $export = new OpenVPNClientExport(usetoken: true); + $export->validate_type($forbidden_type); + } + ); + } + } + + /** + * Ensure no error is thrown when 'usetoken' is disabled, and a usetoken forbidden type is used. + */ + public function test_usetoken_forbidden_types_disabled(): void + { + foreach (OpenVPNClientExport::USETOKEN_FORBIDDEN_TYPES as $forbidden_type) { + $this->assert_does_not_throw( + callable: function () use ($forbidden_type) { + # Create a new OpenVPNClientExport with 'usetoken' disabled and a forbidden export 'type' + $export = new OpenVPNClientExport(usetoken: false); + $export->validate_type($forbidden_type); + } + ); + } + } + + /** + * Ensure no error is thrown when 'usetoken' is enabled, and a non usetoken forbidden type is used. + */ + public function test_usetoken_non_forbidden_types(): void + { + $this->assert_does_not_throw( + callable: function () { + # Create a new OpenVPNClientExport with 'usetoken' enabled and a non forbidden export 'type' + $export = new OpenVPNClientExport(usetoken: true); + $export->validate_type("inst-Win10"); + } + ); + } + + /** + * Ensure the certref validation for user certs is skipped if there is no username specified. + */ + public function test_certref_validation_skipped_for_user_certs_without_username(): void + { + # Create a new OpenVPNClientExport with a user cert and no username + $export = new OpenVPNClientExport(); + $export->username->value = null; + $this->assert_equals($export->validate_certref("anything"), "anything"); + $this->assert_equals( + $export->validate_certref($this->user_cert->refid->value), + $this->user_cert->refid->value + ); + } + + /** + * Ensure an error is thrown if the provided certref is not assigned to the provided user. + */ + public function test_certref_validation_user_cert_not_assigned_to_user(): void + { + # Ensure an error is thrown if the certref is not assigned to the user + $this->assert_throws_response( + response_id: "OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND", + code: 404, + callable: function () { + # Create a new OpenVPNClientExport with a user cert and a username + $export = new OpenVPNClientExport(username: $this->user->name->value); + + # Try to use the server cert (which isn't assigned to the user), this should throw an error + $export->validate_certref($this->server_cert->refid->value); + } + ); + } + + /** + * Ensure an error is thrown if the requested user cert does not have a private key + * and usepkcs11 or usetoken is not used + */ + public function test_certref_validation_user_cert_no_private_key(): void + { + # Temporarily unset the prv value for the user cert + Certificate::set_config( + "{$this->user_cert->get_config_path()}/prv", + null + ); + + # Ensure an error is thrown if the user cert does not have a private key + $this->assert_throws_response( + response_id: "OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY", + code: 400, + callable: function () { + # Create a new OpenVPNClientExport with a user cert and a username + $export = new OpenVPNClientExport( + username: $this->user->name->value, + usepkcs11: false, + usetoken: false + ); + + # Ensure the validate_certref method throws an error for no private key + $export->validate_certref($this->user_cert->refid->value); + } + ); + } + + /** + * Checks that the export config correctly generates an inline client export + */ + public function test_export_config_inline(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: "confinline", + username: $this->user->name->value, + certref: $this->user_cert->refid->value + ); + + $config = $exporter->export_config(); + + # Ensure the user ca, cert and key are present in the config + $this->assert_str_contains($config, $this->user_cert->crt->value); + $this->assert_str_contains($config, $this->user_cert->prv->value); + $this->assert_str_contains($config, $this->server_ca->crt->value); + } + + /** + * Checks that the export config correctly generates a zip client export + */ + public function test_export_config_zip(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: "confzip", + username: $this->user->name->value, + certref: $this->user_cert->refid->value + ); + $filepath = $exporter->export_config(); + + # Ensure the generated file exists and is a zip + $this->assert_is_true(file_exists($filepath)); + $this->assert_str_contains($filepath, '.zip'); + @unlink($filepath); // Clean up the generated file + } + + /** + * Checks that the export installer correctly generates a exe client export + */ + public function test_export_installer(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: "inst-Win10", + username: $this->user->name->value, + certref: $this->user_cert->refid->value + ); + $filepath = $exporter->export_installer(); + + # Ensure the generated file exists and is a exe + $this->assert_is_true(file_exists($filepath)); + $this->assert_str_contains($filepath, '.exe'); + @unlink($filepath); // Clean up the generated file + } + + /** + * Checks that the export viscosity correctly generates a viscosity client export + */ + public function test_export_viscosity(): void { + $exporter = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: "visc", + username: $this->user->name->value, + certref: $this->user_cert->refid->value + ); + $filepath = $exporter->export_installer(); + + # Ensure the generated file exists and is a exe + $this->assert_is_true(file_exists($filepath)); + $this->assert_str_contains($filepath, '.exe'); + @unlink($filepath); // Clean up the generated file + } + + /** + * Checks that the create methods behaves as intended + */ + public function test_create(): void { + # Create a new OpenVPNClientExport with the required fields + $export = new OpenVPNClientExport( + id: $this->ovpnce->id, + type: 'confinline', + blockoutsidedns: true, + useaddr: 'other', + useaddr_hostname: 'ovpn.example.com', + username: $this->user->name->value, + certref: $this->user_cert->refid->value, + ); + + # Ensure the export can be created without errors + $export->create(); + + # Ensure the binary_data field is populated with the correct config + $this->assert_is_not_empty($export->binary_data->value); + $this->assert_str_contains($export->binary_data->value, $this->user_cert->crt->value); + $this->assert_str_contains($export->binary_data->value, $this->user_cert->prv->value); + $this->assert_str_contains($export->binary_data->value, $this->server_ca->crt->value); + $this->assert_str_contains($export->binary_data->value, 'remote ovpn.example.com'); + $this->assert_str_contains($export->binary_data->value, 'block-outside-dns'); + + # Change the export config and ensure the config is regenerated + $export->useaddr->value = 'other'; + $export->useaddr_hostname->value = 'new.ovpn.example.com'; + $export->blockoutsidedns->value = false; + $export->create(); + + # Ensure the binary_data field is updated with the new config + $this->assert_str_contains($export->binary_data->value, 'remote new.ovpn.example.com'); + $this->assert_str_does_not_contain($export->binary_data->value, 'block-outside-dns'); + + # Export a file based type + $export->type->value = 'confzip'; + $export->create(); + + # Ensure a filename was set, the binary_data is populated (as actual binary data), and the actual file has + # already been removed + $this->assert_str_contains($export->filename->value, '.zip'); + $this->assert_is_not_empty($export->binary_data->value); + $this->assert_is_true(!ctype_print($export->binary_data->value)); + $this->assert_is_false(file_exists("/tmp/{$export->filename->value}")); + } +} From 64a14cb1bed80d3910e0afd22d1b9f2b4dacc050 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 25 Jul 2025 16:09:10 -0600 Subject: [PATCH 36/62] style: run prettier on changed files --- .../APIModelsOpenVPNClientExportTestCase.inc | 118 ++++++++---------- 1 file changed, 49 insertions(+), 69 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc index 8ae43393..5ec1858a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsOpenVPNClientExportTestCase.inc @@ -12,8 +12,7 @@ use RESTAPI\Models\OpenVPNClientExportConfig; use RESTAPI\Models\OpenVPNServer; use RESTAPI\Models\User; -class APIModelsOpenVPNClientExportTestCase extends TestCase -{ +class APIModelsOpenVPNClientExportTestCase extends TestCase { private CertificateAuthority $server_ca; private CertificateGenerate $server_cert; private CertificateGenerate $user_cert; @@ -27,8 +26,7 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase /** * Setup the test environment before starting tests. */ - public function setup(): void - { + public function setup(): void { # Create a CA we can test with $this->server_ca = new CertificateAuthority( descr: 'test_ca', @@ -62,7 +60,7 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase $this->user_cert->create(); # Create a user we can use for testing - $this->user = new User(name: "ovpntest", password: "ovpntest", cert: [$this->user_cert->refid->value]); + $this->user = new User(name: 'ovpntest', password: 'ovpntest', cert: [$this->user_cert->refid->value]); $this->user->create(); # Create a remote auth server to use for testing @@ -108,8 +106,7 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase /** * Remove the CA and cert used for testing after tests complete. */ - public function teardown(): void - { + public function teardown(): void { $this->user->delete(); $this->authserver->delete(); $this->ovpns->delete(apply: true); @@ -121,37 +118,35 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase /** * Ensure the 'certref' field is required for OpenVPNServers using the 'server_tls' mode */ - public function test_certref_required_for_server_tls(): void - { + public function test_certref_required_for_server_tls(): void { # Ensure the server_tls server mode requires a certref for export $this->assert_throws_response( - response_id: "OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED", + response_id: 'OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED', code: 400, callable: function () { # Update the OpenVPNServer mode - $this->ovpns->mode->value = "server_tls"; + $this->ovpns->mode->value = 'server_tls'; $this->ovpns->update(); # Ensure the validate_server method throws an error for no certref $export = new OpenVPNClientExport(id: $this->ovpnce->id); $export->certref->value = null; $export->validate_server($this->ovpns->vpnid->value); - } + }, ); } /** * Ensure the 'certref' field is required for OpenVPNServers using the 'server_tls_user' mode */ - public function test_certref_required_for_server_tls_user(): void - { + public function test_certref_required_for_server_tls_user(): void { # Ensure the server_tls_user server mode requires a certref for export $this->assert_throws_response( - response_id: "OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED", + response_id: 'OPENVPN_CLIENT_EXPORT_CERTREF_REQUIRED', code: 400, callable: function () { # Update the OpenVPNServer mode - $this->ovpns->mode->value = "server_tls_user"; + $this->ovpns->mode->value = 'server_tls_user'; $this->ovpns->authmode->value = $this->ovpns->authmode->default; $this->ovpns->update(); @@ -159,20 +154,19 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase $export = new OpenVPNClientExport(id: $this->ovpnce->id); $export->certref->value = null; $export->validate_server($this->ovpns->vpnid->value); - } + }, ); } /** * Ensure the 'certref' field is not required for OpenVPNServers using the 'server_user' mode */ - public function test_certref_not_required_for_server_user(): void - { + public function test_certref_not_required_for_server_user(): void { # Ensure the server_user server mode does not require a certref for export $this->assert_does_not_throw( callable: function () { # Update the OpenVPNServer mode - $this->ovpns->mode->value = "server_user"; + $this->ovpns->mode->value = 'server_user'; $this->ovpns->authmode->value = $this->ovpns->authmode->default; $this->ovpns->update(); @@ -181,22 +175,21 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase $export->certref->value = null; $export->username->value = $this->user->name->value; # username is required for server_user mode $export->validate_server($this->ovpns->vpnid->value); - } + }, ); } /** * Ensure the username field is required for OpenVPN servers using the 'Local Database' authmode */ - public function test_username_required_for_local_database_authmode(): void - { + public function test_username_required_for_local_database_authmode(): void { # Ensure a username is required when using the local database authmode $this->assert_throws_response( - response_id: "OPENVPN_CLIENT_EXPORT_USERNAME_REQUIRED", + response_id: 'OPENVPN_CLIENT_EXPORT_USERNAME_REQUIRED', code: 400, callable: function () { # Update the OpenVPNServer mode - $this->ovpns->mode->value = "server_tls_user"; + $this->ovpns->mode->value = 'server_tls_user'; $this->ovpns->authmode->value = $this->ovpns->authmode->default; $this->ovpns->update(); @@ -204,20 +197,19 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase $export = new OpenVPNClientExport(id: $this->ovpnce->id, certref: $this->user_cert->refid->value); $export->username->value = null; $export->validate_server($this->ovpns->vpnid->value); - } + }, ); } /** * Ensure the username field is not required for OpenVPN servers using a remote authmode */ - public function test_username_not_required_for_remote_authmode(): void - { + public function test_username_not_required_for_remote_authmode(): void { # Ensure a username is not required when using a remote authmode $this->assert_does_not_throw( callable: function () { # Update the OpenVPNServer mode - $this->ovpns->mode->value = "server_tls_user"; + $this->ovpns->mode->value = 'server_tls_user'; $this->ovpns->authmode->value = [$this->authserver->name->value]; $this->ovpns->update(); @@ -225,24 +217,23 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase $export = new OpenVPNClientExport(id: $this->ovpnce->id, certref: $this->user_cert->refid->value); $export->username->value = null; $export->validate_server($this->ovpns->vpnid->value); - } + }, ); } /** * Ensure an error is thrown when 'usetoken' is enabled with a forbidden export 'type' */ - public function test_usetoken_forbidden_types(): void - { + public function test_usetoken_forbidden_types(): void { foreach (OpenVPNClientExport::USETOKEN_FORBIDDEN_TYPES as $forbidden_type) { $this->assert_throws_response( - response_id: "OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED", + response_id: 'OPENVPN_CLIENT_EXPORT_TYPE_USETOKEN_NOT_ALLOWED', code: 409, callable: function () use ($forbidden_type) { # Create a new OpenVPNClientExport with 'usetoken' enabled and a forbidden export 'type' $export = new OpenVPNClientExport(usetoken: true); $export->validate_type($forbidden_type); - } + }, ); } } @@ -250,15 +241,14 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase /** * Ensure no error is thrown when 'usetoken' is disabled, and a usetoken forbidden type is used. */ - public function test_usetoken_forbidden_types_disabled(): void - { + public function test_usetoken_forbidden_types_disabled(): void { foreach (OpenVPNClientExport::USETOKEN_FORBIDDEN_TYPES as $forbidden_type) { $this->assert_does_not_throw( callable: function () use ($forbidden_type) { # Create a new OpenVPNClientExport with 'usetoken' disabled and a forbidden export 'type' $export = new OpenVPNClientExport(usetoken: false); $export->validate_type($forbidden_type); - } + }, ); } } @@ -266,40 +256,34 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase /** * Ensure no error is thrown when 'usetoken' is enabled, and a non usetoken forbidden type is used. */ - public function test_usetoken_non_forbidden_types(): void - { + public function test_usetoken_non_forbidden_types(): void { $this->assert_does_not_throw( callable: function () { # Create a new OpenVPNClientExport with 'usetoken' enabled and a non forbidden export 'type' $export = new OpenVPNClientExport(usetoken: true); - $export->validate_type("inst-Win10"); - } + $export->validate_type('inst-Win10'); + }, ); } /** * Ensure the certref validation for user certs is skipped if there is no username specified. */ - public function test_certref_validation_skipped_for_user_certs_without_username(): void - { + public function test_certref_validation_skipped_for_user_certs_without_username(): void { # Create a new OpenVPNClientExport with a user cert and no username $export = new OpenVPNClientExport(); $export->username->value = null; - $this->assert_equals($export->validate_certref("anything"), "anything"); - $this->assert_equals( - $export->validate_certref($this->user_cert->refid->value), - $this->user_cert->refid->value - ); + $this->assert_equals($export->validate_certref('anything'), 'anything'); + $this->assert_equals($export->validate_certref($this->user_cert->refid->value), $this->user_cert->refid->value); } /** * Ensure an error is thrown if the provided certref is not assigned to the provided user. */ - public function test_certref_validation_user_cert_not_assigned_to_user(): void - { + public function test_certref_validation_user_cert_not_assigned_to_user(): void { # Ensure an error is thrown if the certref is not assigned to the user $this->assert_throws_response( - response_id: "OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND", + response_id: 'OPENVPN_CLIENT_EXPORT_USER_CERT_NOT_FOUND', code: 404, callable: function () { # Create a new OpenVPNClientExport with a user cert and a username @@ -307,7 +291,7 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase # Try to use the server cert (which isn't assigned to the user), this should throw an error $export->validate_certref($this->server_cert->refid->value); - } + }, ); } @@ -315,29 +299,25 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase * Ensure an error is thrown if the requested user cert does not have a private key * and usepkcs11 or usetoken is not used */ - public function test_certref_validation_user_cert_no_private_key(): void - { + public function test_certref_validation_user_cert_no_private_key(): void { # Temporarily unset the prv value for the user cert - Certificate::set_config( - "{$this->user_cert->get_config_path()}/prv", - null - ); + Certificate::set_config("{$this->user_cert->get_config_path()}/prv", null); # Ensure an error is thrown if the user cert does not have a private key $this->assert_throws_response( - response_id: "OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY", + response_id: 'OPENVPN_CLIENT_EXPORT_CERT_NO_PRIVATE_KEY', code: 400, callable: function () { # Create a new OpenVPNClientExport with a user cert and a username $export = new OpenVPNClientExport( username: $this->user->name->value, usepkcs11: false, - usetoken: false + usetoken: false, ); # Ensure the validate_certref method throws an error for no private key $export->validate_certref($this->user_cert->refid->value); - } + }, ); } @@ -347,9 +327,9 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase public function test_export_config_inline(): void { $exporter = new OpenVPNClientExport( id: $this->ovpnce->id, - type: "confinline", + type: 'confinline', username: $this->user->name->value, - certref: $this->user_cert->refid->value + certref: $this->user_cert->refid->value, ); $config = $exporter->export_config(); @@ -366,9 +346,9 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase public function test_export_config_zip(): void { $exporter = new OpenVPNClientExport( id: $this->ovpnce->id, - type: "confzip", + type: 'confzip', username: $this->user->name->value, - certref: $this->user_cert->refid->value + certref: $this->user_cert->refid->value, ); $filepath = $exporter->export_config(); @@ -384,9 +364,9 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase public function test_export_installer(): void { $exporter = new OpenVPNClientExport( id: $this->ovpnce->id, - type: "inst-Win10", + type: 'inst-Win10', username: $this->user->name->value, - certref: $this->user_cert->refid->value + certref: $this->user_cert->refid->value, ); $filepath = $exporter->export_installer(); @@ -402,9 +382,9 @@ class APIModelsOpenVPNClientExportTestCase extends TestCase public function test_export_viscosity(): void { $exporter = new OpenVPNClientExport( id: $this->ovpnce->id, - type: "visc", + type: 'visc', username: $this->user->name->value, - certref: $this->user_cert->refid->value + certref: $this->user_cert->refid->value, ); $filepath = $exporter->export_installer(); From 772a8288b84d2e3c107c9583f9999e47f4c7f9c5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 26 Jul 2025 13:45:48 -0600 Subject: [PATCH 37/62] docs: add extra help text for /api/v2/vpn/openvpn/client_export --- .../Endpoints/VPNOpenVPNClientExportEndpoint.inc | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc index 1f2193cb..69a112e0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/VPNOpenVPNClientExportEndpoint.inc @@ -21,11 +21,14 @@ class VPNOpenVPNClientExportEndpoint extends Endpoint { # Set help texts $this->post_help_text = - 'Export an OpenVPN Client configuration. Before using this endpoint, you must define ' . - 'a default export configuration for your OpenVPN server(s) using the the endpoint at ' . - '/api/v2/openvpn/vpn/client_export/config as you will need its ID to use this endpoint. ' . - 'Any specific configurations made to this endpoint will override the default configurations, ' . - 'but will not store them in the pfSense configuration.'; + "Export an OpenVPN Client configuration.\n\n" . + 'Before using this endpoint, you must define a default export configuration for your ' . + 'OpenVPN server(s) using the the endpoint at /api/v2/openvpn/vpn/client_export/config ' . + 'as you will need its ID to use this endpoint. Any specific configurations made to this ' . + 'endpoint will override the default configurations, but will not store them in the pfSense ' . + "configuration.\n\n" . + "Exports of exe, zip and other binary file types MUST use the 'application/octet-stream' accept " . + 'type as their data is not serializable.'; # Construct the parent Endpoint object parent::__construct(); From e81f1d13fc29682f417f18cd9ac970feaf252652 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 14 Aug 2025 09:17:46 -0600 Subject: [PATCH 38/62] feat: add models, endpoints and tests for system tables --- .../Endpoints/DiagnosticsTableEndpoint.inc | 28 +++++++ .../Endpoints/DiagnosticsTablesEndpoint.inc | 28 +++++++ .../usr/local/pkg/RESTAPI/Models/Table.inc | 69 +++++++++++++++++ .../RESTAPI/Tests/APIModelsTableTestCase.inc | 75 +++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc new file mode 100644 index 00000000..c4c43902 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTableEndpoint.inc @@ -0,0 +1,28 @@ +url = '/api/v2/diagnostics/table'; + $this->model_name = 'Table'; + $this->request_method_options = ['GET', 'DELETE']; + + # Set help texts + $this->get_help_text = 'Retrieves the entries in a specified table.'; + $this->delete_help_text = + 'Flushes all entries in a specified table. Please note this does not ' . + 'delete the table itself, only its entries.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc new file mode 100644 index 00000000..0adcf899 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/DiagnosticsTablesEndpoint.inc @@ -0,0 +1,28 @@ +url = '/api/v2/diagnostics/tables'; + $this->model_name = 'Table'; + $this->request_method_options = ['GET']; + $this->many = true; + + # Set help texts + $this->get_help_text = + 'Retrieves the entries from all tables. For systems with a large number of tables or ' . + 'entries, it is recommended to use pagination and/or querying to limit the amount of data returned.'; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc new file mode 100644 index 00000000..8df2b406 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Table.inc @@ -0,0 +1,69 @@ +internal_callable = 'get_tables'; + $this->id_type = 'string'; + $this->many = true; + + $this->entries = new StringField(many: true, delimiter: ' ', help_text: 'The entries currently in the table.'); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Retrieves the list of available tables from the pfctl command. + * @return array The list of available table names. + */ + public function get_available_table_names(): array { + $table_names_ouptput = new Command('/sbin/pfctl -sT'); + return explode("\n", $table_names_ouptput->output); + } + + /** + * Obtains the auth log as an array. This method is the internal callable for this Model. + * @return array The auth log as an array of objects. + */ + protected function get_tables(): array { + $tables = []; + + # Loop through each table and expand its entries + foreach ($this->get_available_table_names() as $table_name) { + # Get the entries for the table + $table_entries_output = new Command( + '/sbin/pfctl -t ' . escapeshellarg($table_name) . ' -T show', + trim_whitespace: true, + ); + $tables[$table_name] = ['entries' => trim($table_entries_output->output)]; + } + + return $tables; + } + + /** + * Overrides the default _delete method to flush the table entries instead. + */ + protected function _delete(): void { + # Flush the table entries using pfctl + $flush_command = new Command('/sbin/pfctl -t ' . escapeshellarg($this->id) . ' -T flush'); + if ($flush_command->result_code !== 0) { + throw new ServerError( + message: 'Failed to flush table entries for ' . $this->id . ': ' . $flush_command->output, + response_id: 'TABLE_FLUSH_FAILED', + ); + } + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc new file mode 100644 index 00000000..cc43d570 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc @@ -0,0 +1,75 @@ +assert_is_true( + in_array('pfrest_test_table', $table->get_available_table_names()), + message: 'The test table should be in the list of available tables.', + ); + + # Delete the test table after the test + new Command('/sbin/pfctl -t pfrest_test_table -T kill'); + + # Ensure the test table is no longer available + $this->assert_is_false( + in_array('pfrest_test_table', $table->get_available_table_names()), + message: 'The test table should no longer be in the list of available tables.', + ); + } + + /** + * Checks that we can successfully read entries from a table + */ + public function test_read(): void { + # Create a new pf table to test with + new Command('/sbin/pfctl -t pfrest_test_table -T add 1.2.3.4 4.3.2.1'); + + # Load the Table model + $table = new Table(id: 'pfrest_test_table'); + + # Ensure the table has the expected entries + $this->assert_equals($table->entries->value, ['1.2.3.4', '4.3.2.1']); + + # Delete the test table after the test + new Command('/sbin/pfctl -t pfrest_test_table -T kill'); + } + + /** + * Checks that we can successfully delete (flush) entrries from a table + */ + public function test_delete(): void { + # Create a new pf table to test with + new Command('/sbin/pfctl -t pfrest_test_table -T add 1.2.3.4 4.3.2.1'); + + # Load the Table model + $table = new Table(id: 'pfrest_test_table'); + + # Ensure the table entries are present (so we know delete actually flushes them) + $this->assert_equals($table->entries->value, ['1.2.3.4', '4.3.2.1']); + + # Delete (flush) the test table and ensure the entries are actually flushed + $table->delete(); + $table_show = new Command('/sbin/pfctl -t pfrest_test_table -T show'); + $this->assert_str_does_not_contain($table_show->output, '1.2.3.4'); + $this->assert_str_does_not_contain($table_show->output, '4.3.2.1'); + + # Delete the test table after the test + new Command('/sbin/pfctl -t pfrest_test_table -T kill'); + } +} From ee6feaf3ca0e4533e4dc82d720061460835b8d3a Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 14 Aug 2025 09:18:02 -0600 Subject: [PATCH 39/62] style: run prettier on changed files --- composer.lock | 290 ++++++++++++++++++++++++-------------------------- 1 file changed, 140 insertions(+), 150 deletions(-) diff --git a/composer.lock b/composer.lock index 3cc92ef2..cd78a83b 100644 --- a/composer.lock +++ b/composer.lock @@ -1,156 +1,146 @@ { - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "a32ab4a8fc071e68a251a9446caf15b9", - "packages": [ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a32ab4a8fc071e68a251a9446caf15b9", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["BSD-3-Clause"], + "authors": [ { - "name": "firebase/php-jwt", - "version": "v6.11.1", - "source": { - "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.4", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^2.0||^3.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firebase\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", - "keywords": [ - "jwt", - "php" - ], - "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" - }, - "time": "2025-04-09T20:32:01+00:00" + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" }, { - "name": "webonyx/graphql-php", - "version": "v15.22.0", - "source": { - "type": "git", - "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d160c99334edd34adbc38fbe80b6df455d1923f8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d160c99334edd34adbc38fbe80b6df455d1923f8", - "reference": "d160c99334edd34adbc38fbe80b6df455d1923f8", - "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.84.0", - "mll-lab/php-cs-fixer-config": "5.11.0", - "nyholm/psr7": "^1.5", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.20", - "phpstan/phpstan-phpunit": "2.0.7", - "phpstan/phpstan-strict-rules": "2.0.6", - "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", - "psr/http-message": "^1 || ^2", - "react/http": "^1.6", - "react/promise": "^2.0 || ^3.0", - "rector/rector": "^2.0", - "symfony/polyfill-php81": "^1.23", - "symfony/var-exporter": "^5 || ^6 || ^7", - "thecodingmachine/safe": "^1.3 || ^2 || ^3" - }, - "suggest": { - "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", - "psr/http-message": "To use standard GraphQL server", - "react/promise": "To leverage async resolving on React PHP platform" - }, - "type": "library", - "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP port of GraphQL reference implementation", - "homepage": "https://github.com/webonyx/graphql-php", - "keywords": [ - "api", - "graphql" - ], - "support": { - "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.22.0" - }, - "funding": [ - { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" - } - ], - "time": "2025-07-28T12:28:41+00:00" + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": ["jwt", "php"], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.22.0", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "d160c99334edd34adbc38fbe80b6df455d1923f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d160c99334edd34adbc38fbe80b6df455d1923f8", + "reference": "d160c99334edd34adbc38fbe80b6df455d1923f8", + "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.84.0", + "mll-lab/php-cs-fixer-config": "5.11.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.20", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "2.0.6", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7", + "thecodingmachine/safe": "^1.3 || ^2 || ^3" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["MIT"], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": ["api", "graphql"], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.22.0" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + ], + "time": "2025-07-28T12:28:41+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" } From 77a3de5721e533097ddba1e4d61bb414cfe7e5bd Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Wed, 27 Aug 2025 23:03:33 -0600 Subject: [PATCH 40/62] test: give /dev/pf time to apply limiters --- .../pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 47ed2212..60b474fa 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -795,8 +795,9 @@ class APIModelsFirewallRuleTestCase extends TestCase { async: false, ); $rule->create(apply: true); + sleep(3); // Wait a bit to ensure device is not busy - # Check pfctl rules and ensure the dnpipe is correctly represented + # Ensure the dnpipe is correctly represented $pfctl = new Command('pfctl -sr'); $this->assert_str_contains( $pfctl->output, @@ -842,6 +843,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { async: false, ); $rule->create(apply: true); + sleep(3); // Wait a bit to ensure device is not busy # Check pfctl rules and ensure the dnpipe is correctly represented $pfctl = new Command('pfctl -sr'); @@ -892,6 +894,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { async: false, ); $rule->create(apply: true); + sleep(3); // Wait a bit to ensure device is not busy # Check pfctl rules and ensure the dnpipe is correctly represented $pfctl = new Command('pfctl -sr'); From 1972124c43294be8222616594219ec4ea8fc5072 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Wed, 27 Aug 2025 23:04:23 -0600 Subject: [PATCH 41/62] test: give pf tables time to populate entries --- .../files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc index cc43d570..c438cf84 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc @@ -56,6 +56,7 @@ class APIModelsTableTestCase extends TestCase { public function test_delete(): void { # Create a new pf table to test with new Command('/sbin/pfctl -t pfrest_test_table -T add 1.2.3.4 4.3.2.1'); + sleep(1); # Load the Table model $table = new Table(id: 'pfrest_test_table'); From 4373ec29ca14a7b98ea71577837d75d6b90516ce Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 29 Aug 2025 10:37:18 -0600 Subject: [PATCH 42/62] test: make reading pf rules context aware --- .../Tests/APIModelsFirewallRuleTestCase.inc | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 60b474fa..f5241cc6 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -11,8 +11,44 @@ use RESTAPI\Models\RoutingGateway; use RESTAPI\Models\TrafficShaper; use RESTAPI\Models\TrafficShaperLimiter; use RESTAPI\Models\TrafficShaperLimiterQueue; +use RESTAPI\Responses\ServerError; class APIModelsFirewallRuleTestCase extends TestCase { + /** + * Reads the active ruleset directly from pfctl. + */ + public function read_pfctl_rules(): Command { + # Keywords that indicate pf is not ready yet + $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy']; + + # Check the pf ruleset until it appears to be fully loaded, or until we've tried 5 times + $attempt = 0; + $max_attempts = 5; + while ($attempt < $max_attempts) { + $cmd = new Command('/sbin/pfctl -sr'); + $ready = true; + + foreach ($not_ready_keywords as $keyword) { + # pf is not ready if any of the keywords are found in the output + if (str_contains($cmd->output, $keyword)) { + $ready = false; + $attempt++; + sleep(1); + break; + } + } + + if ($ready) { + return $cmd; + } + } + + throw new ServerError( + message: "pfctl ruleset was not ready after $max_attempts attempts.", + response_id: 'API_MODELS_FIREWALL_RULE_TEST_CASE_PFCTL_NOT_READY', + ); + } + /** * Checks that multiple interfaces cannot be assigned to a FirewallRule unless `floating` is enabled. */ @@ -508,20 +544,20 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the rule with the queue is seen in pfctl - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains($pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue1'); # Update the rule to use a different queue and ensure it is seen in pfctl $rule->defaultqueue->value = 'TestQueue2'; $rule->update(apply: true); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains($pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue2'); # Delete the rule and ensure the rule referencing the queue no longer exists $rule->delete(); $shaper1->delete(); $rule->apply(); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_does_not_contain( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue1', @@ -577,7 +613,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the rule with the queue is seen in pfctl - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue1, TestQueue2)', @@ -587,7 +623,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->defaultqueue->value = 'TestQueue2'; $rule->ackqueue->value = 'TestQueue1'; $rule->update(apply: true); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue2, TestQueue1)', @@ -597,7 +633,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->delete(); $shaper1->delete(); $rule->apply(); - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_does_not_contain( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue1, TestQueue2)', @@ -795,10 +831,9 @@ class APIModelsFirewallRuleTestCase extends TestCase { async: false, ); $rule->create(apply: true); - sleep(3); // Wait a bit to ensure device is not busy # Ensure the dnpipe is correctly represented - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe {$limiter->number->value}", @@ -843,10 +878,9 @@ class APIModelsFirewallRuleTestCase extends TestCase { async: false, ); $rule->create(apply: true); - sleep(3); // Wait a bit to ensure device is not busy # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnqueue {$queue->number->value}", @@ -894,10 +928,9 @@ class APIModelsFirewallRuleTestCase extends TestCase { async: false, ); $rule->create(apply: true); - sleep(3); // Wait a bit to ensure device is not busy # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = new Command('pfctl -sr'); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe({$limiter1->number->value}, {$limiter2->number->value})", From 704b0b6507c3319677a20924c4692b480f47fb07 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 29 Aug 2025 12:23:12 -0600 Subject: [PATCH 43/62] test: don't check for matching certs during renewal test --- .../pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc index ef320577..bf3273a0 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsCertificateRenewTestCase.inc @@ -86,12 +86,11 @@ class APIModelsCertificateRenewTestCase extends TestCase { } /** - * Ensure when we renew the Certificate while reusing the key and serial, both the certificate and + * Ensure when we renew the Certificate while reusing the key and serial, both the serial and * the key remain the same. */ public function test_renew_certificate_reuse(): void { # Before we renew, obtain the existing CA cert - $old_cert = $this->cert->crt->value; $old_key = $this->cert->prv->value; # Renew the Certificate @@ -107,8 +106,7 @@ class APIModelsCertificateRenewTestCase extends TestCase { # Refresh our CA object $this->cert = Certificate::query(refid: $this->cert->refid->value)->first(); - # Ensure the certificate, key and serial are the same - $this->assert_equals($old_cert, $this->cert->crt->value); + # Ensure the key and serial are the same $this->assert_equals($old_key, $this->cert->prv->value); $this->assert_equals($renew->oldserial->value, $renew->newserial->value); } From 27ba6beb56ea13fb8cda5106101314fb6ac39807 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 29 Aug 2025 13:34:36 -0600 Subject: [PATCH 44/62] test: remove flaky sync tests The sync components need to be rewritten in a way that is easily tested. For now these are causing semi regular test failures that are not actionable. --- .../APIModelsRESTAPISettingsSyncTestCase.inc | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc index e75b1381..28cfa8e6 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsRESTAPISettingsSyncTestCase.inc @@ -65,41 +65,4 @@ class APIModelsRESTAPISettingsSyncTestCase extends TestCase { $this->assert_equals($original_api_config, $new_api_config); $this->assert_not_equals($sync_api_config, $new_api_config); } - - /** - * Checks that the 'sync()' method correctly syncs the API config to HA peers. - */ - public function test_sync(): void { - # Use a non-pfSense host as an HA peer - $api_settings = new RESTAPISettings(); - $api_settings->ha_sync->value = true; - $api_settings->ha_sync_hosts->value = ['www.example.com']; - $api_settings->ha_sync_username->value = 'admin'; - $api_settings->ha_sync_password->value = 'pfsense'; - $api_settings->update(); - - # Read the syslog and ensure the synced failed - # TODO: This test is flaky and needs to be reworked - // RESTAPISettingsSync::sync(); - // $syslog = file_get_contents('/var/log/system.log'); - // $this->assert_str_contains( - // $syslog, - // 'Failed to sync REST API settings to example.com: received unexpected response.', - // ); - - # Use a non-existent host as an HA peer and ensure the sync failed - $api_settings->ha_sync_hosts->value = ['127.1.2.3']; - $api_settings->update(); - RESTAPISettingsSync::sync(); - $syslog = file_get_contents('/var/log/system.log'); - $this->assert_str_contains($syslog, 'Failed to sync REST API settings to 127.1.2.3: no response received.'); - - # Use bad credentials and ensure the sync failed - $api_settings->ha_sync_hosts->value = ['127.0.0.1']; - $api_settings->ha_sync_password->value = 'bad password'; - $api_settings->update(); - RESTAPISettingsSync::sync(); - $syslog = file_get_contents('/var/log/system.log'); - $this->assert_str_contains($syslog, 'Failed to sync REST API settings to 127.0.0.1: Authentication failed'); - } } From 34d8783cb5f4f0c355ffeef51dd802b657900927 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 29 Aug 2025 16:26:27 -0600 Subject: [PATCH 45/62] test: add additional pf not ready keywords --- .../local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index f5241cc6..2230bd39 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -19,7 +19,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { */ public function read_pfctl_rules(): Command { # Keywords that indicate pf is not ready yet - $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy']; + $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy', 'pfctl: DIOCGETRULENV: Device busy']; # Check the pf ruleset until it appears to be fully loaded, or until we've tried 5 times $attempt = 0; From 7a39cbfbfd83a27d289ea3557b6d1c38b220edaf Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 2 Sep 2025 22:32:12 -0600 Subject: [PATCH 46/62] test: ensure table is readable after creation during tests --- .../RESTAPI/Tests/APIModelsTableTestCase.inc | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc index c438cf84..6451663c 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc @@ -7,14 +7,41 @@ use RESTAPI\Core\TestCase; use RESTAPI\Models\CARP; use RESTAPI\Models\Table; use RESTAPI\Models\VirtualIP; +use RESTAPI\Responses\ServerError; class APIModelsTableTestCase extends TestCase { /** - * Checks that we can successful retrieve the list of available table names. + * Adds a new table using pfctl directly and waits until it is readable. + * @param string $table_name The name of the table to add. + * @param array $entries The entries to add to the table. + * @return Command The Command object representing the result of pfctl command. + */ + public function add_table(string $table_name, array $entries): Command { + $entries_str = implode(' ', $entries); + $add_cmd = new Command("/sbin/pfctl -t $table_name -T add $entries_str"); + + # Wait until the table is readable by pfctl + foreach (range(1, 5) as $i) { + $show_cmd = new Command("/sbin/pfctl -t $table_name -T show"); + if (str_contains($show_cmd->output, $entries[0])) { + return $add_cmd; + } + sleep(1); + } + + throw new ServerError( + message: "Failed to add table $table_name with entries", + response_id: "API_MODELS_TABLE_TEST_CASE_ADD_TABLE_FAILED" + ); + + } + + /** + * Checks that we can successfully retrieve the list of available table names. */ public function test_get_available_table_names(): void { # Create a new pf table to test with - new Command('/sbin/pfctl -t pfrest_test_table -T add 1.2.3.4'); + $this->add_table(table_name: "pfrest_test_table", entries: ["1.2.3.4"]); # Ensure get_available_table_names returns the test table $table = new Table(); @@ -38,7 +65,7 @@ class APIModelsTableTestCase extends TestCase { */ public function test_read(): void { # Create a new pf table to test with - new Command('/sbin/pfctl -t pfrest_test_table -T add 1.2.3.4 4.3.2.1'); + $this->add_table(table_name: "pfrest_test_table", entries: ["1.2.3.4", "4.3.2.1"]); # Load the Table model $table = new Table(id: 'pfrest_test_table'); @@ -55,7 +82,7 @@ class APIModelsTableTestCase extends TestCase { */ public function test_delete(): void { # Create a new pf table to test with - new Command('/sbin/pfctl -t pfrest_test_table -T add 1.2.3.4 4.3.2.1'); + $this->add_table(table_name: "pfrest_test_table", entries: ["1.2.3.4", "4.3.2.1"]); sleep(1); # Load the Table model From 91ddafc6c075a7e5a789bc52269cf62776c2aeff Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 2 Sep 2025 22:32:36 -0600 Subject: [PATCH 47/62] style: run prettier on changed files --- .../local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc index 6451663c..95518509 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc @@ -31,9 +31,8 @@ class APIModelsTableTestCase extends TestCase { throw new ServerError( message: "Failed to add table $table_name with entries", - response_id: "API_MODELS_TABLE_TEST_CASE_ADD_TABLE_FAILED" + response_id: 'API_MODELS_TABLE_TEST_CASE_ADD_TABLE_FAILED', ); - } /** @@ -41,7 +40,7 @@ class APIModelsTableTestCase extends TestCase { */ public function test_get_available_table_names(): void { # Create a new pf table to test with - $this->add_table(table_name: "pfrest_test_table", entries: ["1.2.3.4"]); + $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4']); # Ensure get_available_table_names returns the test table $table = new Table(); @@ -65,7 +64,7 @@ class APIModelsTableTestCase extends TestCase { */ public function test_read(): void { # Create a new pf table to test with - $this->add_table(table_name: "pfrest_test_table", entries: ["1.2.3.4", "4.3.2.1"]); + $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4', '4.3.2.1']); # Load the Table model $table = new Table(id: 'pfrest_test_table'); @@ -82,7 +81,7 @@ class APIModelsTableTestCase extends TestCase { */ public function test_delete(): void { # Create a new pf table to test with - $this->add_table(table_name: "pfrest_test_table", entries: ["1.2.3.4", "4.3.2.1"]); + $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4', '4.3.2.1']); sleep(1); # Load the Table model From 38221153e2143c80f005375347fa153a50b9463b Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 2 Sep 2025 22:44:06 -0600 Subject: [PATCH 48/62] ci: add build for pfSense Plus 25.07.1 --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1d51b57..f536e1f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,8 @@ jobs: PFSENSE_VERSION: "24.11" - FREEBSD_VERSION: FreeBSD-15.0-CURRENT PFSENSE_VERSION: "25.07" + - FREEBSD_VERSION: FreeBSD-15.0-CURRENT + PFSENSE_VERSION: "25.07.1" steps: - uses: actions/checkout@v4 From ade27ed9ba77b267b9a6c846bfc9d491e73ecd43 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Tue, 2 Sep 2025 22:45:31 -0600 Subject: [PATCH 49/62] docs: update supported pfSense versions --- README.md | 4 ++-- docs/INSTALL_AND_CONFIG.md | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7490d35a..c96e0300 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,13 @@ commands are included below for quick reference. Install on pfSense CE: ```bash -pkg-static add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-2.8.0-pkg-RESTAPI.pkg +pkg-static add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-2.8.1-pkg-RESTAPI.pkg ``` Install on pfSense Plus: ```bash -pkg-static -C /dev/null add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-24.11-pkg-RESTAPI.pkg +pkg-static -C /dev/null add https://github.com/jaredhendrickson13/pfsense-api/releases/latest/download/pfSense-25.07.1-pkg-RESTAPI.pkg ``` > [!WARNING] diff --git a/docs/INSTALL_AND_CONFIG.md b/docs/INSTALL_AND_CONFIG.md index c730e1d1..19a9ca41 100644 --- a/docs/INSTALL_AND_CONFIG.md +++ b/docs/INSTALL_AND_CONFIG.md @@ -15,7 +15,10 @@ run pfSense. It's recommended to follow Netgate's [minimum hardware requirements ### Supported pfSense versions - pfSense CE 2.8.0 +- pfSense CE 2.8.1 - pfSense Plus 24.11 +- pfSense Plus 25.07 +- pfSense Plus 25.07.1 !!! Warning Installation of the package on unsupported versions of pfSense may result in unexpected behavior and/or system instability. From 10b3fa5c3d2bb7c09beb91e325a1e4b5e5fd0fe4 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 4 Sep 2025 21:04:39 -0600 Subject: [PATCH 50/62] test: add retries to pf rule reads for dnpipe limiter check --- .../RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 2230bd39..f3612625 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -16,8 +16,10 @@ use RESTAPI\Responses\ServerError; class APIModelsFirewallRuleTestCase extends TestCase { /** * Reads the active ruleset directly from pfctl. + * @param string|null $needle If provided, ensures this string is found in the pfctl output before returning. + * @return Command The Command object containing the pfctl output. */ - public function read_pfctl_rules(): Command { + public function read_pfctl_rules(?string $needle = null): Command { # Keywords that indicate pf is not ready yet $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy', 'pfctl: DIOCGETRULENV: Device busy']; @@ -28,9 +30,9 @@ class APIModelsFirewallRuleTestCase extends TestCase { $cmd = new Command('/sbin/pfctl -sr'); $ready = true; - foreach ($not_ready_keywords as $keyword) { + foreach ($not_ready_keywords as $kw) { # pf is not ready if any of the keywords are found in the output - if (str_contains($cmd->output, $keyword)) { + if (str_contains($cmd->output, $kw) or ($needle and !str_contains($cmd->output, $needle))) { $ready = false; $attempt++; sleep(1); @@ -833,7 +835,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the dnpipe is correctly represented - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe {$limiter->number->value}", From 41204152a5c70bb4cb473978d6a3c1ea6c65a6c8 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 4 Sep 2025 21:05:44 -0600 Subject: [PATCH 51/62] fix(InterfaceVLAN): make vlanif field read only --- .../files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc index 8c845982..5a87b576 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/InterfaceVLAN.inc @@ -43,6 +43,7 @@ class InterfaceVLAN extends Model { required: false, default: '', allow_empty: true, + read_only: true, help_text: 'Displays the full interface VLAN. This value is automatically populated and cannot be set.', ); $this->pcp = new IntegerField( From cd5845bb9c28a2a86b0c8ba5bfb33c55c01763a2 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Thu, 4 Sep 2025 21:06:27 -0600 Subject: [PATCH 52/62] style: run prettier on changed files --- composer.lock | 290 +++++++++--------- .../Tests/APIModelsFirewallRuleTestCase.inc | 2 +- 2 files changed, 141 insertions(+), 151 deletions(-) diff --git a/composer.lock b/composer.lock index c6821ab4..9e051d69 100644 --- a/composer.lock +++ b/composer.lock @@ -1,156 +1,146 @@ { - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "a32ab4a8fc071e68a251a9446caf15b9", - "packages": [ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a32ab4a8fc071e68a251a9446caf15b9", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["BSD-3-Clause"], + "authors": [ { - "name": "firebase/php-jwt", - "version": "v6.11.1", - "source": { - "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.4", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^2.0||^3.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firebase\\JWT\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", - "keywords": [ - "jwt", - "php" - ], - "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" - }, - "time": "2025-04-09T20:32:01+00:00" + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" }, { - "name": "webonyx/graphql-php", - "version": "v15.24.0", - "source": { - "type": "git", - "url": "https://github.com/webonyx/graphql-php.git", - "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/030a04d22d52d7fc07049d0e3b683d2b40f90457", - "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^7.4 || ^8" - }, - "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", - "dms/phpunit-arraysubset-asserts": "dev-master", - "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.86.0", - "mll-lab/php-cs-fixer-config": "5.11.0", - "nyholm/psr7": "^1.5", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.22", - "phpstan/phpstan-phpunit": "2.0.7", - "phpstan/phpstan-strict-rules": "2.0.6", - "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", - "psr/http-message": "^1 || ^2", - "react/http": "^1.6", - "react/promise": "^2.0 || ^3.0", - "rector/rector": "^2.0", - "symfony/polyfill-php81": "^1.23", - "symfony/var-exporter": "^5 || ^6 || ^7", - "thecodingmachine/safe": "^1.3 || ^2 || ^3" - }, - "suggest": { - "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", - "psr/http-message": "To use standard GraphQL server", - "react/promise": "To leverage async resolving on React PHP platform" - }, - "type": "library", - "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP port of GraphQL reference implementation", - "homepage": "https://github.com/webonyx/graphql-php", - "keywords": [ - "api", - "graphql" - ], - "support": { - "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.24.0" - }, - "funding": [ - { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" - } - ], - "time": "2025-08-20T10:09:37+00:00" + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": ["jwt", "php"], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.24.0", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/030a04d22d52d7fc07049d0e3b683d2b40f90457", + "reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.86.0", + "mll-lab/php-cs-fixer-config": "5.11.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.22", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "2.0.6", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7", + "thecodingmachine/safe": "^1.3 || ^2 || ^3" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": ["MIT"], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": ["api", "graphql"], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.24.0" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + ], + "time": "2025-08-20T10:09:37+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index f3612625..46a50bf5 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -32,7 +32,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { foreach ($not_ready_keywords as $kw) { # pf is not ready if any of the keywords are found in the output - if (str_contains($cmd->output, $kw) or ($needle and !str_contains($cmd->output, $needle))) { + if (str_contains($cmd->output, $kw) or $needle and !str_contains($cmd->output, $needle)) { $ready = false; $attempt++; sleep(1); From 37bd3e08fbaeff7a92d635948a45b07915722e83 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 5 Sep 2025 20:24:39 -0600 Subject: [PATCH 53/62] docs: add docs for new schemas --- docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md | 5 +++ docs/NATIVE_SCHEMA.md | 39 ++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 45 insertions(+) create mode 100644 docs/NATIVE_SCHEMA.md diff --git a/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md b/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md index fea1a6fe..287124c4 100644 --- a/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md +++ b/docs/BUILDING_CUSTOM_ENDPOINT_CLASSES.md @@ -108,6 +108,11 @@ The `tag` property is used to define the [OpenAPI tag](https://swagger.io/docs/s for the endpoint. This is used to group related endpoints together in the API documentation. This property is optional and defaults to the first section of the URL after the `/api/v2/` prefix. +### deprecated + +The `deprecated` property is used to indicate if the endpoint is deprecated. This property defaults to `false`. When set +to `true`, the endpoint will be marked as deprecated in applicable documentation and schemas. + ### requires_auth The `requires_auth` property is used to specify whether the endpoint requires authentication and authorization. This property diff --git a/docs/NATIVE_SCHEMA.md b/docs/NATIVE_SCHEMA.md new file mode 100644 index 00000000..6ef47ffd --- /dev/null +++ b/docs/NATIVE_SCHEMA.md @@ -0,0 +1,39 @@ +# Native Schema + +The package's framework also generates a proprietary schema that describes components of the API in more detail than +the OpenAPI or GraphQL schemas. This is intended for third-party tools and integrations that need more context about +the API's structure, behavior and attributes. The native schema is generated by extracting data the codebases PHP class +properties directly and use internal metadata that is not normally exposed. + +## Accessing the Schema + +The native schema can by obtained by making a `GET` request to the `/api/v2/schema/native` endpoint. This endpoint +does not require authentication and is accessible to all users as it only provides descriptive metadata about the API +that is already publicly available by referencing the code on GitHub. + +## Understanding the Structure + +Since the native schema is generated from the codebase itself, the easiest way to understand its contents is to +review the properties available to [Endpoint](BUILDING_CUSTOM_ENDPOINT_CLASSES.md#define-__construct-method-properties), +[Model](BUILDING_CUSTOM_MODEL_CLASSES.md#define-__construct-method-properties) and [Field](BUILDING_CUSTOM_MODEL_CLASSES.md#define-field-objects) +classes. Below is a basic outline of the schema's structure: + +```json +{ + "endpoints": { + "/endpoint/url/path": { + ... + } + }, + "models": { + "ModelName": { + "fields": { + "field_name": { + ... + }, + ... + } + } + } +} +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 2c1ee0ce..d5097545 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - Building Custom Dispatchers: BUILDING_CUSTOM_DISPATCHER_CLASSES.md - Building Custom Caches: BUILDING_CUSTOM_CACHE_CLASSES.md - Building Custom Content Handlers: BUILDING_CUSTOM_CONTENT_HANDLER_CLASSES.md + - Native Schema: NATIVE_SCHEMA.md - PHP Reference: https://pfrest.org/php-docs/ theme: name: readthedocs From 403e077b9bb5e85b949fe1ae4521ab0760559c90 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 5 Sep 2025 20:24:58 -0600 Subject: [PATCH 54/62] docs: add notes about correct content type usage --- docs/COMMON_CONTROL_PARAMETERS.md | 8 ++++++-- docs/CONTENT_AND_ACCEPT_TYPES.md | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/COMMON_CONTROL_PARAMETERS.md b/docs/COMMON_CONTROL_PARAMETERS.md index 823f67a2..4df63292 100644 --- a/docs/COMMON_CONTROL_PARAMETERS.md +++ b/docs/COMMON_CONTROL_PARAMETERS.md @@ -1,8 +1,12 @@ # Common Control Parameters The API utilizes a set of common parameters to control certain behaviors of API calls. These parameters are available to -all endpoints and requests, but some endpoints may not support all parameters. Below are the available control -parameters you can use: +all endpoints and requests, but some endpoints may not support all parameters. + +!!! Note + Requests must pass these parameters according to your specific [content type](CONTENT_AND_ACCEPT_TYPES.md). For + example, if you are using the `application/json` content-type, these parameters should be included in the JSON body + of your request. Content types cannot be mixed. ## append diff --git a/docs/CONTENT_AND_ACCEPT_TYPES.md b/docs/CONTENT_AND_ACCEPT_TYPES.md index 2a52d3f6..79b42450 100644 --- a/docs/CONTENT_AND_ACCEPT_TYPES.md +++ b/docs/CONTENT_AND_ACCEPT_TYPES.md @@ -6,6 +6,10 @@ The REST API has been designed to allow multiple content and accept types to be Content types are used to specify the format of the data being sent in your request. You must specify the content type in the `Content-Type` header of your request. The REST API supports the following content types: +!!! Important + Only one content type can be specified per request. Data formats cannot be mixed. For example, a request cannot + use both a JSON body and URL query string parameters to send data. + ### application/json - MIME Type: `application/json` - Description: Use this content type to send JSON data in the body of your request. The data should be formatted as a JSON object or array. From 6057b646723e8b46ac525aba2e48f9ddbe9ccf6e Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 5 Sep 2025 20:25:14 -0600 Subject: [PATCH 55/62] docs: add instructions for pr target branches --- docs/CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index bc9b097d..6083c284 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -27,6 +27,30 @@ To make a code contribution, please follow these steps: If your contribution involves a security vulnerability, please do not open a public issue or pull request. Instead, please report the vulnerability to a [project maintainer](index.md#maintainers) directly. +## Pull Requests + +When submitting a pull request, please be aware of the various development branches used by the project and base your branch accordingly. Ensure your pull request's target branch is set to merge into the appropriate branch. + +### next_patch + +The `next_patch` branch is used for small bug fixes, minor documentation corrections, and other small changes that do not introduce new features or break existing functionality. Changes implemented in this branch will be included in the next patch release. + +### next_minor + +The `next_minor` branch is used for new features, enhancements, fixes, and other changes that do not introduce major breaking changes. Breaking changes _are_ allowed in minor release as long as the impact is minimal and justified. Changes implemented in this branch will be included in the next minor release. + +### next_major + +The `next_major` branch is dedicated to major changes to the projects structure, schemas, and overall functionality. Pull requests to this branch are allowed, but are rarely accepted as the branch is intended for fundamental changes to the project. Changes implemented in this branch will be included in the next major release. + +### master + +The `master` branch reflects the latest stable release of the project. Pull requests should _not_ be made to this branch directly. Instead, pull requests should be made to one of the other branches and merged into `master` when a new release is ready. Notable exceptions to this rule are: + +- Emergency security fixes that need to be applied to the latest stable release. +- Dependency updates that do not break existing functionality. +- Documentation updates that are not tied to a specific release. + ## Project Structure The majority of the pfSense REST API package code can be found at `pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI`. From 5524e7a9d45146edb8216531f3e1f263542bea45 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 5 Sep 2025 20:28:25 -0600 Subject: [PATCH 56/62] test: revert pf retries in dnpipe test --- .../RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 46a50bf5..db6ce69e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -16,10 +16,8 @@ use RESTAPI\Responses\ServerError; class APIModelsFirewallRuleTestCase extends TestCase { /** * Reads the active ruleset directly from pfctl. - * @param string|null $needle If provided, ensures this string is found in the pfctl output before returning. - * @return Command The Command object containing the pfctl output. */ - public function read_pfctl_rules(?string $needle = null): Command { + public function read_pfctl_rules(): Command { # Keywords that indicate pf is not ready yet $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy', 'pfctl: DIOCGETRULENV: Device busy']; @@ -30,9 +28,9 @@ class APIModelsFirewallRuleTestCase extends TestCase { $cmd = new Command('/sbin/pfctl -sr'); $ready = true; - foreach ($not_ready_keywords as $kw) { + foreach ($not_ready_keywords as $keyword) { # pf is not ready if any of the keywords are found in the output - if (str_contains($cmd->output, $kw) or $needle and !str_contains($cmd->output, $needle)) { + if (str_contains($cmd->output, $keyword)) { $ready = false; $attempt++; sleep(1); @@ -835,7 +833,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the dnpipe is correctly represented - $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); + $pfctl = $this->read_pfctl_rules(); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe {$limiter->number->value}", @@ -984,4 +982,4 @@ class APIModelsFirewallRuleTestCase extends TestCase { $this->assert_equals($rule->updated_by->value, "$client->username@$client->ip_address (API)"); } } -} +} \ No newline at end of file From 017cf3cad0806902c2c691388b25fbfdba769349 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Fri, 5 Sep 2025 20:32:58 -0600 Subject: [PATCH 57/62] style: run prettier on changed files --- .../local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index db6ce69e..2230bd39 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -982,4 +982,4 @@ class APIModelsFirewallRuleTestCase extends TestCase { $this->assert_equals($rule->updated_by->value, "$client->username@$client->ip_address (API)"); } } -} \ No newline at end of file +} From 9fdfbab81b9ef9a7957cf8c36221eae30aa244b5 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Sep 2025 10:43:09 -0600 Subject: [PATCH 58/62] test: adjust pfctl rule reads to wait for ridentifier --- .../Tests/APIModelsFirewallRuleTestCase.inc | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 2230bd39..97f99f28 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -15,9 +15,12 @@ use RESTAPI\Responses\ServerError; class APIModelsFirewallRuleTestCase extends TestCase { /** - * Reads the active ruleset directly from pfctl. + * Reads the active ruleset directly from pfctl. If pfctl is not ready, it will retry up to 5 times before + * throwing an error. + * @param string|null $needle Optional string to search for in the output before returning + * @return Command The Command object containing the pfctl output */ - public function read_pfctl_rules(): Command { + public function read_pfctl_rules(?string $needle = null): Command { # Keywords that indicate pf is not ready yet $not_ready_keywords = ['pfctl: DIOCGETRULE: Device busy', 'pfctl: DIOCGETRULENV: Device busy']; @@ -32,15 +35,18 @@ class APIModelsFirewallRuleTestCase extends TestCase { # pf is not ready if any of the keywords are found in the output if (str_contains($cmd->output, $keyword)) { $ready = false; - $attempt++; - sleep(1); break; } } - if ($ready) { + # If pfctl is ready and either no needle was specified or the needle was found, return the output + if ($ready and (!$needle or str_contains($cmd->output, $needle))) { return $cmd; } + + # Otherwise, wait a second and try again + sleep(1); + $attempt++; } throw new ServerError( @@ -833,7 +839,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the dnpipe is correctly represented - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe {$limiter->number->value}", From 76a0e3644d5d42cf1294db7b8832c64cea582fbe Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Sep 2025 14:23:06 -0600 Subject: [PATCH 59/62] test: adjust wait times for pfctl rules to read --- .../RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index 97f99f28..bf5eb13e 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -41,6 +41,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { # If pfctl is ready and either no needle was specified or the needle was found, return the output if ($ready and (!$needle or str_contains($cmd->output, $needle))) { + sleep(1); return $cmd; } @@ -516,6 +517,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { scheduler: 'FAIRQ', bandwidth: 100, bandwidthtype: 'Mb', + async: false, queue: [ [ 'name' => 'TestQueue1', @@ -550,13 +552,13 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the rule with the queue is seen in pfctl - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains($pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue1'); # Update the rule to use a different queue and ensure it is seen in pfctl $rule->defaultqueue->value = 'TestQueue2'; $rule->update(apply: true); - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains($pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue TestQueue2'); # Delete the rule and ensure the rule referencing the queue no longer exists @@ -584,6 +586,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { scheduler: 'FAIRQ', bandwidth: 100, bandwidthtype: 'Mb', + async: false, queue: [ [ 'name' => 'TestQueue1', @@ -619,7 +622,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Ensure the rule with the queue is seen in pfctl - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, 'ridentifier ' . $rule->tracker->value . ' queue(TestQueue1, TestQueue2)', @@ -886,7 +889,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnqueue {$queue->number->value}", @@ -936,7 +939,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { $rule->create(apply: true); # Check pfctl rules and ensure the dnpipe is correctly represented - $pfctl = $this->read_pfctl_rules(); + $pfctl = $this->read_pfctl_rules(needle: $rule->tracker->value); $this->assert_str_contains( $pfctl->output, "ridentifier {$rule->tracker->value} dnpipe({$limiter1->number->value}, {$limiter2->number->value})", From 7856ef28f7733cc95ad102f34c431df8c59dd7c9 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Sep 2025 20:22:54 -0600 Subject: [PATCH 60/62] test: support retries in test methods --- .../usr/local/pkg/RESTAPI/Core/TestCase.inc | 53 +++++++++++++++---- .../local/pkg/RESTAPI/Core/TestCaseRetry.inc | 25 +++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc 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 d17e1f1e..2f183d1f 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc @@ -68,20 +68,36 @@ class TestCase { # Set the current method undergoing testing $this->method = $method; + $ref_method = new ReflectionMethod($this, $method); # Gather the description for this test method from its doc comment - $this->method_docstring = (new ReflectionMethod($this, $method))->getDocComment(); + $this->method_docstring = $ref_method->getDocComment(); $this->method_docstring = str_replace([PHP_EOL, '/**', '*/', '*', ' '], '', $this->method_docstring); - # Try to run the test. On failure, restore the original config and throw the exception - try { - $this->$method(); - } catch (Error | Exception $e) { - # Restore the original configuration, teardown the TestCase and throw the encountered error - $config = $original_config; - write_config("Restored config after API test '$method'"); - $this->teardown(); - throw $e; + # Get the retry settings for this method + $retry_settings = $this->get_method_retry_settings($ref_method); + + # Always attempt once, then add retries if configured + for ($attempt = 0; $attempt <= $retry_settings->retries; $attempt++) { + try { + # Try to run the test + $this->$method(); + break; + } catch (Error | Exception $e) { + # Tear down any resources created by this test before retrying or exiting + $config = $original_config; + write_config("Restored config after API test '$method'"); + $this->teardown(); + + # If we have retries left, wait the configured delay and try again + if ($attempt < $retry_settings->retries) { + sleep($retry_settings->delay); + continue; + } + + # If we made it here, we have no retries left. Throw the exception to the caller. + throw $e; + } } # Restore the config as it was when the test began. @@ -137,6 +153,23 @@ class TestCase { */ public function teardown(): void {} + /** + * @param ReflectionMethod $method The method to check for a TestCaseRetry attribute. + * @return TestCaseRetry The TestCaseRetry attribute found on the method, or a default TestCaseRetry object + */ + protected function get_method_retry_settings(ReflectionMethod $method): TestCaseRetry { + # Use default retry settings if no attribute is found + $retries = new TestCaseRetry(); + + # If this method has a TestCaseRetry attribute, use it + $attributes = $method->getAttributes(TestCaseRetry::class); + if (count($attributes) > 0) { + $retries = $attributes[0]->newInstance(); + } + + return $retries; + } + /** * Obtains the environment variable with a given name. */ diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc new file mode 100644 index 00000000..46102ea8 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc @@ -0,0 +1,25 @@ +retries = max(0, $retries); + $this->delay = max(0, $delay); + } + +} \ No newline at end of file From ff9b8c4740ce6fb93efaf04627ce3c2731fe8b85 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Sep 2025 20:27:03 -0600 Subject: [PATCH 61/62] test: add retries to time sensitive tests --- .../local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc | 2 ++ .../usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc | 3 +++ 2 files changed, 5 insertions(+) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc index bf5eb13e..5135ec1a 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsFirewallRuleTestCase.inc @@ -5,6 +5,7 @@ namespace RESTAPI\Tests; use RESTAPI\Core\Auth; use RESTAPI\Core\Command; use RESTAPI\Core\TestCase; +use RESTAPI\Core\TestCaseRetry; use RESTAPI\Models\FirewallRule; use RESTAPI\Models\FirewallSchedule; use RESTAPI\Models\RoutingGateway; @@ -817,6 +818,7 @@ class APIModelsFirewallRuleTestCase extends TestCase { /** * Checks that a rule with a TrafficShaperLimiter object assigned to dnpipe is correctly represented in pfctl. */ + #[TestCaseRetry(retries: 3, delay: 2)] public function test_dnpipe_as_limiter_in_pfctl(): void { # Create a limiter and rule to test with $limiter = new TrafficShaperLimiter( diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc index 95518509..45dee568 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsTableTestCase.inc @@ -4,6 +4,7 @@ namespace RESTAPI\Tests; use RESTAPI\Core\Command; use RESTAPI\Core\TestCase; +use RESTAPI\Core\TestCaseRetry; use RESTAPI\Models\CARP; use RESTAPI\Models\Table; use RESTAPI\Models\VirtualIP; @@ -38,6 +39,7 @@ class APIModelsTableTestCase extends TestCase { /** * Checks that we can successfully retrieve the list of available table names. */ + #[TestCaseRetry(retries: 3, delay: 1)] public function test_get_available_table_names(): void { # Create a new pf table to test with $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4']); @@ -62,6 +64,7 @@ class APIModelsTableTestCase extends TestCase { /** * Checks that we can successfully read entries from a table */ + #[TestCaseRetry(retries: 3, delay: 1)] public function test_read(): void { # Create a new pf table to test with $this->add_table(table_name: 'pfrest_test_table', entries: ['1.2.3.4', '4.3.2.1']); From cd716e57d03de5d40e4f60105619a7b42caa0d16 Mon Sep 17 00:00:00 2001 From: Jared Hendrickson Date: Sat, 6 Sep 2025 20:27:25 -0600 Subject: [PATCH 62/62] style: run prettier on changed files --- .../files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc index 46102ea8..88cec5f2 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCaseRetry.inc @@ -8,18 +8,14 @@ use Attribute; * A class to represent retry settings for test cases. This is intended to be defined in methods as a method attribute. */ #[Attribute(Attribute::TARGET_METHOD)] -class TestCaseRetry -{ - +class TestCaseRetry { /** * @param int $retries The number of retries to attempt * @param int $delay The delay in seconds between retries */ - public function __construct(public int $retries = 0, public int $delay = 0) - { + public function __construct(public int $retries = 0, public int $delay = 0) { # Ensure values are non-negative $this->retries = max(0, $retries); $this->delay = max(0, $delay); } - -} \ No newline at end of file +}