#!/usr/bin/php8.4 -c/etc/openmediavault
<?php

/**
 * This file is part of OpenMediaVault.
 *
 * @license   https://www.gnu.org/licenses/gpl.html GPL Version 3
 * @author    Volker Theile <volker.theile@openmediavault.org>
 * @copyright Copyright (c) 2009-2026 Volker Theile
 *
 * OpenMediaVault is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * any later version.
 *
 * OpenMediaVault is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with OpenMediaVault. If not, see <https://www.gnu.org/licenses/>.
 */
require_once("openmediavault/autoloader.inc");
require_once("openmediavault/functions.inc");

///////////////////////////////////////////////////////////////////////////////
// Helper functions.
///////////////////////////////////////////////////////////////////////////////

/**
 * Display command usage.
 */
function usage()
{
    global $argv, $cmdName;
    $text = <<<EOF
The RPC daemon. RPC request will be received via socket.
Usage:
  %s [options]

OPTIONS:
  -d --debug       Enable debug mode
  -f --foreground  Run in foreground
  -h --help        Print a help text
  -x --xdebug      Enable XDebug compatibility mode

EOF;
    printf($text, $cmdName);
}

/**
 * Signal handler function.
 * @param signal The signal.
 */
function signalHandler($signal)
{
    global $sigTerm, $sigChld;

    switch ($signal) {
        case SIGINT:
            debug("SIGINT received ...\n");
            $sigTerm = true;
            break;
        case SIGTERM:
            debug("SIGTERM received ...\n");
            $sigTerm = true;
            break;
        case SIGCHLD:
            debug("SIGCHLD received ...\n");
            $sigChld = true;
            break;
        default:
            // Nothing to do here.
            break;
    }
}

/**
 * Process SIGCHLD signals.
 */
function handleSigChld()
{
    global $sigChld, $children;

    while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
        foreach ($children as $childk => $childv) {
            if ($childv !== $pid) {
                continue;
            }
            unset($children[$childk]);
            if (pcntl_wifexited($status)) {
                debug(
                    "Child (pid=%d) terminated with exit code %d\n",
                    $pid,
                    pcntl_wexitstatus($status)
                );
            } else {
                debug(
                    "Child (pid=%d) terminated with signal %d\n",
                    $pid,
                    pcntl_wtermsig($status)
                );
            }
            break;
        }
    }
    $sigChld = false;
}

/**
 * Kill all child processes.
 */
function killChld()
{
    global $children;

    foreach ($children as $childk => $childv) {
        if (posix_kill($childv, SIGTERM)) {
            debug("Send SIGTERM to child (pid=%d)\n", $childv);
        }
    }
    while (!empty($children)) {
        debug("Waiting for children to terminate ...\n");
        handleSigChld();
        usleep(1000);
    }
}

/**
 * Daemonize the application.
 * @see https://www.freedesktop.org/software/systemd/man/latest/daemon.html
 * @see http://stackoverflow.com/a/17955149
 * @see https://stackoverflow.com/questions/881388/what-is-the-reason-for-performing-a-double-fork-when-creating-a-daemon
 */
function daemonize()
{
    global $debug, $daemon, $pidFile, $stdIn, $stdOut, $stdErr;

    if (false === $daemon) {
        return;
    }

    // Check if PID file already exists and whether a daemon is already
    // running.
    if (file_exists($pidFile)) {
        $pid = file_get_contents($pidFile);
        if (true === posix_kill($pid, 0)) {
            error("Daemon already running (pid=%d)\n", $pid);
            exit(1);
        }
        \OMV\System\Os::remove($pidFile);
    }

    $pid = pcntl_fork();
    if ($pid == -1) {
        error("Failed to fork process\n");
        exit(1);
    } elseif ($pid) { // Parent process
        exit(0);
    }

    // Make the current process a session leader.
    if (0 > posix_setsid()) {
        error("Could not detach from terminal\n");
        exit(1);
    }

    // Ignore signals.
    pcntl_signal(SIGHUP, SIG_IGN);

    // If starting a process on the command line, the shell will become the
    // session leader of that command. To create a new process group with the
    // daemon as session leader it is necessary to fork a new process again.
    $pid = pcntl_fork();
    if ($pid == -1) {
        error("Failed to fork process\n");
        exit(1);
    } elseif ($pid) { // Parent process
        debug("Daemon process started (pid=%d)\n", $pid);
        // Exit parent process.
        exit(0);
    }

    // Change the file mode mask.
    umask(022);

    // Change the current working directory.
    if (false === chdir("/")) {
        error("Failed to change to root directory\n");
        exit(1);
    }

    // Create PID file.
    file_put_contents($pidFile, posix_getpid());
    chmod($pidFile, 0644);

    if (false === $debug) {
        // Close all of the standard file descriptors.
        if (is_resource(STDIN)) {
            fclose(STDIN);
        }
        if (is_resource(STDOUT)) {
            fclose(STDOUT);
        }
        if (is_resource(STDERR)) {
            fclose(STDERR);
        }
        // Create new standard file descriptors.
        $stdIn = fopen("/dev/null", "r");
        $stdOut = fopen("/dev/null", "w");
        $stdErr = fopen("/dev/null", "w");
    }
}

