diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc index 19d8ae8d..15857358 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Endpoint.inc @@ -239,6 +239,13 @@ class Endpoint { */ public array $delete_privileges = []; + /** + * @var bool $requires_page_all_privilege + * When enabled, this endpoint will always require the page-all privilege and will + * disable auto-generated/scoped REST API privileges for this endpoint. + */ + public bool $requires_page_all_privilege = false; + /** * @var string $get_help_text * Sets the GET request's OpenAPI documentation for this Endpoint. This will be @@ -534,6 +541,17 @@ class Endpoint { */ private function get_default_privs(): void { $page_all_priv = 'page-all'; + + # If this endpoint requires page-all, then only assign page-all to the privileges for each method + if ($this->requires_page_all_privilege) { + $this->get_privileges = [$page_all_priv]; + $this->post_privileges = [$page_all_priv]; + $this->patch_privileges = [$page_all_priv]; + $this->put_privileges = [$page_all_priv]; + $this->delete_privileges = [$page_all_priv]; + return; + } + $this->get_privileges = [$page_all_priv, $this->get_method_priv_name('GET')]; $this->post_privileges = [$page_all_priv, $this->get_method_priv_name('POST')]; $this->patch_privileges = [$page_all_priv, $this->get_method_priv_name('PATCH')]; @@ -564,6 +582,11 @@ class Endpoint { * @returns array The pfSense priv list entry array corresponding to the privileges of this Endpoint. */ public function generate_pfsense_privs(): array { + # If this endpoint requires page-all, do not generate privileges + if ($this->requires_page_all_privilege) { + return []; + } + # Set an array to populate pfSense priv entries for this Endpoint $privs = []; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc index 80be5bda..9fed4dd1 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc @@ -1769,7 +1769,7 @@ class Model { * @throws NotFoundError When the Model requires a pfSense package that is not installed. * @throws ServerError When a package requires a PHP include file that could not be found. */ - private function check_packages(): void { + protected function check_packages(): void { # Check if the user has opted in to using development (-devel) package variants $pkg_config = RESTAPI\Models\RESTAPISettings::get_pkg_config(); $allow_development_packages = ($pkg_config['allow_development_packages'] ?? '') === 'enabled'; diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc new file mode 100644 index 00000000..b5770c8f --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardPeersEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/status/wireguard/peers'; + $this->model_name = 'WireGuardPeerStatus'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc new file mode 100644 index 00000000..ebde4980 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/StatusWireGuardTunnelsEndpoint.inc @@ -0,0 +1,23 @@ +url = '/api/v2/status/wireguard/tunnels'; + $this->model_name = 'WireGuardTunnelStatus'; + $this->many = true; + $this->request_method_options = ['GET']; + + # Construct the parent Endpoint object + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc new file mode 100644 index 00000000..bc8b5cf1 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemHASyncEndpoint.inc @@ -0,0 +1,24 @@ +url = '/api/v2/system/hasync'; + $this->model_name = 'HASync'; + $this->request_method_options = ['GET', 'PATCH']; + + $this->get_help_text = 'Reads pfSense High Availability synchronization settings.'; + $this->patch_help_text = 'Updates pfSense High Availability synchronization settings.'; + + parent::__construct(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc index 66231769..fe32a602 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Endpoints/SystemRESTAPISettingsSyncEndpoint.inc @@ -22,6 +22,7 @@ class SystemRESTAPISettingsSyncEndpoint extends Endpoint { $this->ignore_interfaces = true; $this->ignore_read_only = true; $this->auth_methods = ['BasicAuth']; + $this->requires_page_all_privilege = true; # Construct the parent Endpoint object parent::__construct(); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc new file mode 100644 index 00000000..e0d3ee85 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/HASync.inc @@ -0,0 +1,241 @@ +config_path = 'hasync'; + $this->many = false; + $this->always_apply = true; + $this->verbose_name = 'HA Sync Settings'; + $this->verbose_name_plural = 'HA Sync Settings'; + + $this->pfsyncenabled = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Enable pfsync state synchronization.', + ); + $this->pfsyncinterface = new InterfaceField( + default: 'lo0', + allow_localhost_interface: true, + allow_empty: true, + help_text: 'The interface used by pfsync state synchronization.', + ); + $this->pfhostid = new StringField( + default: '', + allow_empty: true, + maximum_length: 8, + help_text: 'Custom pf host identifier carried in state data.', + ); + $this->pfsyncpeerip = new StringField( + default: '', + allow_empty: true, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + help_text: 'The peer IP address used by pfsync.', + ); + $this->synchronizetoip = new StringField( + default: '', + allow_empty: true, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: false)], + help_text: 'The remote pfSense host IP address used for XMLRPC configuration synchronization.', + ); + $this->username = new StringField( + default: '', + allow_empty: true, + help_text: 'The remote pfSense username used for XMLRPC synchronization.', + ); + $this->password = new StringField( + default: '', + allow_empty: true, + write_only: true, + sensitive: true, + internal_name: 'passwordfld', + help_text: 'The remote pfSense password used for XMLRPC synchronization.', + ); + $this->adminsync = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize admin accounts and automatically update the XMLRPC sync password.', + ); + $this->synchronizeusers = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize users and groups.', + ); + $this->synchronizeauthservers = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize authentication servers.', + ); + $this->synchronizecerts = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize certificates.', + ); + $this->synchronizerules = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize firewall rules.', + ); + $this->synchronizeschedules = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize firewall schedules.', + ); + $this->synchronizealiases = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize firewall aliases.', + ); + $this->synchronizenat = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize NAT configuration.', + ); + $this->synchronizeipsec = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize IPsec configuration.', + ); + $this->synchronizeopenvpn = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize OpenVPN configuration.', + ); + $this->synchronizedhcpd = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCP server configuration.', + ); + $this->synchronizedhcpdv6 = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCPv6 server configuration.', + ); + $this->synchronizekea6 = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize Kea DHCPv6 server configuration.', + ); + $this->synchronizedhcrelay = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCP relay configuration.', + ); + $this->synchronizedhcrelay6 = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DHCPv6 relay configuration.', + ); + $this->synchronizewol = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize Wake-on-LAN configuration.', + ); + $this->synchronizestaticroutes = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize static routes.', + ); + $this->synchronizevirtualip = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize virtual IP addresses.', + ); + $this->synchronizetrafficshaper = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize traffic shaper queues.', + ); + $this->synchronizetrafficshaperlimiter = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize traffic shaper limiters.', + ); + $this->synchronizednsforwarder = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize DNS Forwarder and DNS Resolver configuration.', + ); + $this->synchronizecaptiveportal = new BooleanField( + default: false, + indicates_true: 'on', + indicates_false: null, + help_text: 'Synchronize captive portal configuration.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Applies HA Sync configuration changes via the FirewallApplyDispatcher. + */ + public function apply(): void { + (new FirewallApplyDispatcher(async: $this->async))->spawn_process(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc index 1793e099..c7f750ba 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettings.inc @@ -227,7 +227,7 @@ class RESTAPISettings extends Model { allow_empty: true, verbose_name: 'HA username', help_text: "Sets the username to use when authenticating for HA sync processes. This user must be the present - on all hosts defined in `ha_sync_hosts`.", + on all hosts defined in `ha_sync_hosts` and must hold the `page-all` privilege", ); $this->ha_sync_password = new StringField( default: '', diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc index 71cecbd4..2614fe2b 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/RESTAPISettingsSync.inc @@ -7,6 +7,7 @@ require_once 'RESTAPI/autoloader.inc'; use RESTAPI\Core\Model; use RESTAPI\Endpoints\SystemRESTAPISettingsSyncEndpoint; use RESTAPI\Fields\StringField; +use RESTAPI\Responses\NotAcceptableError; use RESTAPI\Responses\ValidationError; use function RESTAPI\Core\Tools\cprint; @@ -42,6 +43,22 @@ class RESTAPISettingsSync extends Model { return []; } + /** + * Adds extra validation to the entire RESTAPISettingsSync model + * @throws NotAcceptableError When the REST API settings sync feature is not enabled on this host + */ + public function validate_extra(): void { + $settings = new RESTAPISettings(); + + # Do not accept requests if HA sync features are disabled + if (!$settings->ha_sync->value) { + throw new NotAcceptableError( + message: 'REST API settings sync is not enabled on this host.', + response_id: 'RESTAPI_SETTINGS_SYNC_NOT_ENABLED', + ); + } + } + /** * Adds extra validation to the `sync_data` field. * @param string $sync_data The incoming value to be validated. @@ -50,7 +67,7 @@ class RESTAPISettingsSync extends Model { */ public function validate_sync_data(string $sync_data): string { # Attempt to unserialize the sync data - $unserialized_sync_data = unserialize($this->sync_data->value); + $unserialized_sync_data = json_decode($this->sync_data->value, associative: true); # Throw a validation error if $sync_data could not be unserialized if (!$unserialized_sync_data) { @@ -60,6 +77,11 @@ class RESTAPISettingsSync extends Model { ); } + # Re-validate the incoming data + $settings = new RESTAPISettings(); + $settings->from_internal_object($unserialized_sync_data); + $settings->validate(); + return $sync_data; } @@ -68,10 +90,9 @@ class RESTAPISettingsSync extends Model { */ public function _create(): void { # Unserialize the sync data - $unserialized_sync_data = unserialize($this->sync_data->value); + $unserialized_sync_data = json_decode($this->sync_data->value, associative: true); # Disable HA sync on this system since it is being managed by a remote peer. - $unserialized_sync_data['ha_sync'] = 'disabled'; $unserialized_sync_data['ha_sync_hosts'] = ''; $unserialized_sync_data['ha_sync_username'] = ''; $unserialized_sync_data['ha_sync_password'] = ''; @@ -111,7 +132,7 @@ class RESTAPISettingsSync extends Model { $response = \RESTAPI\Core\Tools\http_request( url: "$wc_protocol://$ha_sync_host:$wc_port$endpoint_url", method: 'POST', - data: ['sync_data' => serialize($settings->get_pkg_config())], + data: ['sync_data' => json_encode($settings->get_pkg_config())], username: $settings->ha_sync_username->value, password: $settings->ha_sync_password->value, validate_certs: false, diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc index 2da5e16a..95376334 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/SystemDNS.inc @@ -42,8 +42,8 @@ class SystemDNS extends Model { allow_empty: true, many: true, delimiter: null, - validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], verbose_name: 'DNS Servers', + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], help_text: 'The remote DNS server IPv4 or IPv6 addresses.', ); diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc new file mode 100644 index 00000000..1430055d --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardPeerStatus.inc @@ -0,0 +1,137 @@ +many = true; + $this->internal_callable = 'get_wireguard_status'; + $this->verbose_name = 'WireGuard Tunnel Status Peer'; + $this->packages = ['pfSense-pkg-WireGuard']; + $this->package_includes = [ + 'wireguard/includes/wg_service.inc', + 'wireguard/includes/wg.inc', + 'wireguard/includes/wg_globals.inc', + 'wireguard/includes/wg_api.inc', + ]; + + # Fields + $this->tunnel_device = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Tunnel Device', + help_text: 'The tunnel device this peer is associated with.', + ); + $this->public_key = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Public Key', + help_text: 'The public key of the peer.', + ); + $this->preshared_key = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + sensitive: true, + verbose_name: 'Preshared Key', + help_text: 'The preshared key negotiated with this peer, if any.', + ); + $this->endpoint = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + verbose_name: 'Endpoint', + help_text: 'The remote endpoint (IP:port) the peer is connected from.', + ); + $this->allowed_ips = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + many: true, + verbose_name: 'Allowed IPs', + help_text: 'The comma-separated list of IP prefixes that are routed to this peer.', + ); + $this->latest_handshake = new UnixTimeField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Latest Handshake', + help_text: 'The Unix timestamp of the most recent handshake with this peer.', + ); + $this->transfer_rx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer RX', + help_text: 'The total number of bytes received from this peer.', + ); + $this->transfer_tx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer TX', + help_text: 'The total number of bytes sent to this peer.', + ); + $this->persistent_keepalive = new StringField( + default: null, + allow_empty: true, + allow_null: true, + read_only: true, + verbose_name: 'Persistent Keepalive', + help_text: 'The persistent keepalive interval for this peer, or "off" if disabled.', + ); + $this->descr = new StringField( + default: '', + allow_empty: true, + read_only: true, + verbose_name: 'Description', + help_text: 'The description of this peer as stored in the pfSense configuration.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Retrieves the live WireGuard status from the `wg` CLI tool and maps it into the flat array format + * expected by the Model framework's internal_callable mechanism. + * @return array An indexed array of tunnel status records, each containing peer status as a sub-array. + */ + protected function get_wireguard_status(): array { + $peers = []; + $tunnels = (new WireGuardTunnelStatus())->get_internal_objects(); + + foreach ($tunnels as $tunnel) { + $peers = $peers + $tunnel['peers']; + } + + return $peers; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc new file mode 100644 index 00000000..510a5a60 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/WireGuardTunnelStatus.inc @@ -0,0 +1,172 @@ +internal_callable = 'get_wireguard_status'; + $this->many = true; + $this->verbose_name = 'WireGuard Tunnel Status'; + $this->packages = ['pfSense-pkg-WireGuard']; + $this->package_includes = [ + 'wireguard/includes/wg_service.inc', + 'wireguard/includes/wg.inc', + 'wireguard/includes/wg_globals.inc', + 'wireguard/includes/wg_api.inc', + ]; + + # Fields + $this->name = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Name', + help_text: 'The WireGuard tunnel interface name (e.g. wg0).', + ); + $this->status = new StringField( + default: null, + choices: ['up', 'down'], + allow_null: true, + read_only: true, + verbose_name: 'Status', + help_text: 'The current operational status of the tunnel interface.', + ); + $this->public_key = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Public Key', + help_text: 'The public key of this tunnel.', + ); + $this->listen_port = new StringField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Listen Port', + help_text: 'The UDP port this tunnel is listening on.', + ); + $this->mtu = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'MTU', + help_text: 'The MTU of the tunnel interface.', + ); + $this->transfer_rx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer RX', + help_text: 'The total number of bytes received on the tunnel interface.', + ); + $this->transfer_tx = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Transfer TX', + help_text: 'The total number of bytes transmitted on the tunnel interface.', + ); + $this->inpkts = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Inbound Packets', + help_text: 'The total number of inbound packets observed on the tunnel interface.', + ); + $this->outpkts = new IntegerField( + default: null, + allow_null: true, + read_only: true, + verbose_name: 'Outbound Packets', + help_text: 'The total number of outbound packets observed on the tunnel interface.', + ); + $this->descr = new StringField( + default: '', + allow_empty: true, + read_only: true, + verbose_name: 'Description', + help_text: 'The description of this tunnel as stored in the pfSense configuration.', + ); + $this->peers = new NestedModelField( + model_class: 'WireGuardPeerStatus', + default: [], + allow_empty: true, + read_only: true, + verbose_name: 'Peers', + help_text: 'The live status of each peer associated with this WireGuard tunnel.', + ); + + parent::__construct($id, $parent_id, $data, ...$options); + } + + /** + * Retrieves the live WireGuard status from the `wg` CLI tool and maps it into the flat array format + * expected by the Model framework's internal_callable mechanism. + * @return array An indexed array of tunnel status records, each containing peer status as a sub-array. + */ + protected function get_wireguard_status(): array { + $tunnels = []; + + $this->check_packages(); + + foreach (wg_get_status() as $device_name => $device) { + # Build the indexed peer list from the public-key-keyed peers array + $peers = []; + + foreach ($device['peers'] as $peer) { + $peers[] = [ + 'tunnel_device' => $device_name, + 'public_key' => $peer['public_key'] ?? null, + 'preshared_key' => $peer['preshared_key'] ?? null, + 'endpoint' => $peer['endpoint'] ?? null, + 'allowed_ips' => $peer['allowed_ips'] ?? null, + 'latest_handshake' => isset($peer['latest_handshake']) ? (int) $peer['latest_handshake'] : null, + 'transfer_rx' => isset($peer['transfer_rx']) ? (int) $peer['transfer_rx'] : null, + 'transfer_tx' => isset($peer['transfer_tx']) ? (int) $peer['transfer_tx'] : null, + 'persistent_keepalive' => $peer['persistent_keepalive'] ?? null, + 'descr' => $peer['config']['descr'] ?? '', + ]; + } + + $tunnels[] = [ + 'name' => $device_name, + 'status' => $device['status'] ?? null, + 'public_key' => $device['public_key'] ?? null, + 'listen_port' => $device['listen_port'] ?? null, + 'mtu' => isset($device['mtu']) ? (int) $device['mtu'] : null, + 'transfer_rx' => isset($device['transfer_rx']) ? (int) $device['transfer_rx'] : null, + 'transfer_tx' => isset($device['transfer_tx']) ? (int) $device['transfer_tx'] : null, + 'inpkts' => isset($device['inpkts']) ? (int) $device['inpkts'] : null, + 'outpkts' => isset($device['outpkts']) ? (int) $device['outpkts'] : null, + 'descr' => $device['config']['descr'] ?? '', + 'peers' => $peers, + ]; + } + + return $tunnels; + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc index 7ee51940..79303527 100644 --- a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APICoreEndpointTestCase.inc @@ -133,6 +133,51 @@ class APICoreEndpointTestCase extends TestCase { ]); } + /** + * Checks that when $requires_page_all_privilege is false (the default), each method's privilege set contains both + * the page-all privilege and an auto-generated method-scoped privilege. + */ + public function test_privileges_auto_generated_when_requires_page_all_privilege_is_false(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $endpoint = new FirewallAliasEndpoint(); + + # Confirm the flag is off by default + $this->assert_is_false($endpoint->requires_page_all_privilege); + + # Each privilege array must contain page-all AND the scoped method privilege + $this->assert_equals($endpoint->get_privileges, ['page-all', 'api-v2-firewall-alias-get']); + $this->assert_equals($endpoint->post_privileges, ['page-all', 'api-v2-firewall-alias-post']); + $this->assert_equals($endpoint->patch_privileges, ['page-all', 'api-v2-firewall-alias-patch']); + $this->assert_equals($endpoint->put_privileges, ['page-all', 'api-v2-firewall-alias-put']); + $this->assert_equals($endpoint->delete_privileges, ['page-all', 'api-v2-firewall-alias-delete']); + + # generate_pfsense_privs() must return a non-empty array of method-scoped privileges + $this->assert_is_not_empty($endpoint->generate_pfsense_privs()); + } + + /** + * Checks that when $requires_page_all_privilege is true, every method's privilege set contains only page-all + * and that generate_pfsense_privs() returns an empty array (no auto-generated scoped privileges). + */ + public function test_privileges_only_page_all_when_requires_page_all_privilege_is_true(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $endpoint = new FirewallAliasEndpoint(); + $endpoint->requires_page_all_privilege = true; + + # Re-run privilege assignment to apply the flag + $endpoint->__construct(); + + # Each privilege array must contain ONLY page-all + $this->assert_equals($endpoint->get_privileges, ['page-all']); + $this->assert_equals($endpoint->post_privileges, ['page-all']); + $this->assert_equals($endpoint->patch_privileges, ['page-all']); + $this->assert_equals($endpoint->put_privileges, ['page-all']); + $this->assert_equals($endpoint->delete_privileges, ['page-all']); + + # generate_pfsense_privs() must return an empty array — no scoped privileges are registered + $this->assert_is_empty($endpoint->generate_pfsense_privs()); + } + /** * Checks that the check_auth() method correct enforces authentication */ @@ -926,7 +971,6 @@ class APICoreEndpointTestCase extends TestCase { $_SERVER['QUERY_STRING'] = "name=$unique_name&type=host&dry_run=true"; $endpoint = new FirewallAliasEndpoint(); $json_resp = json_decode($endpoint->process_request()); - var_dump($json_resp); $this->assert_equals($json_resp->code, 200); # Ensure no new alias was actually persisted diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc new file mode 100644 index 00000000..8b0ba4b9 --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIEndpointsSystemRESTAPISettingsSyncEndpointTestCase.inc @@ -0,0 +1,134 @@ +update(); + } + + /** + * Restores HA sync and login protection to their original values after tests complete. + */ + public function teardown(): void { + $settings = new RESTAPISettings(ha_sync: false, login_protection: true); + $settings->update(); + } + + /** + * Ensures a POST request to the sync endpoint is rejected with a 406 when ha_sync is disabled. + */ + public function test_post_rejected_when_ha_sync_disabled(): void { + # Disable HA sync + $settings = new RESTAPISettings(ha_sync: false); + $settings->update(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 406); + $this->assert_equals($resp->response_id, 'RESTAPI_SETTINGS_SYNC_NOT_ENABLED'); + + # Re-enable HA sync for subsequent tests + $settings->ha_sync->value = true; + $settings->update(); + } + + /** + * Ensures a POST request with invalid (non-JSON) sync_data is rejected with a 400. + */ + public function test_post_rejected_when_sync_data_is_invalid(): void { + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => 'not valid json'], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 400); + $this->assert_equals($resp->response_id, 'RESTAPI_SETTINGS_SYNC_SYNC_DATA_COULD_NOT_BE_UNSERIALIZED'); + } + + /** + * Ensures a POST request with valid sync_data succeeds. + */ + public function test_post_accepted_with_valid_sync_data(): void { + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: 'admin', + password: 'pfsense', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 200); + } + + /** + * Ensures a POST request from a user without the page-all privilege is rejected with a 403. + */ + public function test_post_rejected_without_page_all_privilege(): void { + # Create a user with no privileges + $user = new User(data: ['name' => 'sync_test_nopageall', 'password' => 'testpassword', 'priv' => []]); + $user->create(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: $user->name->value, + password: 'testpassword', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 403); + + $user->delete(); + } + + /** + * Ensures a POST request from a user with the page-all privilege is accepted. + */ + public function test_post_accepted_with_page_all_privilege(): void { + # Create a user with the page-all privilege + $user = new User(data: ['name' => 'sync_test_pageall', 'password' => 'testpassword', 'priv' => ['page-all']]); + $user->create(); + + $json_resp = \RESTAPI\Core\Tools\http_request( + url: self::ENDPOINT_URL, + method: 'POST', + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + headers: ['Content-Type' => 'application/json'], + username: $user->name->value, + password: 'testpassword', + validate_certs: false, + ); + $resp = json_decode($json_resp); + $this->assert_equals($resp->code, 200); + + $user->delete(); + } +} diff --git a/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc new file mode 100644 index 00000000..2864ed0f --- /dev/null +++ b/pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Tests/APIModelsHASyncTestCase.inc @@ -0,0 +1,145 @@ +assert_does_not_throw( + callable: function () { + $hasync = new HASync(); + $hasync->pfsyncenabled->value = true; + $hasync->pfhostid->value = 'testid'; + $hasync->pfsyncpeerip->value = '192.168.1.2'; + $hasync->update(); + }, + ); + + # Restore defaults + $hasync = new HASync(); + $hasync->pfsyncenabled->value = false; + $hasync->pfhostid->value = ''; + $hasync->pfsyncpeerip->value = ''; + $hasync->update(); + } + + /** + * Checks that XMLRPC sync connection settings can be updated without error. + */ + public function test_update_xmlrpc_connection_settings(): void { + $this->assert_does_not_throw( + callable: function () { + $hasync = new HASync(); + $hasync->synchronizetoip->value = '10.0.0.1'; + $hasync->username->value = 'syncuser'; + $hasync->password->value = 'syncpassword'; + $hasync->adminsync->value = true; + $hasync->update(); + }, + ); + + # Restore defaults + $hasync = new HASync(); + $hasync->synchronizetoip->value = ''; + $hasync->username->value = ''; + $hasync->password->value = ''; + $hasync->adminsync->value = false; + $hasync->update(); + } + + /** + * Checks that all synchronize* boolean flags can be enabled and disabled without error. + */ + public function test_update_synchronize_flags(): void { + $this->assert_does_not_throw( + callable: function () { + $hasync = new HASync( + data: [ + 'synchronizeusers' => true, + 'synchronizeauthservers' => true, + 'synchronizecerts' => true, + 'synchronizerules' => true, + 'synchronizeschedules' => true, + 'synchronizealiases' => true, + 'synchronizenat' => true, + 'synchronizeipsec' => true, + 'synchronizeopenvpn' => true, + 'synchronizedhcpd' => true, + 'synchronizedhcpdv6' => true, + 'synchronizekea6' => true, + 'synchronizedhcrelay' => true, + 'synchronizedhcrelay6' => true, + 'synchronizewol' => true, + 'synchronizestaticroutes' => true, + 'synchronizevirtualip' => true, + 'synchronizetrafficshaper' => true, + 'synchronizetrafficshaperlimiter' => true, + 'synchronizednsforwarder' => true, + 'synchronizecaptiveportal' => true, + ], + ); + $hasync->update(); + }, + ); + + # Restore all flags to disabled + $this->assert_does_not_throw( + callable: function () { + $hasync = new HASync( + data: [ + 'synchronizeusers' => false, + 'synchronizeauthservers' => false, + 'synchronizecerts' => false, + 'synchronizerules' => false, + 'synchronizeschedules' => false, + 'synchronizealiases' => false, + 'synchronizenat' => false, + 'synchronizeipsec' => false, + 'synchronizeopenvpn' => false, + 'synchronizedhcpd' => false, + 'synchronizedhcpdv6' => false, + 'synchronizekea6' => false, + 'synchronizedhcrelay' => false, + 'synchronizedhcrelay6' => false, + 'synchronizewol' => false, + 'synchronizestaticroutes' => false, + 'synchronizevirtualip' => false, + 'synchronizetrafficshaper' => false, + 'synchronizetrafficshaperlimiter' => false, + 'synchronizednsforwarder' => false, + 'synchronizecaptiveportal' => false, + ], + ); + $hasync->update(); + }, + ); + } + + /** + * Checks that updated values are persisted correctly and can be read back. + */ + public function test_updated_values_are_persisted(): void { + $hasync = new HASync(); + $hasync->synchronizerules->value = true; + $hasync->synchronizealiases->value = true; + $hasync->username->value = 'hauser'; + $hasync->update(); + + # Read the settings back and verify the values were stored + $hasync_read = new HASync(); + $this->assert_is_true($hasync_read->synchronizerules->value); + $this->assert_is_true($hasync_read->synchronizealiases->value); + $this->assert_equals($hasync_read->username->value, 'hauser'); + + # Restore defaults + $hasync_read->synchronizerules->value = false; + $hasync_read->synchronizealiases->value = false; + $hasync_read->username->value = ''; + $hasync_read->update(); + } +} 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 28cfa8e6..2dbbc99b 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 @@ -8,47 +8,88 @@ use RESTAPI\Models\RESTAPISettingsSync; class APIModelsRESTAPISettingsSyncTestCase extends TestCase { /** - * Checks that the 'serialize_settings()` method correctly populates the 'sync_data' field. + * Enables HA sync before running tests so that requests are accepted. + */ + public function setup(): void { + $settings = new RESTAPISettings(); + $settings->ha_sync->value = true; + $settings->update(); + } + + /** + * Restores HA sync to its original disabled state after tests complete. + */ + public function teardown(): void { + $settings = new RESTAPISettings(); + $settings->ha_sync->value = false; + $settings->update(); + } + + /** + * Checks that create() is rejected with a NotAcceptableError when ha_sync is disabled. + */ + public function test_create_rejected_when_ha_sync_disabled(): void { + # Disable HA sync + $settings = new RESTAPISettings(); + $settings->ha_sync->value = false; + $settings->update(); + + $this->assert_throws_response( + response_id: 'RESTAPI_SETTINGS_SYNC_NOT_ENABLED', + code: 406, + callable: function () { + $settings_sync = new RESTAPISettingsSync( + data: ['sync_data' => json_encode(RESTAPISettings::get_pkg_config())], + ); + $settings_sync->create(); + }, + ); + + # Re-enable HA sync for subsequent tests + $settings->ha_sync->value = true; + $settings->update(); + } + + /** + * Checks that the 'validate_sync_data()' method correctly validates the 'sync_data' field. */ public function test_validate_sync_data(): void { - # Ensure a validation error is thrown if 'sync_data' cannot be unserialized + # Ensure a validation error is thrown if 'sync_data' is not valid JSON $this->assert_throws_response( response_id: 'RESTAPI_SETTINGS_SYNC_SYNC_DATA_COULD_NOT_BE_UNSERIALIZED', code: 400, callable: function () { - # Create a sync object to test with $settings_sync = new RESTAPISettingsSync(); - $settings_sync->sync_data->value = 'not a serialized value'; + $settings_sync->sync_data->value = 'not valid json'; $settings_sync->validate(); }, ); - # Ensure serialized values are accepted + # Ensure valid JSON-encoded settings are accepted $this->assert_does_not_throw( callable: function () { - # Create a sync object to test with $settings_sync = new RESTAPISettingsSync(); - $settings_sync->sync_data->value = serialize(RESTAPISettings::get_pkg_config()); + $settings_sync->sync_data->value = json_encode(RESTAPISettings::get_pkg_config()); $settings_sync->validate(); }, ); } /** - * Checks that the 'create()' method correctly syncs the API configuration with the serialized sync data + * Checks that the 'create()' method correctly syncs the API configuration with the JSON-encoded sync data. */ public function test_create(): void { # Get the current API configuration $original_api_config = RESTAPISettings::get_pkg_config(); $sync_api_config = $original_api_config; - # Make some modifications to the API and serialize it + # Make some modifications to the API config and JSON-encode it $sync_api_config['enable'] = 'disabled'; $sync_api_config['read_only'] = 'enabled'; $sync_api_config['allowed_interfaces'] = 'lan'; # Perform the sync against ourself and ensure it updates the API config - $sync = new RESTAPISettingsSync(data: ['sync_data' => serialize($sync_api_config)]); + $sync = new RESTAPISettingsSync(data: ['sync_data' => json_encode($sync_api_config)]); $sync->create(); # Read the API config now and ensure it matches the config that was synced @@ -57,7 +98,7 @@ class APIModelsRESTAPISettingsSyncTestCase extends TestCase { $this->assert_equals($sync_api_config, $new_api_config); # Revert the API configuration to its original values via sync - $sync->sync_data->value = serialize($original_api_config); + $sync->sync_data->value = json_encode($original_api_config); $sync->create(); # Read the API config now and ensure it matches the original config