diff --git a/furtka/gateway.py b/furtka/gateway.py index 067c449..e9f778d 100644 --- a/furtka/gateway.py +++ b/furtka/gateway.py @@ -214,13 +214,27 @@ def run_connect(gateway_url: str, registration_token: str, box_name: str) -> Non systemd-run from `POST /api/gateway/connect`), writing state transitions as it goes so the UI can poll instead of the request thread blocking on a slow/unreachable gateway. + + A registration saved by an earlier attempt whose tunnel failed to come + up is reused rather than registering again — re-registering would leave + the first box (and its wg-easy peer) orphaned on the gateway. """ - write_state("registering") - try: - config = register(gateway_url, registration_token, box_name) - except GatewayError as e: - write_state("error", error=str(e)) - return + existing = load_config() + if existing is not None: + if existing["gateway_url"] != gateway_url.rstrip("/"): + write_state( + "error", + error=f"already registered with {existing['gateway_url']} — disconnect first", + ) + return + config = existing + else: + write_state("registering") + try: + config = register(gateway_url, registration_token, box_name) + except GatewayError as e: + write_state("error", error=str(e)) + return write_state("starting_tunnel") try: diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 89fd279..a7888c6 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -280,6 +280,58 @@ def test_run_connect_tunnel_failure_writes_error_state(gw, monkeypatch): assert gw.load_config()["box_id"] == "abc" +def test_run_connect_retry_after_tunnel_failure_reuses_registration(gw, monkeypatch): + register_calls = [] + + def urlopen(req, timeout): + register_calls.append(req.full_url) + return _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}) + + monkeypatch.setattr("urllib.request.urlopen", urlopen) + + def failing_tunnel(enabled, wg=None): + raise gw.wireguard.WireguardError("systemctl failed: unit missing") + + monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel) + gw.run_connect("https://gw.example.com", "tok", "my-box") + assert gw.read_state()["stage"] == "error" + + tunnel_calls = [] + monkeypatch.setattr( + gw.wireguard, + "set_tunnel_enabled", + lambda enabled, wg=None: tunnel_calls.append((enabled, wg)), + ) + gw.run_connect("https://gw.example.com/", "tok", "my-box") + + # Registered exactly once; the retry only brought the tunnel up. + assert register_calls == ["https://gw.example.com/v1/boxes/register"] + assert tunnel_calls == [(True, WG)] + assert gw.read_state()["stage"] == "done" + assert gw.load_config()["box_id"] == "abc" + + +def test_run_connect_refuses_a_different_gateway_while_registered(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + def must_not_call(req, timeout): + raise AssertionError("must not register with a second gateway") + + monkeypatch.setattr("urllib.request.urlopen", must_not_call) + monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", lambda enabled, wg=None: None) + + gw.run_connect("https://other.example.com", "tok", "box") + + state = gw.read_state() + assert state["stage"] == "error" + assert "disconnect first" in state["error"] + assert gw.load_config()["gateway_url"] == "https://gw.example.com" + + def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch): monkeypatch.setattr( "urllib.request.urlopen",