diff --git a/api/app.py b/api/app.py index d163fe1..85457c7 100644 --- a/api/app.py +++ b/api/app.py @@ -854,6 +854,33 @@ def connectivity_get_peer_exits(): return jsonify({'error': str(e)}), 500 +@app.route('/api/egress/status', methods=['GET']) +def egress_status(): + """Return egress status for all installed services that have an egress config.""" + try: + return jsonify(egress_manager.get_status()) + except Exception as e: + logger.error(f"egress_status: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/egress/services//exit', methods=['PUT']) +def egress_set_service_exit(service_id: str): + """Persist and immediately apply a per-service egress override.""" + try: + data = request.get_json(silent=True) or {} + exit_type = data.get('exit_type') + if not isinstance(exit_type, str): + return jsonify({'ok': False, 'error': 'exit_type is required'}), 400 + result = egress_manager.set_service_exit(service_id, exit_type) + if result.get('ok'): + return jsonify(result) + return jsonify(result), 400 + except Exception as e: + logger.error(f"egress_set_service_exit({service_id}): {e}") + return jsonify({'error': str(e)}), 500 + + if __name__ == '__main__': debug = os.environ.get('FLASK_DEBUG', '0') == '1' app.run(host='0.0.0.0', port=3000, debug=debug) \ No newline at end of file diff --git a/api/routes/peers.py b/api/routes/peers.py index 7decbe0..a71abfb 100644 --- a/api/routes/peers.py +++ b/api/routes/peers.py @@ -89,6 +89,20 @@ def add_peer(): except Exception as e: logger.warning(f"Peer {peer_name}: {step_name} account creation failed (non-fatal): {e}") + # Provision accounts for installed HTTP-backed store services (non-fatal) + try: + from app import account_manager as _am, config_manager as _cfg, service_registry as _sreg + for _svc_id in (_cfg.get_installed_services() or {}): + _svc_info = _sreg.get(_svc_id) + if _svc_info and (_svc_info.get('accounts') or {}).get('manager') == 'http': + try: + _am.provision(_svc_id, peer_name) + except Exception as _he: + logger.warning('Peer %s: HTTP account provision for %s failed (non-fatal): %s', + peer_name, _svc_id, _he) + except Exception as _am_err: + logger.warning('Peer %s: HTTP store provisioning failed (non-fatal): %s', peer_name, _am_err) + peer_info = { 'peer': peer_name, 'ip': assigned_ip, diff --git a/api/service_store_manager.py b/api/service_store_manager.py index cc33cac..55bf1df 100644 --- a/api/service_store_manager.py +++ b/api/service_store_manager.py @@ -436,3 +436,10 @@ class ServiceStoreManager(BaseServiceManager): self.service_composer.reapply_active_services() except Exception as e: logger.warning('reapply_on_startup: reapply_active_services failed: %s', e) + + # Re-apply egress fwmark rules + if self.egress_manager is not None: + try: + self.egress_manager.apply_all() + except Exception as e: + logger.warning('reapply_on_startup: egress apply_all failed: %s', e) diff --git a/tests/test_peer_provisioning.py b/tests/test_peer_provisioning.py index d579513..0b1ac37 100644 --- a/tests/test_peer_provisioning.py +++ b/tests/test_peer_provisioning.py @@ -372,6 +372,104 @@ def test_delete_nonexistent_peer_returns_gracefully(admin_client, mock_peer_regi assert r.status_code in (200, 404) +# ── POST /api/peers — HTTP store service provisioning ──────────────────────── + +def test_create_peer_provisions_http_store_services( + auth_mgr, mock_email_mgr, mock_calendar_mgr, + mock_file_mgr, mock_wg_mgr, mock_peer_registry): + """When an installed store service has accounts.manager='http', + account_manager.provision() must be called for the new peer.""" + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'test-secret' + + mock_am = MagicMock() + mock_am.provision.return_value = {'password': 'generated'} + mock_am.store_credentials = MagicMock() + + mock_cfg = MagicMock() + mock_cfg.get_installed_services.return_value = {'my-store-app': {}} + + mock_sreg = MagicMock() + mock_sreg.get.return_value = {'id': 'my-store-app', 'accounts': {'manager': 'http'}, 'backend': 'cell-my-store-app:8080'} + + patches = [ + patch('app.auth_manager', auth_mgr), + patch('app.email_manager', mock_email_mgr), + patch('app.calendar_manager', mock_calendar_mgr), + patch('app.file_manager', mock_file_mgr), + patch('app.wireguard_manager', mock_wg_mgr), + patch('app.peer_registry', mock_peer_registry), + patch('app.firewall_manager'), + patch('app.account_manager', mock_am), + patch('app.config_manager', mock_cfg), + patch('app.service_registry', mock_sreg), + ] + try: + import auth_routes + patches.append(patch.object(auth_routes, 'auth_manager', auth_mgr, create=True)) + except (ImportError, AttributeError): + pass + + started = [p.start() for p in patches] + try: + with app.test_client() as client: + r = _login(client) + assert r.status_code == 200 + resp = _post_peer(client) + assert resp.status_code == 201 + mock_am.provision.assert_called_once_with('my-store-app', 'alice') + finally: + for p in patches: + p.stop() + + +def test_create_peer_http_provision_failure_is_nonfatal( + auth_mgr, mock_email_mgr, mock_calendar_mgr, + mock_file_mgr, mock_wg_mgr, mock_peer_registry): + """HTTP account provisioning failure must not block peer creation.""" + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'test-secret' + + mock_am = MagicMock() + mock_am.provision.side_effect = RuntimeError('service unavailable') + mock_am.store_credentials = MagicMock() + + mock_cfg = MagicMock() + mock_cfg.get_installed_services.return_value = {'my-store-app': {}} + + mock_sreg = MagicMock() + mock_sreg.get.return_value = {'id': 'my-store-app', 'accounts': {'manager': 'http'}, 'backend': 'cell-my-store-app:8080'} + + patches = [ + patch('app.auth_manager', auth_mgr), + patch('app.email_manager', mock_email_mgr), + patch('app.calendar_manager', mock_calendar_mgr), + patch('app.file_manager', mock_file_mgr), + patch('app.wireguard_manager', mock_wg_mgr), + patch('app.peer_registry', mock_peer_registry), + patch('app.firewall_manager'), + patch('app.account_manager', mock_am), + patch('app.config_manager', mock_cfg), + patch('app.service_registry', mock_sreg), + ] + try: + import auth_routes + patches.append(patch.object(auth_routes, 'auth_manager', auth_mgr, create=True)) + except (ImportError, AttributeError): + pass + + started = [p.start() for p in patches] + try: + with app.test_client() as client: + r = _login(client) + assert r.status_code == 200 + resp = _post_peer(client) + assert resp.status_code == 201, 'HTTP provision failure must not block peer creation' + finally: + for p in patches: + p.stop() + + # ── POST /api/peers — firewall rollback (A3) ────────────────────────────────── def test_create_peer_rolls_back_firewall_on_dns_failure( diff --git a/tests/test_service_store_manager.py b/tests/test_service_store_manager.py index ea1554f..e21690f 100644 --- a/tests/test_service_store_manager.py +++ b/tests/test_service_store_manager.py @@ -771,5 +771,36 @@ class TestRemove(unittest.TestCase): composer.remove.assert_called_once_with('myapp', purge_data=True) +class TestReapplyOnStartup(unittest.TestCase): + + def _make_ssm_with_installed(self): + ssm = _make_manager(installed={'svc1': {'service_ip': '172.20.1.10', 'iptables_rules': []}}) + ssm.caddy_manager = MagicMock() + return ssm + + def test_reapply_calls_egress_apply_all_when_wired(self): + ssm = self._make_ssm_with_installed() + mock_egress = MagicMock() + ssm.egress_manager = mock_egress + with patch('firewall_manager.apply_service_rules'): + ssm.reapply_on_startup() + mock_egress.apply_all.assert_called_once() + + def test_reapply_skips_egress_when_not_wired(self): + """reapply_on_startup must not raise when egress_manager is None.""" + ssm = self._make_ssm_with_installed() + ssm.egress_manager = None + with patch('firewall_manager.apply_service_rules'): + ssm.reapply_on_startup() # must not raise + + def test_reapply_egress_failure_is_nonfatal(self): + ssm = self._make_ssm_with_installed() + mock_egress = MagicMock() + mock_egress.apply_all.side_effect = RuntimeError('iptables error') + ssm.egress_manager = mock_egress + with patch('firewall_manager.apply_service_rules'): + ssm.reapply_on_startup() # must not raise + + if __name__ == '__main__': unittest.main()