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
+59
View File
@@ -30,6 +30,8 @@ api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from app import app
import backup_crypto
import tarfile
class TestCreateConfigBackup(unittest.TestCase):
@@ -345,6 +347,63 @@ class TestUploadBackup(unittest.TestCase):
)
self.assertEqual(r.status_code, 400)
@patch('app.config_manager')
def test_upload_stores_encrypted_blob_verbatim(self, mock_cm):
backup_dir = Path(self.tmp)
mock_cm.backup_dir = backup_dir
blob = backup_crypto.encrypt_bytes(b'payload-bytes', 'secret')
self.assertTrue(blob.startswith(backup_crypto.MAGIC))
r = self.client.post(
'/api/config/backup/upload',
data={'file': (io.BytesIO(blob), 'backup_20260101_010101.tar.gz.age')},
content_type='multipart/form-data',
)
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertTrue(data['encrypted'])
self.assertEqual(data['backup_id'], 'backup_20260101_010101')
archive = backup_dir / 'backup_20260101_010101.tar.gz.age'
self.assertTrue(archive.exists())
self.assertEqual(archive.read_bytes(), blob)
@patch('app.config_manager')
def test_upload_encrypted_then_restore_round_trip(self, mock_cm):
# Build a real encrypted backup archive (tar.gz of a manifest, then
# encrypted), upload it, then restore it through the real ConfigManager
# decrypt/resolve path with the correct and an incorrect passphrase.
from config_manager import ConfigManager
backup_dir = Path(self.tmp) / 'backups'
backup_dir.mkdir(parents=True, exist_ok=True)
mock_cm.backup_dir = backup_dir
tar_buf = io.BytesIO()
with tarfile.open(fileobj=tar_buf, mode='w:gz') as tar:
inner = json.dumps({'backup_id': 'rt', 'services': []}).encode()
info = tarfile.TarInfo('manifest.json')
info.size = len(inner)
tar.addfile(info, io.BytesIO(inner))
blob = backup_crypto.encrypt_bytes(tar_buf.getvalue(), 'pw123')
r = self.client.post(
'/api/config/backup/upload',
data={'file': (io.BytesIO(blob), 'rt.tar.gz.age')},
content_type='multipart/form-data',
)
self.assertEqual(r.status_code, 200)
backup_id = json.loads(r.data)['backup_id']
# Resolve+decrypt with the correct passphrase succeeds.
real_cm = ConfigManager.__new__(ConfigManager)
real_cm.backup_dir = backup_dir
path, cleanup = real_cm._resolve_backup_dir(f'{backup_id}.tar.gz.age', 'pw123')
self.assertTrue((path / 'manifest.json').exists())
shutil.rmtree(cleanup, ignore_errors=True)
# Wrong passphrase raises PermissionError → route returns 400.
with self.assertRaises(PermissionError):
real_cm._resolve_backup_dir(f'{backup_id}.tar.gz.age', 'wrong')
if __name__ == '__main__':
unittest.main()
+25 -1
View File
@@ -1037,11 +1037,35 @@ class TestExitStatus(unittest.TestCase):
self.assertIn('status', item)
self.assertIn(item['status'], ('active', 'configured', 'not_configured'))
def test_tor_defaults_to_configured(self):
def test_tor_not_configured_when_not_installed_or_running(self):
# Tor must not report configured just because it has no per-cell config;
# it flows through the store-installed / container-running bridge.
mgr = self._mgr()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(returncode=1, stdout='', stderr='')
info = mgr._exit_status('tor')
self.assertFalse(info['configured'])
self.assertEqual(info['status'], 'not_configured')
def test_tor_configured_when_store_installed(self):
mgr = self._mgr(installed={'tor': {'manifest': {'id': 'tor'}}})
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(returncode=1, stdout='', stderr='')
info = mgr._exit_status('tor')
self.assertTrue(info['configured'])
self.assertEqual(info['status'], 'configured')
def test_tor_configured_when_container_running(self):
mgr = self._mgr()
def fake_run(cmd, **kwargs):
if 'inspect' in cmd:
return MagicMock(returncode=0, stdout='true\n', stderr='')
return MagicMock(returncode=1, stdout='', stderr='')
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.side_effect = fake_run
info = mgr._exit_status('tor')
self.assertTrue(info['configured'])
self.assertEqual(info['status'], 'configured')
+21
View File
@@ -36,6 +36,7 @@ class TestWireGuardEndpoints(unittest.TestCase):
def test_check_port_returns_port_open_true(self, mock_wg):
mock_wg.check_port_open.return_value = True
mock_wg._get_configured_port.return_value = 51820
mock_wg._kernel_listening_port.return_value = 51820
r = self.client.post('/api/wireguard/check-port')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
@@ -43,15 +44,35 @@ class TestWireGuardEndpoints(unittest.TestCase):
self.assertIn('port', data)
self.assertTrue(data['port_open'])
self.assertEqual(data['port'], 51820)
self.assertEqual(data['listening_port'], 51820)
self.assertFalse(data['port_mismatch'])
@patch('app.wireguard_manager')
def test_check_port_reports_actual_listening_port_on_mismatch(self, mock_wg):
# Configured 51821 but kernel bound to 51820 — endpoint surfaces the real
# bound port and flags the mismatch without reporting the port closed.
mock_wg.check_port_open.return_value = True
mock_wg._get_configured_port.return_value = 51821
mock_wg._kernel_listening_port.return_value = 51820
r = self.client.post('/api/wireguard/check-port')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertTrue(data['port_open'])
self.assertEqual(data['port'], 51821)
self.assertEqual(data['listening_port'], 51820)
self.assertTrue(data['port_mismatch'])
@patch('app.wireguard_manager')
def test_check_port_returns_port_open_false(self, mock_wg):
mock_wg.check_port_open.return_value = False
mock_wg._get_configured_port.return_value = 51820
mock_wg._kernel_listening_port.return_value = None
r = self.client.post('/api/wireguard/check-port')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertFalse(data['port_open'])
self.assertIsNone(data['listening_port'])
self.assertFalse(data['port_mismatch'])
@patch('app.wireguard_manager')
def test_check_port_returns_500_on_exception(self, mock_wg):
+24 -7
View File
@@ -615,27 +615,44 @@ class TestWireGuardSysctlAndPortCheck(unittest.TestCase):
self.assertTrue(result)
@patch('subprocess.run')
def test_check_port_open_wrong_port_returns_false(self, mock_run):
# wg0 is up but listening on 51820 while wg0.conf says 51821 — must return False
def test_check_port_open_true_despite_port_mismatch(self, mock_run):
# wg0 is up and listening on 51820 while wg0.conf says 51821. The kernel
# port is what actually serves traffic, so this is a reachability success;
# check_port_open must NOT return False merely because of the mismatch.
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = 'interface: wg0\n listening port: 51820\n'
# Write wg0.conf with a different port so _get_configured_port() returns 51821
cfg_path = os.path.join(self.wg.wireguard_dir, 'wg0.conf')
with open(cfg_path, 'w') as f:
f.write('[Interface]\nListenPort = 51821\nPrivateKey = abc\n')
self.assertFalse(self.wg.check_port_open())
self.assertTrue(self.wg.check_port_open())
@patch('subprocess.run')
def test_check_port_open_explicit_port_matches(self, mock_run):
def test_kernel_listening_port_parses_actual_bound_port(self, mock_run):
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = 'interface: wg0\n listening port: 51820\n'
self.assertEqual(self.wg._kernel_listening_port(), 51820)
@patch('subprocess.run')
def test_kernel_listening_port_none_when_down(self, mock_run):
mock_run.return_value.returncode = 1
mock_run.return_value.stdout = ''
self.assertIsNone(self.wg._kernel_listening_port())
@patch('subprocess.run')
def test_check_port_open_true_when_interface_bound(self, mock_run):
# check_port_open is a liveness check: an up interface with a bound port
# is reachable, regardless of which port number it is.
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = 'interface: wg0\n listening port: 12345\n'
self.assertTrue(self.wg.check_port_open(port=12345))
@patch('subprocess.run')
def test_check_port_open_explicit_port_mismatch(self, mock_run):
def test_check_port_open_true_even_when_bound_port_differs(self, mock_run):
# Bound port (51820) differs from the configured/expected port (51821),
# but the interface is up and serving — this is reachable, not closed.
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = 'interface: wg0\n listening port: 51820\n'
self.assertFalse(self.wg.check_port_open(port=51821))
self.assertTrue(self.wg.check_port_open(port=51821))
# ── get_peer_status ───────────────────────────────────────────────────────