/**
 * Error function. Output message to system log and console in debug mode.
 * @param msg The error message.
 */
function error()
{
    global $debug;

    $args = func_get_args();
    $msg = array_shift($args);
    // Log the message in syslog.
    syslog(LOG_ALERT, vsprintf($msg, $args));
    // Print the message to STDOUT if debug mode is enabled.
    if (true === $debug) {
        // Append a new line if necessary.
        if ("\n" !== substr($msg, -1)) {
            $msg .= "\n";
        }
        // Print the message to STDOUT.
        vprintf($msg, $args);
    }
}

/**
 * Debug function. Output message to syslog or console in debug mode.
 * @param msg The debug message.
 */
function debug()
{
    global $debug, $daemon;

    $args = func_get_args();
    $msg = array_shift($args);
    if (true === $debug) {
        if (false === $daemon) {
            vprintf($msg, $args);
        } else {
            syslog(LOG_DEBUG, vsprintf($msg, $args));
        }
    }
}

/**
 * The error handler.
 */
function errorHandler($errno, $errstr, $errfile, $errline)
{
    switch ($errno) {
        case E_RECOVERABLE_ERROR:
            throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
            break;
        default:
            // Nothing to do here.
            break;
    }
    // Don't execute the PHP internal error handler.
    return true;
}

/**
 * The function for execution on shutdown.
 */
function shutdownHandler()
{
    // Check if there was a fatal error.
    $error = error_get_last();
    if (!is_null($error) && (E_ERROR == $error['type'])) {
        // Log fatal errors to syslog.
        error(
            "PHP Fatal error: %s in %s on line %d",
            $error['message'],
            $error['file'],
            $error['line']
        );
    }
}

///////////////////////////////////////////////////////////////////////////////
// Global variables.
///////////////////////////////////////////////////////////////////////////////

$cmdName = basename($argv[0]);
$sigTerm = false;
$sigChld = false;
$socket = null;
$maxConnections = 10;
$timeout = 1;
$debug = false;
$daemon = true;
$xdebug = false;
$pidFile = "/run/{$cmdName}.pid";
$children = [];

$stdIn = null;
$stdOut = null;
$stdErr = null;

///////////////////////////////////////////////////////////////////////////////
// Set the error and shutdown handler.
///////////////////////////////////////////////////////////////////////////////
set_error_handler("errorHandler");
register_shutdown_function("shutdownHandler");

///////////////////////////////////////////////////////////////////////////////
// Process command line arguments.
///////////////////////////////////////////////////////////////////////////////

// Check the command line arguments. Exit and display usage if
// necessary.
$cmdArgs = [
    "d::" => "debug::",
    "f::" => "foreground::",
    "h::" => "help::",
    "x::" => "xdebug::"
];
$options = getopt(implode("", array_keys($cmdArgs)), $cmdArgs);
foreach ($options as $optionk => $optionv) {
    switch ($optionk) {
        case "d":
        case "debug":
            $argc -= 1;
            $debug = true;
            break;
        case "f":
        case "foreground":
            $argc -= 1;
            $daemon = false;
            break;
        case "h":
        case "help":
            usage();
            exit(0);
            break;
        case "x":
        case "xdebug":
            $argc -= 1;
            $xdebug = true;
            break;
    }
}
if ($argc > 1) {
    print gettext("ERROR: Invalid number of arguments\n");
    usage();
    exit(1);
}

ini_set("max_execution_time", "0");
ini_set("max_input_time", "0");
set_time_limit(0);

// Open syslog, include the process ID and also send the log to
// standard error.
openlog($cmdName, LOG_PID | LOG_PERROR, LOG_USER);

// Change process name.
cli_set_process_title($cmdName);

///////////////////////////////////////////////////////////////////////////////
// Daemonize the application if running in daemon mode and initialize
// signal handlers.
///////////////////////////////////////////////////////////////////////////////
daemonize();

pcntl_signal(SIGINT, "signalHandler");
pcntl_signal(SIGTERM, "signalHandler");
pcntl_signal(SIGCHLD, "signalHandler");

