299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""
|
|
SSH-based device discovery: connects to each rack PC, reads its caps config,
|
|
extracts plugin URIs and classifies discovered network devices.
|
|
|
|
Discovery chain:
|
|
Object DB → racks table (host IPs) → SSH per rack → parse config → upsert devices
|
|
|
|
Limitations (devices not discoverable automatically):
|
|
- Serial/USB devices (no network URI)
|
|
- RODOS-8 relays, Moxa switches (not represented as plugins)
|
|
- Physical MikroTik router (not in rack configs)
|
|
"""
|
|
|
|
import re
|
|
from configparser import RawConfigParser
|
|
from dataclasses import dataclass, field
|
|
from io import StringIO
|
|
from urllib.parse import urlparse
|
|
|
|
import asyncssh
|
|
import psycopg
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.device import Device
|
|
from app.models.object import Object
|
|
from app.services.crypto import decrypt
|
|
|
|
# ── URI extraction ────────────────────────────────────────────────────────────
|
|
|
|
_SERIAL_SCHEMES = {"serial", "com"}
|
|
_SERVICE_HOSTS = {"localhost", "127.0.0.1"}
|
|
|
|
def _extract_ip(uri: str) -> str | None:
|
|
"""Return IP from a URI string, or None if not a network device."""
|
|
if not uri:
|
|
return None
|
|
try:
|
|
parsed = urlparse(uri)
|
|
scheme = (parsed.scheme or "").lower()
|
|
if scheme in _SERIAL_SCHEMES:
|
|
return None
|
|
hostname = parsed.hostname
|
|
if not hostname or hostname in _SERVICE_HOSTS:
|
|
return None
|
|
# Validate it looks like an IP (simple check)
|
|
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname):
|
|
return hostname
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ── Plugin class → device category ───────────────────────────────────────────
|
|
|
|
def _cls_to_category(cls: str) -> str:
|
|
cls_lower = cls.lower()
|
|
if "camera" in cls_lower or "facedetect" in cls_lower:
|
|
return "camera"
|
|
if "controller" in cls_lower or "ovenmk" in cls_lower or "rodos" in cls_lower:
|
|
return "io_board"
|
|
if "payment" in cls_lower or "sberpilot" in cls_lower or "payx" in cls_lower:
|
|
return "bank_terminal"
|
|
if "payonline" in cls_lower or "kkt" in cls_lower or "fiscal" in cls_lower:
|
|
return "cash_register"
|
|
return "other"
|
|
|
|
|
|
def _cls_to_role(cls: str, plugin_name: str) -> str:
|
|
cls_lower = cls.lower()
|
|
name_lower = plugin_name.lower()
|
|
if "camera" in cls_lower or "camera" in name_lower:
|
|
if "grz" in name_lower or "plate" in name_lower or "lpr" in name_lower or "frame" in name_lower:
|
|
return "plate"
|
|
if "face" in name_lower or "lico" in name_lower:
|
|
return "face"
|
|
return "overview"
|
|
return "other"
|
|
|
|
|
|
# Plugins whose URIs point at the CAPS server itself, not at separate devices
|
|
_SKIP_PLUGIN_PREFIXES = ("mnemo", "processor", "recognition", "photo", "voice")
|
|
|
|
|
|
# ── Config parsing ────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class DiscoveredDevice:
|
|
ip: str
|
|
category: str
|
|
role: str
|
|
plugin_name: str
|
|
cls: str
|
|
|
|
|
|
def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]:
|
|
"""Extract discovered network devices from a caps config file."""
|
|
parser = RawConfigParser()
|
|
parser.read_string(config_text)
|
|
|
|
discovered: list[DiscoveredDevice] = []
|
|
seen_ips: set[str] = set()
|
|
|
|
for section in parser.sections():
|
|
if not section.startswith("plugin:"):
|
|
continue
|
|
|
|
plugin_name = section[len("plugin:"):]
|
|
if any(plugin_name.startswith(prefix) for prefix in _SKIP_PLUGIN_PREFIXES):
|
|
continue
|
|
|
|
cls = parser.get(section, "cls", fallback="")
|
|
if not cls:
|
|
continue
|
|
|
|
# Collect all URI-like values in this section
|
|
uri_fields = ("uri", "frame_uri", "host")
|
|
for field_name in uri_fields:
|
|
raw = parser.get(section, field_name, fallback=None)
|
|
if not raw:
|
|
continue
|
|
ip = _extract_ip(raw)
|
|
if not ip or ip == rack_host or ip in seen_ips:
|
|
continue
|
|
seen_ips.add(ip)
|
|
discovered.append(DiscoveredDevice(
|
|
ip=ip,
|
|
category=_cls_to_category(cls),
|
|
role=_cls_to_role(cls, plugin_name),
|
|
plugin_name=plugin_name,
|
|
cls=cls,
|
|
))
|
|
|
|
return discovered
|
|
|
|
|
|
# ── SSH operations ────────────────────────────────────────────────────────────
|
|
|
|
async def _read_remote_config(
|
|
host: str,
|
|
port: int,
|
|
username: str,
|
|
password: str,
|
|
config_path: str,
|
|
) -> str:
|
|
async with asyncssh.connect(
|
|
host,
|
|
port=port,
|
|
username=username,
|
|
password=password,
|
|
known_hosts=None,
|
|
connect_timeout=10,
|
|
) as conn:
|
|
result = await conn.run(f"cat {config_path}", check=True)
|
|
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,
|
|
password=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
|
|
)
|
|
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"
|
|
)
|
|
return await _fetch_rack_hosts(dsn, obj.db_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)
|
|
|
|
|
|
async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str]]:
|
|
async with await psycopg.AsyncConnection.connect(dsn) as conn:
|
|
cursor = await conn.execute(
|
|
f"SELECT id, data->>'host' AS host FROM \"{table}\" WHERE data->>'host' IS NOT NULL"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [(str(r[0]), str(r[1])) for r in rows if r[1]]
|
|
|
|
|
|
# ── Main entry point ──────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class DiscoveryResult:
|
|
created: int = 0
|
|
updated: int = 0
|
|
skipped: int = 0
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
async def discover_via_ssh(
|
|
db: AsyncSession,
|
|
obj: Object,
|
|
config_path: str = "/etc/caps/caps.conf",
|
|
) -> 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")
|
|
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")
|
|
return result
|
|
|
|
# Step 1: get rack hosts from object's DB
|
|
try:
|
|
rack_hosts = await _get_rack_hosts(obj, db_password)
|
|
except Exception as exc:
|
|
result.errors.append(f"Cannot fetch rack hosts from object DB: {exc}")
|
|
return result
|
|
|
|
if not rack_hosts:
|
|
result.errors.append("No racks with host IPs found in object DB")
|
|
return result
|
|
|
|
# Step 2: SSH into each rack, parse config, upsert devices
|
|
for external_id, rack_host in rack_hosts:
|
|
# Resolve config_path override from the embedded device's connection_params
|
|
existing_rack_pc = (await db.execute(
|
|
select(Device).where(
|
|
Device.object_id == obj.id,
|
|
Device.ip == rack_host,
|
|
)
|
|
)).scalar_one_or_none()
|
|
|
|
effective_config_path = config_path
|
|
if existing_rack_pc and existing_rack_pc.connection_params:
|
|
effective_config_path = existing_rack_pc.connection_params.get(
|
|
"config_path", config_path
|
|
)
|
|
|
|
try:
|
|
config_text = await _read_remote_config(
|
|
host=rack_host,
|
|
port=obj.ssh_port or 22,
|
|
username=obj.ssh_user,
|
|
password=ssh_password,
|
|
config_path=effective_config_path,
|
|
)
|
|
except Exception as exc:
|
|
result.errors.append(f"SSH to {rack_host} failed: {exc}")
|
|
continue
|
|
|
|
devices_found = _parse_config(config_text, rack_host)
|
|
|
|
for found in devices_found:
|
|
try:
|
|
existing = (await db.execute(
|
|
select(Device).where(
|
|
Device.object_id == obj.id,
|
|
Device.ip == found.ip,
|
|
)
|
|
)).scalar_one_or_none()
|
|
|
|
if existing is not None:
|
|
changed = False
|
|
if existing.category != found.category and existing.source == "auto_ssh":
|
|
existing.category = found.category
|
|
changed = True
|
|
if existing.role == "other" and found.role != "other":
|
|
existing.role = found.role
|
|
changed = True
|
|
result.updated += 1 if changed else 0
|
|
result.skipped += 0 if changed else 1
|
|
else:
|
|
db.add(Device(
|
|
object_id=obj.id,
|
|
ip=found.ip,
|
|
category=found.category,
|
|
role=found.role,
|
|
source="auto_ssh",
|
|
device_meta={"plugin": found.plugin_name, "cls": found.cls},
|
|
))
|
|
result.created += 1
|
|
except Exception as exc:
|
|
result.errors.append(f"Error upserting {found.ip}: {exc}")
|
|
|
|
return result
|