%PDF- %PDF-
| Direktori : /proc/self/root/backups/router/usr/local/opnsense/mvc/app/library/OPNsense/Core/ |
| Current File : //proc/self/root/backups/router/usr/local/opnsense/mvc/app/library/OPNsense/Core/Config.php |
<?php
/*
* Copyright (C) 2015-2023 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Core;
use OPNsense\Core\AppConfig;
use OPNsense\Core\Syslog;
/**
* Class Config provides access to systems config xml
* @package Core
*/
class Config extends Singleton
{
/**
* config file location ( path + name )
* @var string
*/
private $config_file = "";
/**
* config file handle
* @var null|file
*/
private $config_file_handle = null;
/**
* SimpleXML type reference to config
* @var SimpleXML
*/
private $simplexml = null;
/**
* status field: file is locked (Exclusive)
* @var bool
*/
private $statusIsLocked = false;
/**
* status field: valid config loaded
* @var bool
*/
private $statusIsValid = false;
/**
* @var array list of revision relevant data
*/
private $revisionContext = [];
/**
* @var float current modification time of our known config
*/
private $mtime = 0;
/**
* return last known status of this configuration (valid or not)
* @return bool return (last known) status of this configuration
*/
public function isValid()
{
return $this->statusIsValid;
}
/**
* check if array is a sequential type.
* @param &array $arrayData array structure to check
* @return bool
*/
private function isArraySequential(&$arrayData)
{
return is_array($arrayData) && ctype_digit(implode('', array_keys($arrayData)));
}
/**
* serialize xml to array structure (backwards compatibility mode)
* @param null|array $forceList force specific tags to be contained in a list.
* @param DOMNode $node node to read
* @return string|array converted node data
* @throws ConfigException when config could not be parsed
*/
public function toArray($forceList = null, $node = null)
{
$result = array();
$this->checkvalid();
// root node
if ($node == null) {
$node = $this->simplexml;
}
// copy attributes to @attribute key item
foreach ($node->attributes() as $AttrKey => $AttrValue) {
if (!isset($result['@attributes'])) {
$result['@attributes'] = [];
}
$result['@attributes'][$AttrKey] = (string)$AttrValue;
}
// iterate xml children
foreach ($node->children() as $xmlNode) {
$xmlNodeName = $xmlNode->getName();
if ($xmlNode->count() > 0) {
$tmpNode = $this->toArray($forceList, $xmlNode);
if (isset($result[$xmlNodeName])) {
$old_content = $result[$xmlNodeName];
// check if array content is associative, move items to new list
// (handles first item of specific type)
if (!$this->isArraySequential($old_content)) {
$result[$xmlNodeName] = array();
$result[$xmlNodeName][] = $old_content;
}
$result[$xmlNodeName][] = $tmpNode;
} elseif (isset($forceList[$xmlNodeName])) {
// force tag in an array
$result[$xmlNodeName] = array();
$result[$xmlNodeName][] = $tmpNode;
} else {
$result[$xmlNodeName] = $tmpNode;
}
} else {
if (isset($result[$xmlNodeName])) {
// repeating item
if (!is_array($result[$xmlNodeName])) {
// move first item into list
$tmp = $result[$xmlNodeName];
$result[$xmlNodeName] = array();
$result[$xmlNodeName][] = $tmp;
}
$result[$xmlNodeName][] = (string)$xmlNode;
} else {
// single content item
if (isset($forceList[$xmlNodeName])) {
$result[$xmlNodeName] = array();
if ((string)$xmlNode != null && trim((string)$xmlNode) !== '') {
$result[$xmlNodeName][] = (string)$xmlNode;
}
} else {
$result[$xmlNodeName] = (string)$xmlNode;
}
// copy attributes to xzy@attribute key item
foreach ($xmlNode->attributes() as $AttrKey => $AttrValue) {
if (!isset($result["{$xmlNodeName}@attributes"])) {
$result["{$xmlNodeName}@attributes"] = [];
}
$result["{$xmlNodeName}@attributes"][$AttrKey] = (string)$AttrValue;
}
}
}
}
return $result;
}
/**
* convert an arbitrary config xml file to an array
* @param $filename config xml filename to parse
* @param null $forceList items to treat as list
* @return array interpretation of config file
* @throws ConfigException when config could not be parsed
*/
public function toArrayFromFile($filename, $forceList = null)
{
$fp = fopen($filename, "r");
$xml = $this->loadFromStream($fp);
fclose($fp);
return $this->toArray($forceList, $xml);
}
/**
* update (reset) config with array structure (backwards compatibility mode)
* @param array $source source array structure
* @param null $node simplexml node
* @param null|string $parentTagName
* @throws ConfigException when config could not be parsed
*/
public function fromArray(array $source, $node = null, $parentTagName = null)
{
$this->checkvalid();
// root node
if ($node == null) {
$this->simplexml = simplexml_load_string('<' . $this->simplexml[0]->getName() . '/>');
$node = $this->simplexml;
// invalidate object on warnings/errors (prevent save from happening)
set_error_handler(
function ($errno, $errstr, $errfile, $errline) {
syslog(LOG_ERR, sprintf(
"Config serialize error [%d] %s @ %s : %s",
$errno,
$errstr,
$errfile,
$errline
));
$this->statusIsValid = false;
}
);
}
foreach ($source as $itemKey => $itemValue) {
if (
(is_bool($itemValue) && $itemValue == false) || // skip empty booleans
$itemKey === null || trim($itemKey) === "" // skip empty tag names
) {
continue;
}
if ($itemKey === '@attributes') {
// copy xml attributes
foreach ($itemValue as $attrKey => $attrValue) {
if (isset($node->attributes()[$attrKey])) {
$node->attributes()->$attrKey = $attrValue;
} else {
$node->addAttribute($attrKey, $attrValue);
}
}
continue;
} elseif (strstr($itemKey, '@attributes') !== false) {
$origname = str_replace('@attributes', '', $itemKey);
if (count($node->$origname)) {
// copy xml attributes
foreach ($itemValue as $attrKey => $attrValue) {
if (isset($node->$origname->attributes()[$attrKey])) {
$node->$origname->attributes()->$attrKey = $attrValue;
} else {
$node->$origname->addAttribute($attrKey, $attrValue);
}
}
}
continue;
} elseif (is_numeric($itemKey)) {
// recurring tag (content), use parent tagname.
$childNode = $node->addChild($parentTagName);
} elseif ($this->isArraySequential($itemValue)) {
// recurring tag, skip placeholder.
$childNode = $node;
} else {
// add new child
$childNode = $node->addChild($itemKey);
}
// set content, propagate container items.
if (is_array($itemValue)) {
$this->fromArray($itemValue, $childNode, $itemKey);
} else {
$childNode[0] = $itemValue;
}
}
// restore error handling on initial call
if ($node == $this->simplexml) {
restore_error_handler();
}
}
/**
* check if there's a valid config loaded, throws an error if config isn't valid.
* @throws ConfigException when config could not be parsed
*/
private function checkvalid()
{
if (!$this->statusIsValid) {
throw new ConfigException('no valid config loaded');
}
}
/**
* Execute a xpath expression on config.xml (full DOM implementation)
* @param string $query xpath expression
* @return \DOMNodeList nodes
* @throws ConfigException when config could not be parsed
*/
public function xpath($query)
{
$this->checkvalid();
$configxml = dom_import_simplexml($this->simplexml);
$dom = new \DOMDocument('1.0');
$dom_sxe = $dom->importNode($configxml, true);
$dom->appendChild($dom_sxe);
$xpath = new \DOMXPath($dom);
return $xpath->query($query);
}
/**
* object representation of xml document via simplexml, references the same underlying model
* @return SimpleXML configuration object
* @throws ConfigException when config could not be parsed
*/
public function object()
{
$this->checkvalid();
return $this->simplexml;
}
/**
* init new config object, try to load current configuration
* (executed via Singleton)
*/
protected function init()
{
$this->statusIsLocked = false;
$this->config_file = (new AppConfig())->globals->config_path . "config.xml";
try {
$this->load();
} catch (\Exception $e) {
$this->simplexml = null;
// there was an issue with loading the config, try to restore the last backup
$backups = $this->getBackups();
$logger = new Syslog('audit', null, LOG_LOCAL5);
if (count($backups) > 0) {
// load last backup
$logger->error(gettext('No valid config.xml found, attempting last known config restore.'));
foreach ($backups as $backup) {
try {
$this->restoreBackup($backup);
$logger->error("restored " . $backup);
return;
} catch (ConfigException $e) {
$logger->error("failed restoring " . $backup);
}
}
}
// in case there are no backups, restore defaults.
$logger->error(gettext('No valid config.xml found, attempting to restore factory config.'));
$this->restoreBackup('/usr/local/etc/config.xml');
}
}
/**
* force a re-init of the object and reload the object
*/
public function forceReload()
{
if ($this->config_file_handle !== null) {
fclose($this->config_file_handle);
$this->config_file_handle = null;
}
$this->init();
}
/**
* load xml config from file handle
* @param file $fp config xml source
* @return \SimpleXMLElement root node
* @throws ConfigException when config could not be parsed
*/
private function loadFromStream($fp)
{
/**
* load data from stream in shared mode unless no valid xml data is returned
* (in which case the writer holds a lock and we should wait for it [LOCK_SH])
*/
foreach ([LOCK_SH | LOCK_NB, LOCK_SH] as $idx => $mode) {
flock($fp, $mode);
fseek($fp, 0);
$xml = trim(stream_get_contents($fp));
set_error_handler(
function () {
// reset simplexml pointer on parse error.
$result = null;
}
);
$result = simplexml_load_string($xml);
restore_error_handler();
if (!$this->statusIsLocked) {
flock($fp, LOCK_UN);
}
if ($result != null) {
break; // successful load
}
}
if ($result == null) {
if (empty($xml)) {
throw new ConfigException('empty file');
}
throw new ConfigException("invalid config xml");
} else {
return $result;
}
}
/**
* Load config file
* @throws ConfigException
*/
private function load()
{
$this->simplexml = null;
$this->statusIsValid = false;
// exception handling
if (!file_exists($this->config_file)) {
throw new ConfigException('file not found');
}
if (!is_resource($this->config_file_handle)) {
if (is_writable($this->config_file)) {
$this->config_file_handle = fopen($this->config_file, "r+");
} else {
// open in read-only mode
$this->config_file_handle = fopen($this->config_file, "r");
}
}
$this->simplexml = $this->loadFromStream($this->config_file_handle);
$this->mtime = fstat($this->config_file_handle)['mtime'];
$this->statusIsValid = true;
}
/**
* return xml text representation of this config
* @return mixed string interpretation of this object
*/
public function __toString()
{
// reformat XML (pretty print)
$dom = new \DOMDocument('1.0');
// make sure our root element is always called "opnsense"
$root = $dom->createElement('opnsense');
$dom->appendChild($root);
foreach ($this->simplexml as $node) {
$domNode = dom_import_simplexml($node);
$domNode = $root->ownerDocument->importNode($domNode, true);
$root->appendChild($domNode);
}
$dom->formatOutput = true;
$dom->preserveWhiteSpace = false;
$dom->loadXML($dom->saveXML());
return $dom->saveXML();
}
/**
* @return array revision key/values
*/
public function getRevisionContext()
{
$revision = $this->revisionContext;
if (!empty($_SESSION["Username"])) {
$revision['username'] = $_SESSION["Username"];
} elseif (!isset($revision['username'])) {
$revision['username'] = '(system)';
}
if (!empty($_SERVER['REMOTE_ADDR']) && strpos($revision['username'], '@') === false) {
$revision['username'] .= "@" . $_SERVER['REMOTE_ADDR'];
}
$revision['description'] = sprintf(
gettext('%s made changes'),
!empty($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : $_SERVER['SCRIPT_NAME']
);
// append session revision tags when supplied (keys start with xrevision_)
if (!empty($_SESSION) && is_array($_SESSION)) {
foreach ($_SESSION as $key => $value) {
if (stripos($key, 'xrevision_') === 0 && !isset($revision[substr($key, 10)])) {
$revision[substr($key, 10)] = $value;
}
}
}
$revision['time'] = microtime(true);
return $revision;
}
/**
* set revision payload
* @param array revision payload
*/
public function setRevisionContext($ctx)
{
if (is_array($ctx)) {
$this->revisionContext = $ctx;
return true;
}
return false;
}
/**
* update config revision information (ROOT.revision tag)
* @param array|null $revision revision tag (associative array)
* @param \SimpleXMLElement|null pass trough xml node
* @return array revision data
*/
private function updateRevision($revision, $node = null, $timestamp = null)
{
/* If revision info is not provided, create one. $revision is used for recursion */
if (!is_array($revision)) {
$revision = $this->getRevisionContext();
}
if ($node == null) {
if (!isset($this->simplexml->revision)) {
$target = $this->simplexml->addChild("revision");
} else {
$target = $this->simplexml->revision;
foreach (iterator_to_array($target->children()) as $child) {
unset($target->{$child->getName()});
}
}
} else {
$target = $node;
}
array_walk($revision, function ($value, $key) use (&$target) {
$node = $target->addChild($key);
if (is_array($value)) {
$this->updateRevision($value, $node);
} else {
$node[0] = $value;
}
});
return $revision;
}
/**
* send config change to audit log including the context we currently know of.
* @param string $backup_filename new backup filename
* @param array $revision revision adata used
*/
private function auditLogChange($backup_filename, $revision)
{
openlog("audit", LOG_ODELAY, LOG_AUTH);
syslog(LOG_NOTICE, sprintf(
"user %s%s changed configuration to %s in %s %s",
$revision['username'],
!empty($revision['impersonated_by']) ? sprintf(" (%s)", $revision['impersonated_by']) : '',
$backup_filename,
!empty($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : $_SERVER['SCRIPT_NAME'],
$revision['description'] ?? ''
));
}
/**
* backup current config
* @return string target filename
*/
public function backup($timestamp = null)
{
$timestamp = $timestamp ? $timestamp : microtime(true);
$target_dir = dirname($this->config_file) . "/backup/";
if (!file_exists($target_dir)) {
// create backup directory if it is missing
mkdir($target_dir);
chmod($target_dir, 0750);
}
if (file_exists($target_dir . "config-" . $timestamp . ".xml")) {
// The new target backup filename shouldn't exists, because of the use of microtime.
// in the unlikely event that we can process events too fast for microtime(), suffix with a more
// precise timestamp to ensure we can't miss a backup
$target_filename = "config-" . $timestamp . "_" . hrtime()[1] . ".xml";
} else {
$target_filename = "config-" . $timestamp . ".xml";
}
File::file_put_contents($target_dir . $target_filename, file_get_contents($this->config_file), 0640);
return $target_dir . $target_filename;
}
/**
* return list of config backups
* @param bool $fetchRevisionInfo fetch revision information and return detailed information. (key/value)
* @return array list of backups
* @throws ConfigException when config could not be parsed
*/
public function getBackups($fetchRevisionInfo = false)
{
$target_dir = dirname($this->config_file) . "/backup/";
if (file_exists($target_dir)) {
$backups = glob($target_dir . "config*.xml");
// sort by date (descending)
rsort($backups);
if (!$fetchRevisionInfo) {
return $backups;
} else {
$result = array ();
foreach ($backups as $filename) {
// try to read backup info from xml
$xmlNode = @simplexml_load_file($filename, "SimpleXMLElement", LIBXML_NOERROR | LIBXML_ERR_NONE);
if (isset($xmlNode->revision)) {
$result[$filename] = $this->toArray(null, $xmlNode->revision);
$result[$filename]['version'] = (string)$xmlNode->version;
$result[$filename]['filesize'] = filesize($filename);
}
}
return $result;
}
}
return array();
}
/**
* Overwrite current config with contents of new file
* @param $filename
*/
private function overwrite($filename)
{
$fhandle = fopen($this->config_file, "a+e");
if (flock($fhandle, LOCK_EX)) {
fseek($fhandle, 0);
chmod($this->config_file, 0640);
ftruncate($fhandle, 0);
fwrite($fhandle, file_get_contents($filename));
fclose($fhandle);
}
}
/**
* restore and load backup config
* @param $filename
* @return bool restored, valid config loaded
* @throws ConfigException no config loaded
*/
public function restoreBackup($filename)
{
if ($this->isValid()) {
// if current config is valid,
$simplexml = $this->simplexml;
$config_file_handle = $this->config_file_handle;
try {
// try to restore config
$this->overwrite($filename);
$this->load();
return true;
} catch (ConfigException $e) {
// copy / load failed, restore previous version
$this->simplexml = $simplexml;
$this->config_file_handle = $config_file_handle;
$this->statusIsValid = true;
$this->save(null, true);
return false;
}
} else {
// we don't have a valid config loaded, just copy and load the requested one
$this->overwrite($filename);
$this->load();
return true;
}
}
/**
* @return int number of backups to keep
*/
public function backupCount()
{
if (
$this->statusIsValid && isset($this->simplexml->system->backupcount)
&& intval($this->simplexml->system->backupcount) >= 0
) {
return intval($this->simplexml->system->backupcount);
} else {
return 100;
}
}
/**
* @return bool when config file underneath has changed without our instance being aware of it
*/
public function hasChanged()
{
return $this->mtime != fstat($this->config_file_handle)['mtime'];
}
/**
* return backup file path if revision exists
* @param $revision revision timestamp (e.g. 1583766095.9337)
* @return bool|string filename when available or false when not found
*/
public function getBackupFilename($revision)
{
$tmp = preg_replace("/[^0-9.]/", "", $revision);
$bckfilename = dirname($this->config_file) . "/backup/config-{$tmp}.xml";
if (is_file($bckfilename)) {
return $bckfilename;
} else {
return false;
}
}
/**
* remove old backups
*/
private function cleanupBackups()
{
$revisions = $this->backupCount();
$cnt = 1;
foreach ($this->getBackups() as $filename) {
if ($cnt > $revisions) {
@unlink($filename);
}
++$cnt;
}
}
/**
* save config to filesystem
* @param array|null $revision revision tag (associative array)
* @param bool $backup do not backup current config
* @throws ConfigException when config could not be parsed
*/
public function save($revision = null, $backup = true)
{
$this->checkvalid();
$time = microtime(true);
// update revision information ROOT.revision tag, align timestamp to backup output
$revision = $this->updateRevision($revision, null, $time);
if ($this->config_file_handle !== null) {
if (flock($this->config_file_handle, LOCK_EX)) {
fseek($this->config_file_handle, 0);
chmod($this->config_file, 0640);
ftruncate($this->config_file_handle, 0);
fwrite($this->config_file_handle, (string)$this);
// flush, unlock, but keep the handle open
fflush($this->config_file_handle);
$backup_filename = $backup ? $this->backup($time) : null;
if ($backup_filename) {
$this->auditLogChange($backup_filename, $revision);
// use syslog to trigger a new configd event, which should signal a syshook config (in batch).
// Although we include the backup filename, the event handler is responsible to determine the
// last processed event itself. (it's merely added for debug purposes)
$logger = new Syslog('config', null, LOG_LOCAL5);
$logger->info("config-event: new_config " . $backup_filename);
}
flock($this->config_file_handle, LOCK_UN);
$this->mtime = fstat($this->config_file_handle)['mtime'];
} else {
throw new ConfigException("Unable to lock config");
}
}
/* cleanup backups */
$this->cleanupBackups();
}
/**
* cleanup, close file handle
*/
public function __destruct()
{
if ($this->config_file_handle !== null) {
fclose($this->config_file_handle);
}
}
/**
* lock configuration
* @param boolean $reload reload config from open file handle to enforce synchronicity, when not already locked
*/
public function lock($reload = true)
{
if ($this->config_file_handle !== null) {
flock($this->config_file_handle, LOCK_EX);
$do_reload = $reload && !$this->statusIsLocked;
$this->statusIsLocked = true;
if ($do_reload) {
/* Only lock when the exclusive lock wasn't ours yet. */
$this->load();
}
}
return $this;
}
/**
* unlock configuration
*/
public function unlock()
{
if (is_resource($this->config_file_handle)) {
flock($this->config_file_handle, LOCK_UN);
$this->statusIsLocked = false;
}
return $this;
}
}