diff --git a/pfSense-pkg-API/files/etc/inc/api/auto_loader.inc b/pfSense-pkg-API/files/etc/inc/api/auto_loader.inc index 158b5e98f..bb78c7e7c 100644 --- a/pfSense-pkg-API/files/etc/inc/api/auto_loader.inc +++ b/pfSense-pkg-API/files/etc/inc/api/auto_loader.inc @@ -26,10 +26,9 @@ require_once("certs.inc"); require_once("pkg-utils.inc"); require_once("firewall_virtual_ip.inc"); -global $config; - const API_LIBRARIES = [ "/etc/inc/api/core/", + "/etc/inc/api/dispatchers/", "/etc/inc/api/responses/", "/etc/inc/api/validators/", "/etc/inc/api/fields/", diff --git a/pfSense-pkg-API/files/etc/inc/api/core/Command.inc b/pfSense-pkg-API/files/etc/inc/api/core/Command.inc index e1cff0eee..a4ba9ba97 100644 --- a/pfSense-pkg-API/files/etc/inc/api/core/Command.inc +++ b/pfSense-pkg-API/files/etc/inc/api/core/Command.inc @@ -8,6 +8,7 @@ namespace API\Core; class Command { public string $command; + public string $redirect; public string $output = ""; public int $result_code = -1; public bool $trim_whitespace = false; @@ -20,10 +21,11 @@ class Command * @return Command Returns this object containing the results of the executed command. Note: the object returned * cannot be used to initiate new commands. A new Command object should be created for any additional commands. */ - public function __construct(string $command, bool $trim_whitespace = false) + public function __construct(string $command, bool $trim_whitespace = false, string $redirect = "2>&1") { $this->command = $command; $this->trim_whitespace = $trim_whitespace; + $this->redirect = $redirect; $this->run_command(); return $this; } @@ -34,7 +36,7 @@ class Command */ private function run_command(): void { - exec(command: "$this->command 2>&1", output: $raw_output, result_code: $this->result_code); + exec(command: "$this->command $this->redirect", output: $raw_output, result_code: $this->result_code); $this->output = implode(PHP_EOL, $raw_output); # Normalize output's whitespace if requested @@ -42,4 +44,4 @@ class Command $this->output = preg_replace('/\s+/', ' ', $this->output); } } -} \ No newline at end of file +} diff --git a/pfSense-pkg-API/files/etc/inc/api/core/Dispatcher.inc b/pfSense-pkg-API/files/etc/inc/api/core/Dispatcher.inc new file mode 100644 index 000000000..08eba9e0d --- /dev/null +++ b/pfSense-pkg-API/files/etc/inc/api/core/Dispatcher.inc @@ -0,0 +1,138 @@ +name = str_replace("\\", "", get_called_class()); + + # Set the Dispatcher PID file directory and name. This cannot be changed by child classes. + $this->pid_dir = "/tmp/"; + $this->pid_file_prefix = $this->name."-"; + $this->pid_file = $this->pid_dir.uniqid(prefix: $this->pid_file_prefix).".pid"; + + # Set the path of the Dispatcher script. This will be used when building the Dispatcher and spawning processes + $this->script_file = "/usr/local/share/pfSense-pkg-API/dispatchers/".$this->name.".php"; + } + + /** + * Builds the Dispatcher PHP script at the assigned filepath. + */ + public function build_dispatcher_script() { + # Get the fully qualified class name for this object + $fq_class_name = get_class($this); + + # Specify the PHP code to write to the Dispatcher script file + $code = "process();"; + + # Assign the absolute path to the file. Assume index.php filename if not specified. + echo "Building ".$fq_class_name." at path $this->script_file... "; + mkdir(dirname($this->script_file), 0755, true); + file_put_contents($this->script_file, $code); + + # Print success output if file now exists, otherwise output error and exit on non-zero code + if (is_file($this->script_file)) { + echo "done.".PHP_EOL; + } + else { + echo "failed.".PHP_EOL; + exit(1); + } + } + + /** + * Obtains the PIDs of any active processes spawned by this Dispatcher. + * @returns array An array of PIDs of processes spawned by this Dispatcher. + */ + public function get_running_processes() : array { + # Variables + $pids = []; + + # Loop through each existing proc file and checks its PID + foreach (glob($this->pid_dir.$this->pid_file_prefix."*.pid") as $pid_file) { + $pid = (int)file_get_contents($pid_file); + + # If this PID is actively running, add it to the return array + if (posix_getpgid($pid)) { + $pids[] = $pid; + } + # Otherwise, remove the PID file + else { + unlink($pid_file); + } + } + + return $pids; + } + + /** + * Spawns a new process for this Dispatcher. + * @param mixed ...$arguments Optional arguments to pass to the `process()` method. + * @return int The PID of the spawned process. + */ + public function spawn_process(mixed ...$arguments) : int { + # Before we spawn a new process, ensure we don't have too many concurrent processes running now + if ($this->max_concurrency and count($this->get_running_processes()) >= $this->max_concurrency) { + throw new ServiceUnavailableResponse( + message: "Dispatcher allows for a maximum of `$this->max_concurrency` processes to be running at ". + "once, try again after `$this->timeout` seconds.", + response_id: "DISPATCHER_TOO_MANY_CONCURRENT_PROCESSES", + retry_after: $this->timeout + ); + } + + # Delay spawning the process if a delay was requested. + if ($this->delay) { + sleep($this->delay); + } + + # Spawn the process + $spawn_process = new Command( + command: "nohup timeout $this->timeout php -f $this->script_file", + redirect: ">/dev/null & echo $!" + ); + $pid = (is_numeric($spawn_process->output)) ? (int)$spawn_process->output : null; + + # Ensure the spawn process output is a numeric PID and that the PID is actively running + if (!is_null($pid) and posix_getpgid($pid)) { + # Write this Dispatcher run's PID file and return the PID. + file_put_contents($this->pid_file, $pid); + return $pid; + } + + # Throw an error if we failed to spawn the Dispatcher process + throw new ServerError( + message: "Dispatcher `$this->name` failed to spawn new process. Received: $spawn_process->output", + response_id: "DISPATCHER_FAILED_TO_SPAWN_PROCESS" + ); + } + + /** + * Defines what should be done when the Dispatch process is spawned. In most cases, this will restart some service + * or perform computations that may take a long time. It is up to the child Dispatcher class to decide what is + * done here. + */ + public static abstract function process(mixed ...$arguments); +} diff --git a/pfSense-pkg-API/files/etc/inc/api/core/Model.inc b/pfSense-pkg-API/files/etc/inc/api/core/Model.inc index 92f434985..8f8486ecf 100644 --- a/pfSense-pkg-API/files/etc/inc/api/core/Model.inc +++ b/pfSense-pkg-API/files/etc/inc/api/core/Model.inc @@ -43,6 +43,8 @@ class Model { public array $package_includes = []; public array $unique_together_fields = []; public string $internal_callable = ""; + public int|null $sort_option = null; + public int|null $sort_by_field = null; public string $subsystem = ""; public bool $always_apply = false; public string $update_strategy = "merge"; @@ -850,6 +852,61 @@ class Model { return ($model->many) ? new ModelSet($model_objects) : $model_objects[0]; } + /** + * Sorts `many` Model entries internally before writing the changes to config. This is useful for Model's whose + * internal objects must be written in a specific order. + */ + protected function sort() { + # Do not allow non `many` models to be sorted + if (!$this->many) { + throw new ServerError( + message: "Only `many` Models can be sorted.", + response_id: "MODEL_CANNOT_BE_SORTED_WITHOUT_MANY" + ); + } + + # Do not sort if there is no `sort_option` or `sort_by_field` set + if (!$this->sort_option or !$this->sort_by_field) { + return; + } + + $internal_objects = $this->get_config($this->config_path, []); + $criteria = []; + + # Loop through each rule and map its sort field value to our sort criteria array + foreach ($internal_objects as $id=>$internal_object) { + # Store the internal object's existing ID so we can locate new IDs after sorting + $internal_objects[$id]["original_id"] = $id; + + # Use the `internal_name` of the assigned `sort_by_field` since we are dealing with internal objects + $sort_by_field_internal_name = $this->{$this->sort_by_field}->internal_name; + + # Map the real field if it's not empty, otherwise assume an empty string + if (!empty($internal_object[$sort_by_field_internal_name])) { + $criteria[$id] = $internal_object[$sort_by_field_internal_name]; + } else { + $criteria[$id] = ""; + } + } + + # Sort the internal objects using the previously determined criteria + array_multisort($criteria, $this->sort_option, $internal_objects); + + # Loop through the sorted internal objects and find $this object's new ID + foreach ($internal_objects as $new_id => $sorted_internal_object) { + # Check if this sorted internal object contains $this objects original ID + if ($this->id == $sorted_internal_object["original_id"]) { + $this->id = $new_id; + } + + # Remove the `original_id` value so we don't save it to config + unset($internal_objects[$new_id]["original_id"]); + } + + # Sets the sorted internal objects to the pfSense config + $this->set_config($this->config_path, array_values($internal_objects)); + } + /** * Performs a query on all Model objects for this Model. This is essentially a shorthand way of calling * `read_all()->query()`. This method is only applicable to `many` models. @@ -895,6 +952,7 @@ class Model { if ($this->config_path and $this->many) { # Write the new object to the internal config $this->set_config(path: "$this->config_path/$this->id", value: $this->to_internal()); + $this->sort(); $this->write_config("Added $this->verbose_name via API"); return; } @@ -974,7 +1032,8 @@ class Model { ); } - # Write the changes to the object in config. + # Sort an write the changes to the object in config. + $this->sort(); $this->write_config("Modified $this->verbose_name via API"); return; } @@ -1060,7 +1119,8 @@ class Model { $this->set_config("$this->config_path/$model_object->id", $model_object->to_internal()); } - # Write the changes to config + # Sort and write the changes to config + $this->sort(); $this->write_config("Replaced all $this->verbose_name_plural via API"); # Mark the subsystem as dirty if set diff --git a/pfSense-pkg-API/files/etc/inc/api/dispatchers/TestDispatcher.inc b/pfSense-pkg-API/files/etc/inc/api/dispatchers/TestDispatcher.inc new file mode 100644 index 000000000..225d14357 --- /dev/null +++ b/pfSense-pkg-API/files/etc/inc/api/dispatchers/TestDispatcher.inc @@ -0,0 +1,19 @@ +config_path = "unbound/hosts"; + $this->many = true; + $this->sort_option = SORT_ASC; + $this->sort_by_field = "host"; + + # Set model fields + $this->host = new StringField( + required: true, + allow_empty: true, + maximum_length: 255, + validators: [new HostnameValidator()], + help_text: "The hostname portion of the host override." + ); + $this->domain = new StringField( + required: true, + maximum_length: 255, + validators: [new HostnameValidator()], + help_text: "The hostname portion of the host override." + ); + $this->ip = new StringField( + required: true, + many: true, + validators: [new IPAddressValidator(allow_ipv4: true, allow_ipv6: true)], + help_text: "The IP addresses this host override will resolve." + ); + $this->descr = new StringField( + default: "", + allow_empty: true, + help_text: "A detailed description for this host override." + ); + parent::__construct($id, $representation_data, $client); + } + + +} diff --git a/pfSense-pkg-API/files/etc/inc/api/models/FirewallRule.inc b/pfSense-pkg-API/files/etc/inc/api/models/FirewallRule.inc index 7d77c0fe4..2f263b393 100644 --- a/pfSense-pkg-API/files/etc/inc/api/models/FirewallRule.inc +++ b/pfSense-pkg-API/files/etc/inc/api/models/FirewallRule.inc @@ -228,8 +228,7 @@ class FirewallRule extends Model # TODO: Add ForeignModelField for `pdnpipe` when the traffic shaper models are developed # TODO: Add ForeignModelField for `defaultqueue` when the traffic shaper models are developed # TODO: Add ForeignModelField for `ackqueue` when the traffic shaper models are developed - - + parent::__construct($id, $representation_data, $client); } @@ -237,6 +236,7 @@ class FirewallRule extends Model * Adds extra validation to the `interface` field. * @param string $interface The incoming value to be validated. * @return string The validated value to be set. + * @throws ValidationError When multiple `interface` values are assigned but `floating` is not enabled */ public function validate_interface(string $interface) : string { # Do not allow more than one interface to be assigned if this is not a floating rule diff --git a/pfSense-pkg-API/files/etc/inc/api/responses/ServiceUnavailableResponse.inc b/pfSense-pkg-API/files/etc/inc/api/responses/ServiceUnavailableResponse.inc new file mode 100644 index 000000000..c04c01f48 --- /dev/null +++ b/pfSense-pkg-API/files/etc/inc/api/responses/ServiceUnavailableResponse.inc @@ -0,0 +1,24 @@ +assert_throws_response( + response_id: "FIREWALL_RULE_MULTIPLE_INTERFACE_WITHOUT_FLOATING", + code: 400, + callable: function () { + $rule = new FirewallRule(); + $rule->interface->value = ["wan", "lan"]; + $rule->validate_interface("wan"); + } + ); + + # Ensure multiple interface values are allowed for floating FirewallRules + $this->assert_does_not_throw( + callable: function () { + $rule = new FirewallRule(); + $rule->interface->value = ["wan", "lan"]; + $rule->floating->value = true; + $rule->validate_interface("wan"); + } + ); + } + + /** + * Checks that the `statetype` can only be `synproxy state` if the `protocol` is `tcp` + */ + public function test_no_synproxy_statetype_with_non_tcp_rule() { + $this->assert_throws_response( + response_id: "FIREWALL_RULE_SYNPROXY_STATE_TYPE_WITH_NON_TCP_PROTOCOL", + code: 400, + callable: function () { + $rule = new FirewallRule(); + $rule->protocol->value = "icmp"; + $rule->validate_statetype("synproxy state"); + } + ); + } + + /** + * Checks that the `statetype` can only be `synproxy state` if a `gateway` is not set. + */ + public function test_no_synproxy_statetype_with_gateway_assigned() { + $this->assert_throws_response( + response_id: "FIREWALL_RULE_SYNPROXY_STATE_TYPE_WITH_GATEWAY", + code: 400, + callable: function () { + $rule = new FirewallRule(); + $rule->protocol->value = "tcp"; + $rule->gateway->value = "TESTGW"; + $rule->validate_statetype("synproxy state"); + } + ); + } + + /** + * Checks that any values specific in `tcp_flags_set` must also be present in `tcp_flags_out_of` + */ + public function test_tcp_flag_set_must_be_in_tcp_flags_out_of() { + $this->assert_throws_response( + response_id: "FIREWALL_RULE_TCP_FLAGS_SET_NOT_IN_TCP_FLAGS_OUT_OF", + code: 400, + callable: function () { + $rule = new FirewallRule(); + $rule->tcp_flags_out_of->value = ["syn", "ack"]; + $rule->validate_tcp_flags_set("rst"); + } + ); + } + + /** + * Checks that the `update_by` value is automatically updated with the current user regardless of what value + * is currently is assigned. + */ + public function test_updated_by_is_automatically_overwritten() { + # Define a FirewallRule object and set it's client username and IP + $rule = new FirewallRule(); + $rule->client->username = "testuser1"; + $rule->client->ip_address = "127.0.0.1"; + + # Ensure the `validate_updated_by()` method automatically generates a new value using the client user and IP + $this->assert_equals( + $rule->validate_updated_by("doesn't matter what this value is!"), + "{$rule->client->username}@{$rule->client->ip_address} (API)" + ); + + # For good measure, update the client username and IP again and ensure it is automatically updated + $rule->client->username = "testuser2"; + $rule->client->ip_address = "127.0.0.2"; + $this->assert_equals( + $rule->validate_updated_by("doesn't matter what this value is!"), + "{$rule->client->username}@{$rule->client->ip_address} (API)" + ); + } +} diff --git a/pfSense-pkg-API/files/etc/inc/api/tests/APIValidatorsHostnameValidatorTestCase.inc b/pfSense-pkg-API/files/etc/inc/api/tests/APIValidatorsHostnameValidatorTestCase.inc new file mode 100644 index 000000000..1a53dd12f --- /dev/null +++ b/pfSense-pkg-API/files/etc/inc/api/tests/APIValidatorsHostnameValidatorTestCase.inc @@ -0,0 +1,44 @@ +assert_throws_response( + response_id: "HOSTNAME_VALIDATOR_FAILED", + code: 400, + callable: function () { + $validator = new HostnameValidator(); + $validator->validate("!!! NOT A HOSTNAME !!!"); + } + ); + + $this->assert_does_not_throw( + callable: function () { + $validator = new HostnameValidator(); + $validator->validate("hostname"); + } + ); + + $this->assert_does_not_throw( + callable: function () { + $validator = new HostnameValidator(); + $validator->validate("also-a-hostname"); + } + ); + + $this->assert_does_not_throw( + callable: function () { + $validator = new HostnameValidator(); + $validator->validate("also.a.hostname"); + } + ); + } +} diff --git a/pfSense-pkg-API/files/etc/inc/api/validators/HostnameValidator.inc b/pfSense-pkg-API/files/etc/inc/api/validators/HostnameValidator.inc new file mode 100644 index 000000000..f52a62051 --- /dev/null +++ b/pfSense-pkg-API/files/etc/inc/api/validators/HostnameValidator.inc @@ -0,0 +1,36 @@ +allow_keywords = $allow_keywords; + } + + /** + * Checks if a given value is a valid hostname. + * @param mixed $value The value to validate. + * @param string $field_name The field name of the value being validated. This is used for error messages. + * @throws ValidationError When the value is not a valid hostname + */ + public function validate(mixed $value, string $field_name = "") { + # Throw a ValidationError if this value is not a hostname or an allowed keyword + if (!is_hostname($value) and !in_array($value, $this->allow_keywords)) { + throw new ValidationError( + message: "Field `$field_name` must be a valid hostname, received `$value`.", + response_id: "HOSTNAME_VALIDATOR_FAILED" + ); + } + } +}