fix: distinguish supported network backup mounts from local filesystems

This commit is contained in:
Codex
2026-10-04 17:28:45 +08:00
parent 83e8981603
commit 631c411602
4 changed files with 55 additions and 7 deletions
+6 -2
View File
@@ -250,6 +250,10 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
1. SQLite online backup API 生成一致性副本,不停止视频服务;副本完整性检查后移除会话,计算 SHA-256 并以临时文件原子改名完成本机保存。 1. SQLite online backup API 生成一致性副本,不停止视频服务;副本完整性检查后移除会话,计算 SHA-256 并以临时文件原子改名完成本机保存。
2. 本机目录为 `VISION_DATA/managed-backups`。副本包含所有家庭、密码散列、设备连接凭据、家谱、记事、照片和设置;**不包含录像文件,未对副本进行额外加密**,按敏感数据保管。恢复后须重新登录。 2. 本机目录为 `VISION_DATA/managed-backups`。副本包含所有家庭、密码散列、设备连接凭据、家谱、记事、照片和设置;**不包含录像文件,未对副本进行额外加密**,按敏感数据保管。恢复后须重新登录。
3. 网络目录必须是服务器已经挂载且服务账号可写的绝对目录。默认 systemd 服务只允许写 `/var/lib/zhaovision`,建议把网络盘挂载在其下的 `network-backup` 目录。服务启动后新增挂载需确认该挂载在服务的文件系统视图中可见;必要时重启应用服务。 3. 网络目录必须是服务器已经挂载且服务账号可写的绝对目录。默认 systemd 服务只允许写 `/var/lib/zhaovision`,建议把网络盘挂载在其下的 `network-backup` 目录。服务启动后新增挂载需确认该挂载在服务的文件系统视图中可见;必要时重启应用服务。
4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。仅检测独立挂载并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。 4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。识别受支持的网络文件系统并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。
5. 网络盘不存在或没有检测到挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。 5. 网络盘不存在或没有检测到受支持的网络挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。
6. 数据库恢复由主机管理员进行:停止 `zhaovision` 和 `zhaovision-media`,先保存当前完整数据目录;校验选定副本的 SHA-256 与 SQLite integrity_check,将它恢复为 `/var/lib/zhaovision/vision.db` 并正确处理旧数据库的 WAL/SHM 文件,修正服务账号所有权,再启动服务。选用不早于副本生成版本的程序;数据库副本不会恢复录像文件或 `/etc/zhaovision.env`、Tailscale/反向代理配置。 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 共享目录。网络类型校验不代替云服务同步状态确认。
+1 -1
View File
@@ -1 +1 @@
0.1.25 0.1.26
+30 -3
View File
@@ -3,7 +3,7 @@ import datetime as dt
from contextlib import closing from contextlib import closing
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path, PurePosixPath
import re import re
import secrets import secrets
import shutil import shutil
@@ -27,6 +27,33 @@ class Backups:
last=self.a.setting('backup_last', None), last=self.a.setting('backup_last', None),
history=self.a.setting('backup_history', [])[-20:][::-1]) 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): def network_folder(self, text):
if not text: if not text:
return None return None
@@ -39,8 +66,8 @@ class Backups:
if not folder.is_dir(): if not folder.is_dir():
raise self.a.Problem('备份目录不存在,请先在服务器挂载网络存储并授予服务账号写入权限') raise self.a.Problem('备份目录不存在,请先在服务器挂载网络存储并授予服务账号写入权限')
# A disconnected mount must not silently become a local-only cloud backup. # 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)): if not self.network_mount(folder):
raise self.a.Problem('该目录尚未检测到独立挂载;请先连接网络盘,不能把本机目录当作云端成功') raise self.a.Problem('该目录未检测到支持的网络挂载,请先连接 NFS、SMB、WebDAV 或 rclone 网络盘')
if folder == (self.a.DATA/'managed-backups').resolve(): if folder == (self.a.DATA/'managed-backups').resolve():
raise self.a.Problem('网络副本与本机备份目录不能相同') raise self.a.Problem('网络副本与本机备份目录不能相同')
return folder return folder
+18 -1
View File
@@ -1,5 +1,6 @@
import datetime as dt import datetime as dt
from contextlib import closing from contextlib import closing
from pathlib import PurePosixPath
import sqlite3 import sqlite3
import unittest import unittest
from unittest.mock import patch 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): 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') 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)) 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() first=backup.run();second=backup.run()
self.assertTrue(first['networkOk'],first);self.assertTrue(second['networkOk'],second) 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()) 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 backup.running=True
with self.assertRaises(app.Problem):backup.start() with self.assertRaises(app.Problem):backup.start()
with self.assertRaises(app.Problem):backup.save(backup.config()) 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']))