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
+4 -1
View File
@@ -2159,7 +2159,10 @@ class ConnectivityManager(BaseServiceManager):
except OSError:
info['configured'] = False
elif exit_type == 'tor':
info['configured'] = True # Tor uses defaults; no per-cell config
# Tor has no per-cell config file; it counts as configured only via
# the store-installed / container-running bridge below, like every
# other exit type. Do not hardcode True here.
pass
elif exit_type == 'sshuttle':
info['configured'] = os.path.isfile(
os.path.join(self.sshuttle_dir, 'sshuttle.conf'))
+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
+10 -1
View File
@@ -288,6 +288,15 @@ def check_wireguard_port():
try:
from app import wireguard_manager
port_open = wireguard_manager.check_port_open()
return jsonify({'port_open': port_open, 'port': wireguard_manager._get_configured_port()})
configured_port = wireguard_manager._get_configured_port()
listening_port = wireguard_manager._kernel_listening_port()
return jsonify({
'port_open': port_open,
'port': configured_port,
'listening_port': listening_port,
'port_mismatch': (
listening_port is not None and listening_port != configured_port
),
})
except Exception as e:
return jsonify({"error": str(e)}), 500
+31 -6
View File
@@ -984,19 +984,44 @@ class WireGuardManager(BaseServiceManager):
pass
return ip
def check_port_open(self, port: int = None) -> bool:
"""Check if WireGuard is running and listening on the configured UDP port."""
configured_port = port if port is not None else self._get_configured_port()
# Primary: verify wg0 is up AND listening on the configured port
def _kernel_listening_port(self) -> Optional[int]:
"""Return the UDP port wg0 is actually bound to per `wg show`, or None.
This reads the live kernel state, which is the source of truth for what
port traffic must reach it may differ from wg0.conf's ListenPort if the
container has not been recreated since the port was changed.
"""
try:
result = subprocess.run(
['docker', 'exec', 'cell-wireguard', 'wg', 'show', 'wg0'],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and f'listening port: {configured_port}' in result.stdout.lower():
return True
if result.returncode != 0:
return None
for line in result.stdout.lower().splitlines():
line = line.strip()
if line.startswith('listening port:'):
try:
return int(line.split(':', 1)[1].strip())
except (ValueError, IndexError):
return None
except Exception:
pass
return None
def check_port_open(self, port: int = None) -> bool:
"""True when WireGuard is up and bound to a UDP port (reachable).
This is a liveness check, not a strict equality check against the
configured port: an interface that is up with a `listening port:` line
is serving traffic on that bound port. The bound port may differ from
wg0.conf's ListenPort if the container has not yet been recreated — that
is surfaced separately via the endpoint's actual-port field, not by
reporting the port closed.
"""
# Primary: wg0 is up and has a listening port → reachable on that port.
if self._kernel_listening_port() is not None:
return True
# Fallback: recent peer handshake confirms external reachability
try:
statuses = self.get_all_peer_statuses()