fix: clean-install bugs — Tor false-installed, WG port-check honesty, encrypted backup upload
Unit Tests / test (push) Successful in 13m7s

Three independent bugs surfaced during pic1 clean-install testing:

1. Tor _exit_status hardcoded configured=True regardless of whether Tor was
   actually installed.  Status now flows through the same store-installed /
   container-running bridge used by every other optional service, so Tor only
   reports installed when the container is present and running.

2. check_port_open compared the port from wg0.conf against the kernel-reported
   listening port, causing false "port closed" results whenever the conf and the
   running container were momentarily out of sync.  The function is now an honest
   liveness check: any wg0 interface that is up and has a "listening port:" line
   in `wg show` is considered open.  The check-port API endpoint now also returns
   the actual kernel listening_port and a port_mismatch flag so the UI can inform
   the user when a container recreate is needed.  (The recreate machinery already
   exists via the port-change pending-restart path; this fix makes the mismatch
   visible rather than silently lying about reachability.)

3. upload_backup only handled .zip archives; encrypted .age blobs were rejected
   with a generic error.  The endpoint now calls backup_crypto.is_encrypted() to
   detect Age-encrypted blobs and stores them verbatim as <id>.tar.gz.age with
   mode 0600 so they can be uploaded and then restored with a passphrase.  The
   plaintext zip path is unchanged.

Tests added/updated: test_connectivity_manager.py (Tor status bridge),
test_wireguard_manager.py + test_wireguard_endpoints.py (port-check liveness
and mismatch flag), test_config_backup_restore_http.py (encrypted upload
round-trip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 01:52:26 -04:00
parent 743b026b01
commit 8d904b1b8f
9 changed files with 207 additions and 26 deletions
+28 -7
View File
@@ -961,27 +961,48 @@ def download_backup(backup_id):
def upload_backup():
try:
from app import config_manager
import backup_crypto
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
f = request.files['file']
filename = f.filename or ''
if filename.endswith('.zip'):
backup_id = filename[:-4]
else:
raw = f.read()
# Derive a clean backup id from the filename, stripping known suffixes.
stem = filename
for suffix in ('.tar.gz.age', '.age', '.zip'):
if stem.endswith(suffix):
stem = stem[:-len(suffix)]
break
backup_id = ''.join(c for c in stem if c.isalnum() or c == '_')
if not backup_id:
backup_id = f"backup_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
backup_id = ''.join(c for c in backup_id if c.isalnum() or c == '_')
# Encrypted backups are opaque blobs: store them verbatim as
# <id>.tar.gz.age so restore_config()/_resolve_backup_dir() can decrypt
# them with the passphrase supplied at restore time.
if backup_crypto.is_encrypted(raw):
config_manager.backup_dir.mkdir(parents=True, exist_ok=True)
archive_path = config_manager.backup_dir / f'{backup_id}.tar.gz.age'
archive_path.write_bytes(raw)
try:
os.chmod(archive_path, 0o600)
except OSError as e:
logger.warning(f"Could not chmod uploaded backup: {e}")
return jsonify({'backup_id': backup_id, 'encrypted': True})
backup_path = config_manager.backup_dir / backup_id
backup_path.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(io.BytesIO(f.read())) as zf:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
zf.extractall(backup_path)
except zipfile.BadZipFile:
shutil.rmtree(backup_path, ignore_errors=True)
return jsonify({'error': 'Invalid zip file'}), 400
return jsonify({'error': 'Invalid backup file'}), 400
if not (backup_path / 'manifest.json').exists():
shutil.rmtree(backup_path, ignore_errors=True)
return jsonify({'error': 'Invalid backup: missing manifest.json'}), 400
return jsonify({'backup_id': backup_id})
return jsonify({'backup_id': backup_id, 'encrypted': False})
except Exception as e:
logger.error(f"Error uploading backup: {e}")
return jsonify({'error': str(e)}), 500