///////////////////////////////////////////////////////////////////////////////
// Load additional include files. This can be used to setup various
// prerequirements.
///////////////////////////////////////////////////////////////////////////////
$dir = build_path(DIRECTORY_SEPARATOR, \OMV\Environment::get(
    "OMV_ENGINED_DIR"
), "inc");
foreach (listdir($dir, "inc") as $path) {
    require_once $path;
}

///////////////////////////////////////////////////////////////////////////////
// Include all RPC service classes.
///////////////////////////////////////////////////////////////////////////////
// Including additional include files here is a workaround because of a bug
// in sem_get(), see https://bugs.php.net/bug.php?id=62928 for more details.
// The workaround simply loads the include files using the sem_get function
// after the daemon process has been forked.
///////////////////////////////////////////////////////////////////////////////
$dir = build_path(DIRECTORY_SEPARATOR, \OMV\Environment::get(
    "OMV_ENGINED_DIR"
), "rpc");
foreach (listdir($dir, "inc") as $path) {
    require_once $path;
}
// Initialize the RPC sevices.
$rpcServiceMngr = &\OMV\Rpc\ServiceManager::getInstance();
$rpcServiceMngr->initializeServices();
if (true === $debug) {
    $rpcServiceMngr->dump();
}

///////////////////////////////////////////////////////////////////////////////
// Load all module classes.
///////////////////////////////////////////////////////////////////////////////
$dir = build_path(DIRECTORY_SEPARATOR, \OMV\Environment::get(
    "OMV_ENGINED_DIR"
), "module");
foreach (listdir($dir, "inc") as $path) {
    require_once $path;
}

///////////////////////////////////////////////////////////////////////////////
// Bind listeners.
///////////////////////////////////////////////////////////////////////////////
$moduleMngr = \OMV\Engine\Module\Manager::getInstance();
$modules = $moduleMngr->getModules();
$dispatcher = \OMV\Engine\Notify\Dispatcher::getInstance();
foreach ($modules as $module) {
    $dispatcher->addListener($module);
}
if (true === $debug) {
    $moduleMngr->dump();
}

///////////////////////////////////////////////////////////////////////////////
// Open the socket.
///////////////////////////////////////////////////////////////////////////////
if (false === ($socket = @socket_create(AF_UNIX, SOCK_STREAM, 0))) {
    error("Failed to create socket: %s\n", socket_strerror(
        socket_last_error()
    ));
    exit(1);
}
if (false === @socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1)) {
    error("Failed to set socket option: %s\n", socket_strerror(
        socket_last_error()
    ));
    exit(1);
}
// Unlink socket, even if it does not exist.
$socketAddress = \OMV\Environment::get("OMV_ENGINED_SO_ADDRESS");
if (false === \OMV\System\Os::remove($socketAddress)) {
    error("Failed to remove path '%s'\n", $socketAddress);
    exit(1);
}
if (false === @socket_bind($socket, $socketAddress)) {
    error("Failed to bind socket: %s\n", socket_strerror(
        socket_last_error()
    ));
    exit(1);
}
// Modify file permission to allow access for the members of the
// 'openmediavault-engined' group.
if (false === chgrp($socketAddress, \OMV\Environment::get(
    "OMV_ENGINED_SO_OWNERGROUP_NAME"
))) {
    error(
        "Failed to set file group to '%s' for '%s'",
        \OMV\Environment::get("OMV_ENGINED_SO_OWNERGROUP_NAME"),
        $socketAddress
    );
    exit(1);
}
if (false === chmod($socketAddress, 0770)) {
    error("Failed to set file mode to '%o' for '%s'", 0770, $socketAddress);
    exit(1);
}
if (false === @socket_listen($socket, $maxConnections)) {
    error("Failed to listen to socket: %s\n", socket_strerror(
        socket_last_error()
    ));
    exit(1);
}

