Skip to content

Commit

Permalink
feat: added Dispatchers
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredhendrickson13 committed Nov 18, 2023
1 parent 6d754b3 commit 9e1ca22
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 9 deletions.
3 changes: 1 addition & 2 deletions pfSense-pkg-API/files/etc/inc/api/auto_loader.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
8 changes: 5 additions & 3 deletions pfSense-pkg-API/files/etc/inc/api/core/Command.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -34,12 +36,12 @@ 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
if ($this->trim_whitespace) {
$this->output = preg_replace('/\s+/', ' ', $this->output);
}
}
}
}
138 changes: 138 additions & 0 deletions pfSense-pkg-API/files/etc/inc/api/core/Dispatcher.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

namespace API\Core;

use API\Responses\ServerError;
use API\Responses\ServiceUnavailableResponse;

/**
* The Dispatcher object is used to define functions that are intended to be run in the background. The Dispatcher
* objects allows us to dynamically create a private PHP script that can be called in the background, manages the
* process spawn queue, and enforces background process timeouts.
*/
abstract class Dispatcher
{
private string $name;
private string $pid_dir;
private string $pid_file;
private string $pid_file_prefix;
private string $script_file;
public int $timeout = 600;
public int $delay = 0;
public int $max_concurrency = 0;

public function __construct()
{
# Get the current class name including namespace, remove slashes
$this->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 = "<?php
require_once('api/auto_loader.inc');
(new ".$fq_class_name."())->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);
}
64 changes: 62 additions & 2 deletions pfSense-pkg-API/files/etc/inc/api/core/Model.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions pfSense-pkg-API/files/etc/inc/api/dispatchers/TestDispatcher.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace API\Dispatchers;

use API\Core\Dispatcher;

class TestDispatcher extends Dispatcher
{
public int $max_concurrency = 1;
public int $timeout = 30;

/**
* Emulate a process that takes a long time
*/
public static function process(...$arguments)
{
sleep(60);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace API\Models;

use API;
use API\Core\Model;
use API\Fields\StringField;
use api\validators\HostnameValidator;
use API\Validators\IPAddressValidator;

class DNSResolverHostOverride extends Model
{
public StringField $host;
public StringField $domain;
public StringField $ip;
public StringField $descr;

public function __construct(mixed $id = null, mixed $representation_data = [], Auth $client = null)
{
# Set model attributes
$this->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);
}


}
4 changes: 2 additions & 2 deletions pfSense-pkg-API/files/etc/inc/api/models/FirewallRule.inc
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,15 @@ 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);
}

/**
* 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
Expand Down
Loading

0 comments on commit 9e1ca22

Please sign in to comment.