From 48196baed5151497ec136444a647af1de23f5784 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 00:00:58 +0300 Subject: [PATCH] fix ssh --- backend/app/services/device_discovery.py | 170 +++++++++++++++++++---- frontend/src/hooks/useMonitorSSE.ts | 5 +- 2 files changed, 146 insertions(+), 29 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 77dc051..12ae5cc 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -155,32 +155,93 @@ async def _read_remote_config( return result.stdout -async def _get_rack_hosts(obj: Object, db_password: str) -> list[tuple[str, str]]: - """Return list of (external_id, host_ip) from the object's racks table.""" - if obj.db_via_tunnel and obj.server_ip and obj.ssh_user and obj.ssh_pass_enc: - ssh_password = decrypt(obj.ssh_pass_enc) - async with asyncssh.connect( - obj.server_ip, - port=obj.ssh_port or 22, - username=obj.ssh_user, +def _parse_db_uri(uri: str) -> dict | None: + """Parse postgresql+psycopg2://user:pass@host:port/dbname → dict.""" + try: + parsed = urlparse(uri) + if not parsed.hostname: + return None + return { + "host": parsed.hostname, + "port": parsed.port or 5432, + "user": parsed.username or "", + "password": parsed.password or "", + "dbname": (parsed.path or "").lstrip("/"), + } + except Exception: + return None + + +async def _autodetect_db_from_server( + server_ip: str, + ssh_port: int, + ssh_user: str, + ssh_password: str, + server_config_path: str = "/etc/caps/config.ini", +) -> dict | None: + """SSH into server, read its caps.conf, extract DB connection params.""" + try: + config_text = await _read_remote_config( + host=server_ip, + port=ssh_port, + username=ssh_user, password=ssh_password, + config_path=server_config_path, + ) + except Exception: + return None + + parser = RawConfigParser() + parser.read_string(config_text) + + db_uri = parser.get("server", "db_uri", fallback=None) + if db_uri: + return _parse_db_uri(db_uri) + + # Fallback: try [database] section with individual fields + if parser.has_section("database"): + return { + "host": parser.get("database", "host", fallback=None), + "port": int(parser.get("database", "port", fallback="5432")), + "user": parser.get("database", "user", fallback=None), + "password": parser.get("database", "password", fallback=None), + "dbname": parser.get("database", "name", fallback=None), + } + return None + + +async def _get_rack_hosts_direct( + host: str, + port: int, + user: str, + password: str, + dbname: str, + table: str, + via_tunnel: bool = False, + tunnel_host: str | None = None, + tunnel_ssh_port: int = 22, + tunnel_ssh_user: str | None = None, + tunnel_ssh_password: str | None = None, +) -> list[tuple[str, str]]: + """Return list of (external_id, host_ip) from the racks table.""" + if via_tunnel and tunnel_host and tunnel_ssh_user and tunnel_ssh_password: + async with asyncssh.connect( + tunnel_host, + port=tunnel_ssh_port, + username=tunnel_ssh_user, + password=tunnel_ssh_password, known_hosts=None, ) as tunnel: - listener = await tunnel.forward_local_port( - "127.0.0.1", 0, obj.db_host, obj.db_port or 5432 - ) + listener = await tunnel.forward_local_port("127.0.0.1", 0, host, port) local_port = listener.get_port() dsn = ( - f"host=127.0.0.1 port={local_port} dbname={obj.db_name} " - f"user={obj.db_user} password={db_password} connect_timeout=10" + f"host=127.0.0.1 port={local_port} dbname={dbname} " + f"user={user} password={password} connect_timeout=10" ) - return await _fetch_rack_hosts(dsn, obj.db_table) + return await _fetch_rack_hosts(dsn, table) else: - dsn = ( - f"host={obj.db_host} port={obj.db_port or 5432} dbname={obj.db_name} " - f"user={obj.db_user} password={db_password} connect_timeout=10" - ) - return await _fetch_rack_hosts(dsn, obj.db_table) + dsn = f"host={host} port={port} dbname={dbname} user={user} password={password} connect_timeout=10" + return await _fetch_rack_hosts(dsn, table) async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str]]: @@ -205,27 +266,82 @@ class DiscoveryResult: async def discover_via_ssh( db: AsyncSession, obj: Object, - config_path: str = "/etc/caps/caps.conf", + config_path: str = "/etc/caps/config.ini", ) -> DiscoveryResult: result = DiscoveryResult() - if not all([obj.db_host, obj.db_name, obj.db_user, obj.db_pass_enc, obj.db_table]): - result.errors.append("Object DB connection is not fully configured") - return result if not obj.ssh_user or not obj.ssh_pass_enc: - result.errors.append("SSH credentials not configured on this object") + result.errors.append("SSH credentials not configured on this object (ssh_user / ssh_password)") return result try: - db_password = decrypt(obj.db_pass_enc) ssh_password = decrypt(obj.ssh_pass_enc) except Exception: - result.errors.append("Failed to decrypt stored credentials") + result.errors.append("Failed to decrypt SSH password") + return result + + ssh_port = obj.ssh_port or 22 + + # ── Resolve DB connection ───────────────────────────────────────────────── + # Priority 1: fully configured DB on the object + # Priority 2: autodetect from server config via SSH (only server_ip needed) + + db_host: str | None = obj.db_host + db_port: int = obj.db_port or 5432 + db_name: str | None = obj.db_name + db_user: str | None = obj.db_user + db_password: str | None = None + db_table: str | None = obj.db_table + + if db_host and db_name and db_user and obj.db_pass_enc and db_table: + try: + db_password = decrypt(obj.db_pass_enc) + except Exception: + result.errors.append("Failed to decrypt DB password") + return result + elif obj.server_ip: + # Autodetect: SSH into server, read its caps.conf to get DB URI + detected = await _autodetect_db_from_server( + server_ip=obj.server_ip, + ssh_port=ssh_port, + ssh_user=obj.ssh_user, + ssh_password=ssh_password, + ) + if not detected or not all([detected.get("host"), detected.get("user"), detected.get("dbname")]): + result.errors.append( + "DB connection not configured and could not be auto-detected from server config. " + "Fill in the DB connection fields on the object or ensure /etc/caps/config.ini " + "on the server contains a [server] db_uri field." + ) + return result + db_host = detected["host"] + db_port = detected["port"] + db_user = detected["user"] + db_password = detected["password"] + db_name = detected["dbname"] + db_table = db_table or obj.db_table or "racks" + else: + result.errors.append( + "DB connection not configured. Either fill in the DB fields on the object " + "or set server_ip so the DB can be auto-detected via SSH." + ) return result # Step 1: get rack hosts from object's DB try: - rack_hosts = await _get_rack_hosts(obj, db_password) + rack_hosts = await _get_rack_hosts_direct( + host=db_host, + port=db_port, + user=db_user, + password=db_password or "", + dbname=db_name, + table=db_table, + via_tunnel=obj.db_via_tunnel, + tunnel_host=obj.server_ip, + tunnel_ssh_port=ssh_port, + tunnel_ssh_user=obj.ssh_user, + tunnel_ssh_password=ssh_password, + ) except Exception as exc: result.errors.append(f"Cannot fetch rack hosts from object DB: {exc}") return result diff --git a/frontend/src/hooks/useMonitorSSE.ts b/frontend/src/hooks/useMonitorSSE.ts index 2a1822a..316e106 100644 --- a/frontend/src/hooks/useMonitorSSE.ts +++ b/frontend/src/hooks/useMonitorSSE.ts @@ -64,9 +64,10 @@ export function useMonitorSSE(handlers: MonitorHandlers) { // ignore parse errors } }, - onerror() { - // fetchEventSource retries automatically; abort only on explicit error + onerror(err) { + // throw — stops fetchEventSource from auto-retrying ctrl.abort() + throw err }, })