fix: distinguish supported network backup mounts from local filesystems
This commit is contained in:
@@ -250,6 +250,10 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
|
||||
1. SQLite online backup API 生成一致性副本,不停止视频服务;副本完整性检查后移除会话,计算 SHA-256 并以临时文件原子改名完成本机保存。
|
||||
2. 本机目录为 `VISION_DATA/managed-backups`。副本包含所有家庭、密码散列、设备连接凭据、家谱、记事、照片和设置;**不包含录像文件,未对副本进行额外加密**,按敏感数据保管。恢复后须重新登录。
|
||||
3. 网络目录必须是服务器已经挂载且服务账号可写的绝对目录。默认 systemd 服务只允许写 `/var/lib/zhaovision`,建议把网络盘挂载在其下的 `network-backup` 目录。服务启动后新增挂载需确认该挂载在服务的文件系统视图中可见;必要时重启应用服务。
|
||||
4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。仅检测独立挂载并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。
|
||||
5. 网络盘不存在或没有检测到挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。
|
||||
4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。识别受支持的网络文件系统并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。
|
||||
5. 网络盘不存在或没有检测到受支持的网络挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。
|
||||
6. 数据库恢复由主机管理员进行:停止 `zhaovision` 和 `zhaovision-media`,先保存当前完整数据目录;校验选定副本的 SHA-256 与 SQLite integrity_check,将它恢复为 `/var/lib/zhaovision/vision.db` 并正确处理旧数据库的 WAL/SHM 文件,修正服务账号所有权,再启动服务。选用不早于副本生成版本的程序;数据库副本不会恢复录像文件或 `/etc/zhaovision.env`、Tailscale/反向代理配置。
|
||||
|
||||
### v0.1.26 备份目录识别修正
|
||||
|
||||
Linux 按目标目录最近一层挂载识别网络文件系统,支持 NFS、SMB、WebDAV、SSHFS、rclone、s3fs/goofys;本机 ext4、tmpfs 等不作为网络目标。Windows 可使用已连接的 UNC 共享目录。网络类型校验不代替云服务同步状态确认。
|
||||
|
||||
+30
-3
@@ -3,7 +3,7 @@ import datetime as dt
|
||||
from contextlib import closing
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
@@ -27,6 +27,33 @@ class Backups:
|
||||
last=self.a.setting('backup_last', None),
|
||||
history=self.a.setting('backup_history', [])[-20:][::-1])
|
||||
|
||||
@staticmethod
|
||||
def network_mount(folder):
|
||||
if os.name == 'nt':
|
||||
return folder.drive.startswith('\\\\')
|
||||
try:
|
||||
records = Path('/proc/self/mountinfo').read_text().splitlines()
|
||||
except OSError:
|
||||
return False
|
||||
return Backups.linux_network_mount(PurePosixPath(str(folder)), records)
|
||||
|
||||
@staticmethod
|
||||
def linux_network_mount(folder, records):
|
||||
matches = []
|
||||
for line in records:
|
||||
left, separator, right = line.partition(' - ')
|
||||
fields, detail = left.split(), right.split()
|
||||
if not separator or len(fields) < 5 or not detail:
|
||||
continue
|
||||
mount = PurePosixPath(re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), fields[4]))
|
||||
if folder.is_relative_to(mount):
|
||||
matches.append((len(mount.parts), detail[0]))
|
||||
# The nearest mount wins: a tmpfs inside an NFS share is still local.
|
||||
filesystem = max(matches, default=(0, ''), key=lambda x:x[0])[1]
|
||||
return filesystem in {'cifs', 'smb3', 'nfs', 'nfs4', 'davfs',
|
||||
'fuse.sshfs', 'fuse.rclone', 'fuse.davfs', 'fuse.davfs2',
|
||||
'fuse.wdfs', 'fuse.s3fs', 'fuse.goofys'}
|
||||
|
||||
def network_folder(self, text):
|
||||
if not text:
|
||||
return None
|
||||
@@ -39,8 +66,8 @@ class Backups:
|
||||
if not folder.is_dir():
|
||||
raise self.a.Problem('备份目录不存在,请先在服务器挂载网络存储并授予服务账号写入权限')
|
||||
# A disconnected mount must not silently become a local-only cloud backup.
|
||||
if not any(os.path.ismount(p) for p in (folder, *folder.parents) if p != Path(p.anchor)):
|
||||
raise self.a.Problem('该目录尚未检测到独立挂载;请先连接网络盘,不能把本机目录当作云端成功')
|
||||
if not self.network_mount(folder):
|
||||
raise self.a.Problem('该目录未检测到支持的网络挂载,请先连接 NFS、SMB、WebDAV 或 rclone 网络盘')
|
||||
if folder == (self.a.DATA/'managed-backups').resolve():
|
||||
raise self.a.Problem('网络副本与本机备份目录不能相同')
|
||||
return folder
|
||||
|
||||
+18
-1
@@ -1,5 +1,6 @@
|
||||
import datetime as dt
|
||||
from contextlib import closing
|
||||
from pathlib import PurePosixPath
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -44,7 +45,7 @@ class BackupTests(unittest.TestCase):
|
||||
def test_network_copy_checksum_and_retention_only_touch_owned_files(self):
|
||||
backup=self.source_file();network=app.DATA/'network';network.mkdir();unrelated=network/'keep.sqlite';unrelated.write_text('unrelated')
|
||||
app.set_setting('backup_config',dict(backup.config(),networkPath=str(network),keep=1))
|
||||
with patch('backups.os.path.ismount',side_effect=lambda p:p==network.resolve()):
|
||||
with patch.object(backup,'network_mount',return_value=True):
|
||||
first=backup.run();second=backup.run()
|
||||
self.assertTrue(first['networkOk'],first);self.assertTrue(second['networkOk'],second)
|
||||
self.assertTrue(unrelated.exists());self.assertFalse((network/first['file']).exists());self.assertTrue((network/second['file']).exists())
|
||||
@@ -63,3 +64,19 @@ class BackupTests(unittest.TestCase):
|
||||
backup.running=True
|
||||
with self.assertRaises(app.Problem):backup.start()
|
||||
with self.assertRaises(app.Problem):backup.save(backup.config())
|
||||
|
||||
def test_local_filesystems_are_never_reported_as_network_mounts(self):
|
||||
records=['1 0 8:1 / / rw - ext4 /dev/sda1 rw',
|
||||
'2 1 0:1 / /tmp rw - tmpfs tmpfs rw',
|
||||
'3 1 0:2 / /mnt/cloud rw - nfs4 nas:/share rw',
|
||||
'4 3 0:3 / /mnt/cloud/local rw - tmpfs tmpfs rw']
|
||||
for path in ('/var/lib/backups','/tmp/backups','/mnt/cloud/local/backups'):
|
||||
self.assertFalse(Backups.linux_network_mount(PurePosixPath(path),records))
|
||||
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud/backups'),records))
|
||||
|
||||
def test_network_mount_paths_with_spaces_and_supported_fuse_types(self):
|
||||
for filesystem in ('cifs','fuse.rclone','fuse.davfs','fuse.sshfs'):
|
||||
records=['1 0 8:1 / / rw - ext4 /dev/sda1 rw',
|
||||
'2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw']
|
||||
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records))
|
||||
self.assertFalse(Backups.linux_network_mount(PurePosixPath('/tmp'),['invalid data']))
|
||||
|
||||
Reference in New Issue
Block a user