sysutils/nextcloud-backup: sync with master

This commit is contained in:
Franco Fichtner
2026-03-03 13:49:44 +01:00
parent 96073bc18d
commit 9d31d4bd16
4 changed files with 430 additions and 65 deletions

View File

@@ -1,5 +1,5 @@
PLUGIN_NAME= nextcloud-backup
PLUGIN_VERSION= 1.1
PLUGIN_VERSION= 1.2
PLUGIN_COMMENT= Track config changes using NextCloud
.include "../../Mk/plugins.mk"

View File

@@ -6,6 +6,14 @@ strongly advise to not use a public service to send backups to.
Plugin Changelog
================
1.2
* Add option to upload to one file each day instead of syncing the contents of /conf/backup
* Add support for having backing up to a subdirectory instead of the root backup dir
* Skip non-files when enumerating local entries to backup
* Only back up when local file is newer than remote
* Switch to UpdateOnlyTextField from TextField
1.1
* Back up the content of /conf/backup (contributed by Daniel Lysfjor)

View File

@@ -84,7 +84,34 @@ class Nextcloud extends Base implements IBackupProvider
"type" => "text",
"label" => gettext("Directory Name without leading slash, starting from user's root"),
"value" => 'OPNsense-Backup'
)
),
array(
"name" => "strategy",
"type" => "checkbox",
"help" => gettext("Select this one to back up to a file named config-YYYYMMDD instead of syncing contents of /conf/backup"),
"label" => gettext("Daily file instead of sync all"),
),
array(
"name" => "addhostname",
"type" => "checkbox",
"label" => gettext("Backup to directory named after hostname"),
"help" => gettext("Create subdirectory under backupdir for this host"),
"value" => null
),
array(
"name" => "numdays",
"type" => "text",
"label" => gettext("Number of days worth of backups to keep"),
"help" => gettext("This works in collaboration with Number of backups below, the one with the oldest/most will win"),
"value" => null
),
array(
"name" => "numbackups",
"type" => "text",
"label" => gettext("Number of backups to keep"),
"help" => gettext("This works in collaboration with Number of days above, the one with the oldest/most will win"),
"value" => null
),
);
$nextcloud = new NextcloudSettings();
foreach ($fields as &$field) {
@@ -121,6 +148,302 @@ class Nextcloud extends Base implements IBackupProvider
return $validation_messages;
}
/**
* check remote file last modified tag
* @param string $remote_filename filename to check on server
* @param string $username username for login to server
* @param string $password password for authentication
* @return int unix timestamp or 0 if errors occour
*/
public function get_remote_file_lastmodified(
$remote_filename,
$username,
$password
) {
$reply = $this->curl_request_nothrow($remote_filename, $username, $password, 'PROPFIND', 'Cannot get remote fileinfo');
$http_code = $reply['info']['http_code'];
if ($http_code >= 200 && $http_code < 300) {
$xml_data = $reply['response'];
if ($xml_data == null) {
syslog(LOG_ERR, 'Data was NULL');
return 0;
}
$xml_data = str_replace(['<d:', '</d:'], ['<', '</'], $xml_data);
$xml = simplexml_load_string($xml_data);
foreach ($xml->children() as $response) {
if ($response->getName() == 'response') {
$lastmodifiedstr = $response->propstat->prop->getlastmodified;
$filedate = strtotime($lastmodifiedstr);
return $filedate;
}
}
}
return 0;
}
/**
* backup with strategy 0 (copy everything in /conf/backup/ to $backupdir/)
* @param string $internal_username the returnvalue from $this->getInternalUsername
* @param string $username
* @param string $password
* @param string $url server protocol and hostname
* @param string $backupdir
* @param string $crypto_password
*/
public function backupstrat_zero(
$internal_username,
$username,
$password,
$url,
$backupdir,
$crypto_password
) {
// Get list of files from local backup system
$local_files = array();
$tmp_local_files = scandir('/conf/backup/');
// Remove '.' and '..', skip directories
foreach ($tmp_local_files as $tmp_local_file) {
if ($tmp_local_file === '.' || $tmp_local_file === '..') {
continue;
}
if (!is_file("/conf/backup/" . $tmp_local_file)) {
continue;
}
$local_files[] = $tmp_local_file;
}
// Get list of filenames (without path) on remote location
$remote_files = array();
$tmp_remote_files = $this->listfiles($url, $username, $password, $internal_username, "/$backupdir/", false);
foreach ($tmp_remote_files as $tmp_remote_file) {
$remote_files[] = pathinfo($tmp_remote_file)['basename'];
}
$uploaded_files = array();
// Loop over each local file,
// see if it's in $remote_files,
// if not, optionally encrypt, and upload
foreach ($local_files as $file_to_upload) {
if (!in_array($file_to_upload, $remote_files)) {
$confdata = file_get_contents("/conf/backup/$file_to_upload");
if (!empty($crypto_password)) {
$confdata = $this->encrypt($confdata, $crypto_password);
}
try {
$this->upload_file_content(
$url,
$username,
$password,
$internal_username,
$backupdir,
$file_to_upload,
$confdata
);
$uploaded_files[] = $file_to_upload;
} catch (\Exception $e) {
return $uploaded_files;
}
}
}
return $uploaded_files;
}
/**
* backup with strategy 1 (copy /conf/config.xml to $backupdir/conf-YYYYMMDD.xml)
* @param string $internal_username the returnvalue from $this->getInternalUsername
* @param string $username
* @param string $password
* @param string url server protocol and hostname
* @param string $backupdir
* @param string $crypto_password
*/
public function backupstrat_one(
$internal_username,
$username,
$password,
$url,
$backupdir,
$crypto_password
) {
$confdata = file_get_contents('/conf/config.xml');
$mdate = filemtime('/conf/config.xml');
$datestring = date('Ymd', $mdate);
$target_filename = 'config-' . $datestring . '.xml';
// Find the same filename @ remote
$remote_filename = $url . '/remote.php/dav/files/' . $internal_username . '/' . $backupdir . '/' . $target_filename;
$remote_file_date = $this->get_remote_file_lastmodified($remote_filename, $username, $password);
if ($remote_file_date >= $mdate) {
return array();
}
// Optionally encrypt
if (!empty($crypto_password)) {
$confdata = $this->encrypt($confdata, $crypto_password);
}
// Finally, upload some data
try {
$this->upload_file_content(
$url,
$username,
$password,
$internal_username,
$backupdir,
$target_filename,
$confdata
);
return array($backupdir . '/' . $target_filename);
} catch (\Exception $e) {
syslog(LOG_ERR, 'Backup to ' . $url . ' failed: ' . $e);
return array();
}
}
/**
* Get timestamp value from filename in list
* @param $filelist array of files in remote location
* @return array($filedata => $filename)
*/
public function get_filelist_dates($filelist)
{
// Save as associative array
// key = lastmodified
// value = filename
$files = array();
foreach ($filelist as $target_filename) {
// Find suggested creation date
// Base this on the filename. Either it is a unix timestamp, or it should be YYYYMMDD
// Either way, it's the part between "config-" and ".xml"
$filestr_no_xml = explode(".xml", $target_filename)[0];
$filedatestr = intval(explode("-", $filestr_no_xml)[1]);
if (($filedate = strtotime($filedatestr)) === false) {
// Cannot convert string to time.. probably already a unix-timestamp
// Try to convert with date()
$date = date(DATE_ATOM, $filedatestr);
// Then to a UNIX timestamp again
$maybedate = strtotime($date);
if ($maybedate === $filedatestr) {
// They represent the same time, this is good
// Just copy the intval() and be done with this
$filedate = $filedatestr;
}
}
if ($filedate) {
$files[(string)$filedate] = $target_filename;
} else {
syslog(LOG_ERR, "Skipping file " . $target_filename . ", cannot determine date");
}
}
ksort($files);
return $files;
}
/**
* housekeeping
* @param $internal_username returnvalue from $this->getInternalUsername
* @param $username
* @param $password
* @param $url protocol and hostname of server
* @param $backupdir directory to operate in
* @param $keep_days number of days to keep backups for
* @param $keep_num number of backups to keep
*/
public function retention(
$internal_username,
$username,
$password,
$url,
$backupdir,
$keep_days,
$keep_num
) {
// Are we configured to run at all?
// Short circuit in case none of our options are set
if (!strlen($keep_days) && !strlen($keep_num)) {
return;
}
// Get list of filenames (without path) on remote location
$remote_files = array();
$tmp_remote_files = $this->listfiles($url, $username, $password, $internal_username, "/$backupdir/", false);
foreach ($tmp_remote_files as $tmp_remote_file) {
if (!($tmp_remote_file === "")) {
if (!($tmp_remote_file == "/$backupdir/")) {
// No idea why the root directory is in the list..
$remote_files[] = pathinfo($tmp_remote_file)['basename'];
}
}
}
$num_remote_files = count($remote_files);
// Short-circuit, if too few files, no need to check no more
if (strlen($keep_num)) {
if ($keep_num > $num_remote_files) {
return;
}
}
$date = new \DateTime();
$files = $this->get_filelist_dates($remote_files);
if (strlen($keep_days)) {
// Admin has specified number of days to keep
$dateinterval = \DateInterval::createFromDateString($keep_days . " day");
$target_timestamp = date_sub($date, $dateinterval)->format('U');
// $files is an associative array with key=creation_time, value=filename
// should be sorted by ksort, hopefully that is a numerical sort:)
$new_files = array();
$old_files = array();
foreach (array_keys($files) as $file_timestamp) {
if ($file_timestamp > $target_timestamp) {
$new_files[(string)$file_timestamp] = $files[$file_timestamp];
} else {
// file is "old", aka ripe for deletion
$old_files[(string)$file_timestamp] = $files[$file_timestamp];
}
}
if (strlen($keep_num)) {
$num_new_files = count($new_files);
if ($num_new_files < $keep_num) {
// Not enough new files to satisfy $keep_num
$missing_num = $keep_num - $num_new_files;
// Can we slice some files from the $old_files list to satisfy $keep_num?
$total_files = count($files);
if ($total_files >= $keep_num) {
// Yes, we can
$tmp_files = array_slice($old_files, 0, $missing_num * -1);
foreach (array_keys($tmp_files) as $filetodelete) {
$this->delete_file($url, $username, $password, $internal_username, $backupdir, $tmp_files[$filetodelete]);
}
}
// No, we can't. Keep all things as is
} else {
// We have more new files than what we need to satisfy $keep_num
foreach (array_keys($old_files) as $filetodelete) {
$this->delete_file($url, $username, $password, $internal_username, $backupdir, $old_files[$filetodelete]);
}
// We do not delete files from new_files, as they are covered by the $keep_days
}
} else {
// We have not been told to keep N items,
// delete everything in $old_files
foreach (array_keys($old_files) as $filetodelete) {
$this->delete_file($url, $username, $password, $internal_username, $backupdir, $old_files[$filetodelete]);
}
}
} else {
// No $keep_days specified
if (strlen($keep_num)) {
// keep_num is some number
// Delete filenames based on their creation time
if (count($files) > $keep_num) {
$tmp_files = array_slice($files, 0, $keep_num * -1);
foreach (array_keys($tmp_files) as $filetodelete) {
$this->delete_file($url, $username, $password, $internal_username, $backupdir, $tmp_files[$filetodelete]);
}
}
}
}
}
/**
* perform backup
* @return array filelist
@@ -129,15 +452,24 @@ class Nextcloud extends Base implements IBackupProvider
*/
public function backup()
{
date_default_timezone_set('UTC');
$cnf = Config::getInstance();
$nextcloud = new NextcloudSettings();
if ($cnf->isValid() && !empty((string)$nextcloud->enabled)) {
$config = $cnf->object();
$url = (string)$nextcloud->url;
$username = (string)$nextcloud->user;
$password = (string)$nextcloud->password;
$backupdir = (string)$nextcloud->backupdir;
$crypto_password = (string)$nextcloud->password_encryption;
$url = $nextcloud->url->getValue();
$username = $nextcloud->user->getValue();
$password = $nextcloud->password->getValue();
$backupdir = $nextcloud->backupdir->getValue();
$crypto_password = $nextcloud->password_encryption->getValue();
$strategy = $nextcloud->strategy->getValue();
// Strategy 0 = Sync /conf/backup
// Strategy 1 = Copy /conf/config.xml to $backupdir/conf-YYYYMMDD.xml
$keep_days = $nextcloud->numdays->getValue();
$keep_num = $nextcloud->numbackups->getValue();
if ($nextcloud->addhostname->isEqual('1')) {
$backupdir .= "/" . gethostname();
}
// Check if destination directory exists, create (full path) if not
try {
@@ -147,53 +479,13 @@ class Nextcloud extends Base implements IBackupProvider
return array();
}
// Get list of files from local backup system
$local_files = array();
$tmp_local_files = scandir('/conf/backup/');
// Remove '.' and '..'
foreach ($tmp_local_files as $tmp_local_file) {
if ($tmp_local_file === '.' || $tmp_local_file === '..') {
continue;
}
$local_files[] = $tmp_local_file;
if ($strategy) {
$list_of_files = $this->backupstrat_one($internal_username, $username, $password, $url, $backupdir, $crypto_password);
} else {
$list_of_files = $this->backupstrat_zero($internal_username, $username, $password, $url, $backupdir, $crypto_password);
}
// Get list of filenames (without path) on remote location
$remote_files = array();
$tmp_remote_files = $this->listfiles($url, $username, $password, $internal_username, "/$backupdir/", false);
foreach ($tmp_remote_files as $tmp_remote_file) {
$remote_files[] = pathinfo($tmp_remote_file)['basename'];
}
$uploaded_files = array();
// Loop over each local file,
// see if it's in $remote_files,
// if not, optionally encrypt, and upload
foreach ($local_files as $file_to_upload) {
if (!in_array($file_to_upload, $remote_files)) {
$confdata = file_get_contents("/conf/backup/$file_to_upload");
if (!empty($crypto_password)) {
$confdata = $this->encrypt($confdata, $crypto_password);
}
try {
$this->upload_file_content(
$url,
$username,
$password,
$internal_username,
$backupdir,
$file_to_upload,
$confdata
);
$uploaded_files[] = $file_to_upload;
} catch (\Exception $e) {
return $uploaded_files;
}
}
}
return $uploaded_files;
$this->retention($internal_username, $username, $password, $url, $backupdir, $keep_days, $keep_num);
return $list_of_files;
}
}
@@ -244,6 +536,7 @@ class Nextcloud extends Base implements IBackupProvider
* @param string $url remote location
* @param string $username remote user
* @param string $password password to use
* @param string $internal_username UUID
* @param string $backupdir remote directory
* @param string $filename filename to use
* @param string $local_file_content contents to save
@@ -251,14 +544,44 @@ class Nextcloud extends Base implements IBackupProvider
*/
public function upload_file_content($url, $username, $password, $internal_username, $backupdir, $filename, $local_file_content)
{
$this->curl_request(
$url . "/remote.php/dav/files/$internal_username/$backupdir/$filename",
$url = $url . "/remote.php/dav/files/$internal_username/$backupdir/$filename";
$reply = $this->curl_request_nothrow(
$url,
$username,
$password,
'PUT',
'cannot execute PUT',
$local_file_content
);
$http_code = $reply['info']['http_code'];
// Accepted http codes for upload is 200-299
if (!($http_code >= 200 && $http_code < 300)) {
syslog(LOG_ERR, 'Could not PUT ' . $url);
throw new \Exception();
}
}
/**
* delete a file
* @param string $url remote location
* @param string $username remote user
* @param string $password password to use
* @param strign $internal_username UUID
* @param string $backupdir remote directory
* @param string $filename filename to use
*/
public function delete_file($url, $username, $password, $internal_username, $backupdir, $filename)
{
$url = $url . "/remote.php/dav/files/$internal_username/$backupdir/$filename";
$reply = $this->curl_request_nothrow(
$url,
$username,
$password,
'DELETE',
'cannot delete file'
);
$http_code = $reply['info']['http_code'];
syslog(LOG_ERR, "Deleting " . $url . " returned " . $http_code);
}
/**
@@ -342,6 +665,31 @@ class Nextcloud extends Base implements IBackupProvider
$error_message,
$postdata = null,
$headers = array("User-Agent: OPNsense Firewall")
) {
$result = $this->curl_request_nothrow($url, $username, $password, $method, $error_message, $postdata, $headers);
$info = $result['info'];
$err = $result['err'];
$response = $result['response'];
if (!($info['http_code'] == 200 || $info['http_code'] == 207 || $info['http_code'] == 201) || $err) {
syslog(LOG_ERR, $error_message);
syslog(LOG_ERR, json_encode($info));
throw new \Exception();
}
return array('response' => $response, 'info' => $info);
}
// Add this here, since I'm fundamentally opposed to throwing exceptions
// if http codes aren't to your liking in a generic function.
// Delegate that to upper functions, where it belongs.
public function curl_request_nothrow(
$url,
$username,
$password,
$method,
$error_message,
$postdata = null,
$headers = array("User-Agent: OPNsense Firewall")
) {
$curl = curl_init();
curl_setopt_array($curl, array(
@@ -361,13 +709,8 @@ class Nextcloud extends Base implements IBackupProvider
$response = curl_exec($curl);
$err = curl_error($curl);
$info = curl_getinfo($curl);
if (!($info['http_code'] == 200 || $info['http_code'] == 207 || $info['http_code'] == 201) || $err) {
syslog(LOG_ERR, $error_message);
syslog(LOG_ERR, json_encode($info));
throw new \Exception();
}
curl_close($curl);
return array('response' => $response, 'info' => $info);
return array('response' => $response, 'info' => $info, 'err' => $err);
}
/**

View File

@@ -1,6 +1,6 @@
<model>
<mount>//system/backup/nextcloud</mount>
<version>1.0.0</version>
<version>1.0.2</version>
<description>OPNsense Nextcloud Backup Settings</description>
<items>
<enabled type="BooleanField">
@@ -31,7 +31,7 @@
</check001>
</Constraints>
</user>
<password type="TextField">
<password type="UpdateOnlyTextField">
<Constraints>
<check001>
<ValidationMessage>A password for a Nextcloud server must be set.</ValidationMessage>
@@ -42,12 +42,26 @@
</check001>
</Constraints>
</password>
<password_encryption type="TextField"/>
<password_encryption type="UpdateOnlyTextField"/>
<backupdir type="TextField">
<Required>Y</Required>
<Mask>/^([\w%+\-]+\/)*[\w+%\-]+$/</Mask>
<Default>OPNsense-Backup</Default>
<ValidationMessage>The Backup Directory can only consist of alphanumeric characters, dash, underscores and slash. No leading or trailing slash.</ValidationMessage>
</backupdir>
<strategy type="BooleanField">
<Required>Y</Required>
<Default>0</Default>
</strategy>
<addhostname type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</addhostname>
<numdays type="IntegerField">
<MinimumValue>1</MinimumValue>
</numdays>
<numbackups type="IntegerField">
<MinimumValue>1</MinimumValue>
</numbackups>
</items>
</model>