///////////////////////////////////////////////////////////////////////////////
// Main loop. Wait for requests until SIGINT or SIGTERM has been received.
///////////////////////////////////////////////////////////////////////////////
while (false === $sigTerm) {
    // Call signal handlers for pending signals.
    pcntl_signal_dispatch();
    // Monitor socket.
    $read = [ $socket ];
    $write = null;
    $except = null;
    if (false === ($r = @socket_select($read, $write, $except, $timeout))) {
        $errCode = socket_last_error();
        if (SOCKET_EINTR == $errCode) {
            pcntl_signal_dispatch();
        } else {
            error("Failed to select socket: %s\n", socket_strerror($errCode));
            exit(1);
        }
    } elseif ($r > 0) {
        if (false === ($conn = @socket_accept($socket))) {
            error("Failed to accept socket: %s\n", socket_strerror(
                socket_last_error()
            ));
            exit(1);
        }

        ///////////////////////////////////////////////////////////////////////
        // Read the RPC request from the socket.
        ///////////////////////////////////////////////////////////////////////
        $request = "";
        while (true) {
            $data = @socket_read($conn, 4096, PHP_BINARY_READ);
            // Check for errors.
            if (false === $data) {
                @socket_close($conn);
                error(
                    "Failed to read from socket: %s\n",
                    socket_strerror(socket_last_error())
                );
                exit(1);
            }
            $request .= $data;
            // Abort if request is complete.
            if (empty($data) || "\0" == substr($request, -1)) {
                break;
            }
        }
        $request = substr($request, 0, -1); // Remove the EOF byte.

        ///////////////////////////////////////////////////////////////////////
        // Fork a child process to execute the RPC.
        ///////////////////////////////////////////////////////////////////////
        // Don't fork if we're in XDebug compatibility mode.
        if (true !== $xdebug) {
            $pid = pcntl_fork();
        }
        if ($pid == -1) {
            @socket_close($conn);
            error("Failed to create child process\n");
            exit(1);
        } elseif ($pid) { // Parent process
            $children[] = $pid;
        } else { // Child process
            debug("Child process forked (pid=%d)\n", posix_getpid());

            // Reset signal handlers to their default.
            if (false === $xdebug) {
                pcntl_signal(SIGINT, SIG_DFL);
                pcntl_signal(SIGTERM, SIG_DFL);
                pcntl_signal(SIGHUP, SIG_DFL);
                pcntl_signal(SIGCHLD, SIG_DFL);
            }

            // Load include file for every new child.
            // Note, if the configuration is dirty and has not been applied
            // until now, then the changes are still used. This is acceptable
            // for the moment because it only applies to the timezone.
            require_once("openmediavault/env.inc");

            $startTime = hrtime(true);

            try {
                // Decode JSON string to an associative array.
                // !!! Note !!!
                // Actually the JSON string should not be converted into an
                // associative array because the information is lost whether an
                // empty array was an object without properties or an empty array
                // before the conversion.
                // If it were done correctly this will break existing code in core
                // and all plugins, so the current implementation which is doing
                // the conversion to an associative array instead an PHP object
                // will be kept for compatibility reasons.
                if (null === ($request = json_decode_safe($request, true))) {
                    throw new \OMV\Exception(
                        "Failed to decode JSON string: %s",
                        json_last_error_msg()
                    );
                }

                ////////////////////////////////////////////////////////////////
                // Execute RPC.
                ////////////////////////////////////////////////////////////////
                debug(
                    "Executing RPC (service=%s, method=%s, params=%s, ".
                  "context=%s) ...\n",
                    $request['service'],
                    $request['method'],
                    json_encode_safe($request['params']),
                    json_encode_safe(
                        $request['context']
                    )
                );

                $response = \OMV\Rpc\Rpc::call(
                    $request['service'],
                    $request['method'],
                    $request['params'],
                    $request['context'],
                    \OMV\Rpc\Rpc::MODE_LOCAL
                );

                $response = json_encode_safe([
                    "response" => $response,
                    "error" => null
                ]);
            } catch (\Exception $e) {
                $response = json_encode_safe([
                    "response" => null,
                    "error" => [
                        "code" => $e->getCode(),
                        "message" => $e->getMessage(),
                        "trace" => $e->__toString(),
                        "http_status_code" =>
                            ($e instanceof \OMV\BaseException) ?
                            $e->getHttpStatusCode() : 500
                    ]
                ]);
            }

            debug(
                "RPC response (service=%s, method=%s, time=%.0fms): %s\n",
                $request['service'],
                $request['method'],
                (hrtime(true) - $startTime) / 1000000,
                $response
            );

            ///////////////////////////////////////////////////////////////////
            // Write RPC response.
            ///////////////////////////////////////////////////////////////////
            $response .= "\0"; // Append the EOF byte.
            if (false === @socket_write($conn, $response, strlen($response))) {
                @socket_close($conn);
                error(
                    "Failed to write to socket: %s\n",
                    socket_strerror(socket_last_error())
                );
                if (false === $xdebug) {
                    exit(1);
                }
            }
            // Close connection.
            @socket_close($conn);
            // Exit child process.
            if (false === $xdebug) {
                exit(0);
            }
        }
    }
    // Process SIGCHLD signal.
    if (true === $sigChld) {
        handleSigChld();
    }
}

///////////////////////////////////////////////////////////////////////////////
// Cleanup.
///////////////////////////////////////////////////////////////////////////////

killChld();

// Close and unlink socket.
@socket_close($socket);
\OMV\System\Os::remove($socketAddress);

// Unlink PID file (exists only when running in daemon mode).
\OMV\System\Os::remove($pidFile);

// Set exit code.
exit(0);
