fix: clean-install bugs — Tor false-installed, WG port-check honesty, encrypted backup upload
Unit Tests / test (push) Successful in 13m7s
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user