diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..bd3bbe0 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,7 @@ +[mcp_servers.ops-bridge] +command = "uv" +args = [ + "run", + "python", + "src/bridge/mcp_server/server.py", +] diff --git a/Makefile b/Makefile index 6861a59..1ea4c88 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,22 @@ -.PHONY: test lint install mcp-http mcp-stop +.DEFAULT_GOAL := help -test: +.PHONY: help setup test lint install mcp-http mcp-stop + +help: ## List available make targets + @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +setup: ## Sync dependencies and install the bridge CLI wrapper + uv sync --all-groups + uv tool install -e . --force + +test: ## Run the test suite uv run pytest -lint: +lint: ## Run ruff lint checks uv run ruff check . -install: - uv tool install -e . +install: ## Install the bridge CLI wrapper + uv tool install -e . --force mcp-http: ## Start MCP server in SSE mode (default port 8002) BRIDGE_MCP_PORT=$${BRIDGE_MCP_PORT:-8002} uv run python src/bridge/mcp_server/server.py --http diff --git a/src/bridge/diagnostics.py b/src/bridge/diagnostics.py index b486a93..db63d3c 100644 --- a/src/bridge/diagnostics.py +++ b/src/bridge/diagnostics.py @@ -1,6 +1,7 @@ """End-to-end tunnel diagnostics for OpsBridge.""" from __future__ import annotations +import socket import subprocess import time from dataclasses import dataclass @@ -13,6 +14,38 @@ from bridge.models import BridgeState, TunnelConfig from bridge.state import StateManager, _pid_alive +def _remote_port_probe_command(remote_port: int) -> str: + """Build a portable remote shell probe for a listening TCP port.""" + return ( + f"port={remote_port}; " + "if command -v ss >/dev/null 2>&1; then " + "ss -tnlp 2>/dev/null | grep -q \":$port \" && echo ok || echo closed; " + "elif command -v netstat >/dev/null 2>&1; then " + "netstat -tnlp 2>/dev/null | " + "grep -q \"[.:]$port[[:space:]]\" && echo ok || echo closed; " + "else " + "hex=$(printf '%04X' \"$port\"); " + "awk -v p=\":$hex\" " + "'NR > 1 && $4 == \"0A\" && index($2, p) { found = 1 } " + "END { print found ? \"ok\" : \"closed\" }' " + "/proc/net/tcp /proc/net/tcp6 2>/dev/null; " + "fi" + ) + + +def _probe_local_port(local_port: int) -> str: + """Check whether the local side of an SSH -L tunnel is accepting TCP.""" + try: + with socket.create_connection(("127.0.0.1", local_port), timeout=5): + return "listening" + except ConnectionRefusedError: + return "closed" + except socket.timeout: + return "error:timeout" + except OSError as e: + return f"error:{e}" + + @dataclass class TunnelCheckResult: tunnel: str @@ -52,35 +85,38 @@ def check_tunnel(cfg: TunnelConfig, state_mgr: StateManager) -> TunnelCheckResul and ssh_process != "ok" ) - # 3. SSH probe for remote port - key_path = str(Path(cfg.ssh_key).expanduser()) - cmd = [ - "ssh", - "-i", key_path, - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=accept-new", - f"{cfg.ssh_user}@{cfg.host}", - f"ss -tnlp 2>/dev/null | grep -q ':{cfg.remote_port} ' && echo ok || echo closed", - ] - try: - proc = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=10, - ) - output = proc.stdout.strip() - if output == "ok": - remote_port = "listening" - elif output == "closed": - remote_port = "closed" - else: - remote_port = f"error:{proc.stderr.strip() or 'unknown'}" - except subprocess.TimeoutExpired: - remote_port = "error:timeout" - except Exception as e: - remote_port = f"error:{e}" + # 3. Port probe: reverse tunnels listen remotely; local tunnels listen here. + if cfg.direction == "local": + remote_port = _probe_local_port(cfg.local_port) + else: + key_path = str(Path(cfg.ssh_key).expanduser()) + cmd = [ + "ssh", + "-i", key_path, + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + f"{cfg.ssh_user}@{cfg.host}", + _remote_port_probe_command(cfg.remote_port), + ] + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + output = proc.stdout.strip() + if output == "ok": + remote_port = "listening" + elif output == "closed": + remote_port = "closed" + else: + remote_port = f"error:{proc.stderr.strip() or 'unknown'}" + except subprocess.TimeoutExpired: + remote_port = "error:timeout" + except Exception as e: + remote_port = f"error:{e}" # 4. Local API health check (optional) local_api: Optional[str] = None diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a99c45b..e7655f6 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -6,7 +6,11 @@ from unittest.mock import MagicMock, patch import pytest -from bridge.diagnostics import check_all_tunnels, check_tunnel +from bridge.diagnostics import ( + _remote_port_probe_command, + check_all_tunnels, + check_tunnel, +) from bridge.models import BridgeState, TunnelConfig from bridge.state import StateManager @@ -32,6 +36,14 @@ def state_mgr(tmp_path): class TestCheckTunnel: + def test_remote_port_probe_has_minimal_host_fallback(self): + """Remote probe supports minimal hosts without ss/netstat.""" + command = _remote_port_probe_command(18000) + assert "command -v ss" in command + assert "command -v netstat" in command + assert "/proc/net/tcp" in command + assert "/proc/net/tcp6" in command + def test_no_pid(self, tcfg, state_mgr): """No PID file → ssh_process='no_pid', ok=False.""" with patch("bridge.diagnostics.subprocess.run") as mock_run: @@ -83,6 +95,29 @@ class TestCheckTunnel: assert result.remote_port == "closed" assert result.ok is False + def test_local_direction_checks_local_port(self, tcfg, state_mgr): + """Local tunnels verify the local listener instead of a remote -R port.""" + local_cfg = TunnelConfig( + name="local-tunnel", + host="haskelseed.local", + remote_port=1234, + local_port=11234, + ssh_user="root", + ssh_key="~/.ssh/id_ops", + actor="adm-bernd", + direction="local", + ) + state_mgr.write_pid("local-tunnel", 12345) + with ( + patch("bridge.diagnostics._pid_alive", return_value=True), + patch("bridge.diagnostics._probe_local_port", return_value="listening"), + patch("bridge.diagnostics.subprocess.run") as mock_run, + ): + result = check_tunnel(local_cfg, state_mgr) + mock_run.assert_not_called() + assert result.remote_port == "listening" + assert result.ok is True + def test_ssh_timeout(self, tcfg, state_mgr): """SSH probe timeout → remote_port='error:timeout'.""" state_mgr.write_pid("test-tunnel", 12345) diff --git a/workplans/ADHOC-2026-06-14.md b/workplans/ADHOC-2026-06-14.md new file mode 100644 index 0000000..6d588e4 --- /dev/null +++ b/workplans/ADHOC-2026-06-14.md @@ -0,0 +1,56 @@ +--- +id: ADHOC-2026-06-14 +type: workplan +title: "Ad hoc ops-bridge fixes for 2026-06-14" +domain: custodian +repo: ops-bridge +status: finished +owner: codex +topic_slug: ops-bridge +created: "2026-06-14" +updated: "2026-06-14" +state_hub_workstream_id: "fbc2ef7e-626f-4c6a-bdf8-c69bf29097ce" +--- + +## Fix haskelseed bridge diagnostics + +```task +id: ADHOC-2026-06-14-T01 +status: done +priority: medium +state_hub_task_id: "ffe6b8d8-889c-4ec4-8b64-00b77f86e39f" +``` + +`haskelseed` is an Alpine host without `ss`, so `bridge check` reported +reverse tunnel ports as closed even while SSH reverse listeners were present. +Updated diagnostics to fall back from `ss` to `netstat` and then +`/proc/net/tcp`/`tcp6`. Also fixed local-direction diagnostics so +`nix-daemon-haskelseed` checks the local `-L` listener instead of probing a +remote reverse port. + +Verification: + +- `state-hub-haskelseed` responded through `127.0.0.1:18000/state/health`. +- `bridge check --json` reported all configured tunnels `ok: true`. +- `python3 -m pytest tests/test_cli.py tests/test_diagnostics.py` passed. + +## Make default target safe and add setup + +```task +id: ADHOC-2026-06-14-T02 +status: done +priority: medium +state_hub_task_id: "3b932955-0d75-4b95-9821-92bfa2dadbd0" +``` + +Changed `make` to default to a help listing that only shows targets with +`##` comments. Added `make setup` to run `uv sync --all-groups` and reinstall +the editable `bridge` CLI wrapper through `uv tool install -e . --force`. + +Verification: + +- `uv sync --all-groups` succeeded and installed the project environment. +- `make` listed targets only and did not run tests or setup. +- `make setup` succeeded and installed the `bridge` executable. +- `make test` passed all 235 tests. +- `make lint` passed.