diff --git a/backend/alembic/versions/0011_credentials.py b/backend/alembic/versions/0011_credentials.py new file mode 100644 index 0000000..b51e658 --- /dev/null +++ b/backend/alembic/versions/0011_credentials.py @@ -0,0 +1,114 @@ +"""SSH key registry and credential management + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-04-09 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0011" +down_revision = "0010" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- SSH key file registry --- + op.create_table( + "ssh_keys", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(100), nullable=False, unique=True), + sa.Column("path", sa.String(500), nullable=False), + sa.Column("description", sa.String(1000), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + + # --- Ordered SSH credential list per object --- + op.create_table( + "object_ssh_credentials", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "object_id", + sa.Integer, + sa.ForeignKey("objects.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("type", sa.String(10), nullable=False), + sa.Column("username", sa.String(100), nullable=False), + sa.Column("password_enc", sa.String(500), nullable=True), + sa.Column( + "ssh_key_id", + sa.Integer, + sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("priority", sa.Integer, nullable=False, server_default="0"), + sa.CheckConstraint("type IN ('password', 'key')", name="ck_obj_ssh_cred_type"), + ) + op.create_index( + "ix_obj_ssh_creds_object", "object_ssh_credentials", ["object_id"] + ) + + # --- Per-category credentials per object (SSH + HTTP) --- + op.create_table( + "object_category_credentials", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "object_id", + sa.Integer, + sa.ForeignKey("objects.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("category", sa.String(20), nullable=False), + sa.Column("protocol", sa.String(10), nullable=False), + sa.Column("type", sa.String(10), nullable=False, server_default="password"), + sa.Column("username", sa.String(100), nullable=False), + sa.Column("password_enc", sa.String(500), nullable=True), + sa.Column( + "ssh_key_id", + sa.Integer, + sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("priority", sa.Integer, nullable=False, server_default="0"), + sa.CheckConstraint( + "protocol IN ('ssh', 'http')", name="ck_obj_cat_cred_protocol" + ), + sa.CheckConstraint("type IN ('password', 'key')", name="ck_obj_cat_cred_type"), + ) + op.create_index( + "ix_obj_cat_creds_object", "object_category_credentials", ["object_id"] + ) + + # --- Device-level additions --- + op.add_column( + "devices", + sa.Column( + "ssh_key_id_override", + sa.Integer, + sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.add_column("devices", sa.Column("ssh_last_auth", JSONB, nullable=True)) + + +def downgrade() -> None: + op.drop_column("devices", "ssh_last_auth") + op.drop_column("devices", "ssh_key_id_override") + + op.drop_index("ix_obj_cat_creds_object", table_name="object_category_credentials") + op.drop_table("object_category_credentials") + + op.drop_index("ix_obj_ssh_creds_object", table_name="object_ssh_credentials") + op.drop_table("object_ssh_credentials") + + op.drop_table("ssh_keys") diff --git a/backend/app/main.py b/backend/app/main.py index 1eb4838..3239193 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.openapi.utils import get_openapi from app.middleware.auth import AuthMiddleware from app.plugins.loader import load_plugins -from app.routers import admin, alerts, auth, devices, events, global_devices, objects, racks, snapshots, tags, tasks, zones +from app.routers import admin, alerts, auth, devices, events, global_devices, objects, racks, snapshots, ssh_keys, tags, tasks, zones from app.workers.monitor import start_monitor, stop_monitor from app.workers.startup import recover_stale_tasks @@ -53,6 +53,7 @@ def create_app() -> FastAPI: app.include_router(racks.router) app.include_router(zones.router) app.include_router(tags.router) + app.include_router(ssh_keys.router) app.include_router(admin.router) app.include_router(alerts.router) app.include_router(snapshots.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d8d58a4..ac13a46 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ from .zone import Zone from .snapshot import ConfigSnapshot, SnapshotContent from .alert import Alert from .plugin import Plugin +from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential __all__ = [ "Base", @@ -28,4 +29,7 @@ __all__ = [ "SnapShotContent", "Alert", "Plugin", + "SSHKey", + "ObjectSSHCredential", + "ObjectCategoryCredential", ] diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 66fbbf0..54a2da6 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -59,6 +59,14 @@ class Device(Base, TimestampMixin): ssh_user_override: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) ssh_pass_override_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) ssh_port_override: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + # Override with a specific SSH key for this device (NULL = follow object/category rules) + ssh_key_id_override: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True + ) + # Last successful SSH auth method; tried first on next connection + # Format: {"type": "key", "key_id": 2, "username": "root"} + # or {"type": "password", "cred_id": 5, "username": "caps"} + ssh_last_auth: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) # Device-specific metadata (Mikrotik API port, RTSP URL, etc.) device_meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) @@ -96,6 +104,9 @@ class Device(Base, TimestampMixin): notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + ssh_key_override: Mapped[Optional["SSHKey"]] = relationship( + "SSHKey", foreign_keys=[ssh_key_id_override] + ) object: Mapped["Object"] = relationship("Object", back_populates="devices") rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices") config_snapshots: Mapped[list["ConfigSnapshot"]] = relationship( diff --git a/backend/app/models/object.py b/backend/app/models/object.py index a5dbd11..bc13cc3 100644 --- a/backend/app/models/object.py +++ b/backend/app/models/object.py @@ -56,3 +56,15 @@ class Object(Base, TimestampMixin): "Zone", back_populates="object", cascade="all, delete-orphan" ) tags: Mapped[list["Tag"]] = relationship("Tag", secondary=object_tags) + ssh_credentials: Mapped[list["ObjectSSHCredential"]] = relationship( + "ObjectSSHCredential", + back_populates="object", + cascade="all, delete-orphan", + order_by="ObjectSSHCredential.priority", + ) + category_credentials: Mapped[list["ObjectCategoryCredential"]] = relationship( + "ObjectCategoryCredential", + back_populates="object", + cascade="all, delete-orphan", + order_by="ObjectCategoryCredential.priority", + ) diff --git a/backend/app/models/ssh_key.py b/backend/app/models/ssh_key.py new file mode 100644 index 0000000..1817975 --- /dev/null +++ b/backend/app/models/ssh_key.py @@ -0,0 +1,83 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + + +class SSHKey(Base): + """Registry of SSH private key files stored on the server filesystem.""" + + __tablename__ = "ssh_keys" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) + path: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +class ObjectSSHCredential(Base): + """Ordered list of SSH credentials (password or key) for an object. + + Tried in ascending priority order when connecting to hosts of this object. + type='password': username + password_enc required. + type='key': username + ssh_key_id required. + """ + + __tablename__ = "object_ssh_credentials" + __table_args__ = ( + CheckConstraint("type IN ('password', 'key')", name="ck_obj_ssh_cred_type"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + object_id: Mapped[int] = mapped_column( + Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False, index=True + ) + type: Mapped[str] = mapped_column(String(10), nullable=False) + username: Mapped[str] = mapped_column(String(100), nullable=False) + password_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + ssh_key_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True + ) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + object: Mapped["Object"] = relationship("Object", back_populates="ssh_credentials") + ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey") + + +class ObjectCategoryCredential(Base): + """Per-category credentials on an object. + + protocol='ssh': SSH access to devices of this category. + protocol='http': HTTP/API access (cameras, MikroTik, Proxmox, etc.). + type='password': username + password_enc required. + type='key': username + ssh_key_id required (ssh only). + """ + + __tablename__ = "object_category_credentials" + __table_args__ = ( + CheckConstraint("protocol IN ('ssh', 'http')", name="ck_obj_cat_cred_protocol"), + CheckConstraint("type IN ('password', 'key')", name="ck_obj_cat_cred_type"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + object_id: Mapped[int] = mapped_column( + Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False, index=True + ) + category: Mapped[str] = mapped_column(String(20), nullable=False) + protocol: Mapped[str] = mapped_column(String(10), nullable=False) + type: Mapped[str] = mapped_column(String(10), nullable=False, default="password") + username: Mapped[str] = mapped_column(String(100), nullable=False) + password_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + ssh_key_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True + ) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + object: Mapped["Object"] = relationship("Object", back_populates="category_credentials") + ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey") diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index 8dadc8b..be28f03 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -88,11 +88,13 @@ async def create_device( ): await _get_object(db, obj_id) - data = body.model_dump(exclude={"ssh_password_override"}) + data = body.model_dump(exclude={"ssh_password_override", "ssh_key_id_override"}) device = Device(object_id=obj_id, **data) if body.ssh_password_override: device.ssh_pass_override_enc = crypto.encrypt(body.ssh_password_override) + if body.ssh_key_id_override is not None: + device.ssh_key_id_override = body.ssh_key_id_override db.add(device) await db.flush() @@ -239,7 +241,7 @@ async def update_device( ): device = await _get_device(db, obj_id, device_id) - data = body.model_dump(exclude_none=True, exclude={"ssh_password_override"}) + data = body.model_dump(exclude_none=True, exclude={"ssh_password_override", "ssh_key_id_override"}) for field, value in data.items(): setattr(device, field, value) @@ -247,6 +249,8 @@ async def update_device( device.ssh_pass_override_enc = ( crypto.encrypt(body.ssh_password_override) if body.ssh_password_override else None ) + if body.ssh_key_id_override is not None: + device.ssh_key_id_override = body.ssh_key_id_override if body.ssh_key_id_override != 0 else None await db.flush() await db.refresh(device) diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 08c01cc..f2f264c 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_current_user, get_db from app.models.device import Device from app.models.object import Object +from app.models.ssh_key import ObjectSSHCredential, ObjectCategoryCredential from app.models.tag import Tag from app.models.user import User from app.schemas.admin import SheetsSyncRequest @@ -45,7 +46,13 @@ def _apply_credentials(obj: Object, db_password: str | None, ssh_password: str | async def _load_object(db: AsyncSession, obj_id: int) -> Object: result = await db.execute( - select(Object).options(selectinload(Object.tags)).where(Object.id == obj_id) + select(Object) + .options( + selectinload(Object.tags), + selectinload(Object.ssh_credentials), + selectinload(Object.category_credentials), + ) + .where(Object.id == obj_id) ) obj = result.scalar_one_or_none() if obj is None: @@ -53,6 +60,41 @@ async def _load_object(db: AsyncSession, obj_id: int) -> Object: return obj +async def _apply_ssh_credentials(db: AsyncSession, obj: Object, cred_list: list, cat_list: list | None) -> None: + """Replace SSH credentials and category credentials for an object.""" + from app.schemas.ssh_key import ObjectSSHCredentialCreate, ObjectCategoryCredentialCreate + + # Delete existing and re-create (cascade via relationship) + for existing in list(obj.ssh_credentials): + await db.delete(existing) + for cred_data in cred_list: + new_cred = ObjectSSHCredential( + object_id=obj.id, + type=cred_data.type, + username=cred_data.username, + password_enc=crypto.encrypt(cred_data.password) if cred_data.password else None, + ssh_key_id=cred_data.ssh_key_id, + priority=cred_data.priority, + ) + db.add(new_cred) + + if cat_list is not None: + for existing in list(obj.category_credentials): + await db.delete(existing) + for cat_data in cat_list: + new_cat = ObjectCategoryCredential( + object_id=obj.id, + category=cat_data.category, + protocol=cat_data.protocol, + type=cat_data.type, + username=cat_data.username, + password_enc=crypto.encrypt(cat_data.password) if cat_data.password else None, + ssh_key_id=cat_data.ssh_key_id, + priority=cat_data.priority, + ) + db.add(new_cat) + + @router.get("/meta", response_model=ObjectMetaResponse) async def get_objects_meta( db: AsyncSession = Depends(get_db), @@ -80,7 +122,11 @@ async def list_objects( db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - query = select(Object).options(selectinload(Object.tags)) + query = select(Object).options( + selectinload(Object.tags), + selectinload(Object.ssh_credentials), + selectinload(Object.category_credentials), + ) if is_active is not None: query = query.where(Object.is_active == is_active) if tag_id is not None: @@ -111,7 +157,7 @@ async def create_object( db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - data = body.model_dump(exclude={"db_password", "ssh_password", "tag_ids"}) + data = body.model_dump(exclude={"db_password", "ssh_password", "tag_ids", "ssh_credentials", "category_credentials"}) obj = Object(**data) _apply_credentials(obj, body.db_password, body.ssh_password) @@ -121,7 +167,12 @@ async def create_object( db.add(obj) await db.flush() - await db.refresh(obj, ["tags"]) + + if body.ssh_credentials: + await _apply_ssh_credentials(db, obj, body.ssh_credentials, body.category_credentials) + await db.flush() + + await db.refresh(obj, ["tags", "ssh_credentials", "category_credentials"]) return obj @@ -143,7 +194,7 @@ async def update_object( ): obj = await _load_object(db, obj_id) - data = body.model_dump(exclude_none=True, exclude={"db_password", "ssh_password", "tag_ids"}) + data = body.model_dump(exclude_none=True, exclude={"db_password", "ssh_password", "tag_ids", "ssh_credentials", "category_credentials"}) for field, value in data.items(): setattr(obj, field, value) @@ -153,6 +204,9 @@ async def update_object( tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids))) obj.tags = list(tags_result.scalars().all()) + if body.ssh_credentials is not None: + await _apply_ssh_credentials(db, obj, body.ssh_credentials, body.category_credentials) + await db.flush() await db.refresh(obj) return obj diff --git a/backend/app/routers/ssh_keys.py b/backend/app/routers/ssh_keys.py new file mode 100644 index 0000000..eb23a41 --- /dev/null +++ b/backend/app/routers/ssh_keys.py @@ -0,0 +1,77 @@ +import os + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.models.ssh_key import SSHKey +from app.models.user import User +from app.schemas.ssh_key import SSHKeyCreate, SSHKeyRead, SSHKeyUpdate + +router = APIRouter(prefix="/api/v1/ssh-keys", tags=["ssh-keys"]) + + +@router.get("", response_model=list[SSHKeyRead]) +async def list_ssh_keys( + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + result = await db.execute(select(SSHKey).order_by(SSHKey.name)) + return result.scalars().all() + + +@router.post("", response_model=SSHKeyRead, status_code=status.HTTP_201_CREATED) +async def create_ssh_key( + body: SSHKeyCreate, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + if not os.path.isfile(body.path): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Key file not found on server: {body.path}", + ) + key = SSHKey(**body.model_dump()) + db.add(key) + await db.flush() + await db.refresh(key) + return key + + +@router.patch("/{key_id}", response_model=SSHKeyRead) +async def update_ssh_key( + key_id: int, + body: SSHKeyUpdate, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + result = await db.execute(select(SSHKey).where(SSHKey.id == key_id)) + key = result.scalar_one_or_none() + if key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SSH key not found") + + data = body.model_dump(exclude_none=True) + if "path" in data and not os.path.isfile(data["path"]): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Key file not found on server: {data['path']}", + ) + for field, value in data.items(): + setattr(key, field, value) + await db.flush() + await db.refresh(key) + return key + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ssh_key( + key_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + result = await db.execute(select(SSHKey).where(SSHKey.id == key_id)) + key = result.scalar_one_or_none() + if key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SSH key not found") + await db.delete(key) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index b7ef0f7..46da3a1 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -19,6 +19,7 @@ class DeviceCreate(BaseModel): ssh_user_override: Optional[str] = None ssh_password_override: Optional[str] = None # plaintext, encrypted on write ssh_port_override: Optional[int] = None + ssh_key_id_override: Optional[int] = None device_meta: dict[str, Any] = {} monitor_enabled: bool = False notes: Optional[str] = None @@ -51,6 +52,7 @@ class DeviceUpdate(BaseModel): ssh_user_override: Optional[str] = None ssh_password_override: Optional[str] = None ssh_port_override: Optional[int] = None + ssh_key_id_override: Optional[int] = None device_meta: Optional[dict[str, Any]] = None monitor_enabled: Optional[bool] = None notes: Optional[str] = None @@ -74,6 +76,8 @@ class DeviceRead(BaseModel): rack_type: Optional[str] = None ssh_user_override: Optional[str] = None ssh_port_override: Optional[int] = None + ssh_key_id_override: Optional[int] = None + ssh_last_auth: Optional[dict[str, Any]] = None device_meta: dict[str, Any] = {} capabilities: list[str] = [] monitor_enabled: bool diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py index 214093d..8a4d0c7 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -4,6 +4,12 @@ from typing import Optional from pydantic import BaseModel from app.schemas.tag import TagRead +from app.schemas.ssh_key import ( + ObjectSSHCredentialCreate, + ObjectSSHCredentialRead, + ObjectCategoryCredentialCreate, + ObjectCategoryCredentialRead, +) class ObjectCreate(BaseModel): @@ -29,6 +35,8 @@ class ObjectCreate(BaseModel): notes: Optional[str] = None is_active: bool = True tag_ids: list[int] = [] + ssh_credentials: list[ObjectSSHCredentialCreate] = [] + category_credentials: list[ObjectCategoryCredentialCreate] = [] class ObjectUpdate(BaseModel): @@ -52,6 +60,8 @@ class ObjectUpdate(BaseModel): notes: Optional[str] = None is_active: Optional[bool] = None tag_ids: Optional[list[int]] = None + ssh_credentials: Optional[list[ObjectSSHCredentialCreate]] = None + category_credentials: Optional[list[ObjectCategoryCredentialCreate]] = None class ObjectRead(BaseModel): @@ -76,6 +86,8 @@ class ObjectRead(BaseModel): created_at: datetime updated_at: datetime tags: list[TagRead] = [] + ssh_credentials: list[ObjectSSHCredentialRead] = [] + category_credentials: list[ObjectCategoryCredentialRead] = [] device_count: int = 0 model_config = {"from_attributes": True} diff --git a/backend/app/schemas/ssh_key.py b/backend/app/schemas/ssh_key.py new file mode 100644 index 0000000..25dbf08 --- /dev/null +++ b/backend/app/schemas/ssh_key.py @@ -0,0 +1,88 @@ +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, model_validator + + +# --- SSH Key file registry --- + +class SSHKeyCreate(BaseModel): + name: str + path: str + description: Optional[str] = None + + +class SSHKeyUpdate(BaseModel): + name: Optional[str] = None + path: Optional[str] = None + description: Optional[str] = None + + +class SSHKeyRead(BaseModel): + id: int + name: str + path: str + description: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +# --- Object SSH credential list --- + +class ObjectSSHCredentialCreate(BaseModel): + type: Literal["password", "key"] + username: str + password: Optional[str] = None # plaintext, encrypted on write (for type=password) + ssh_key_id: Optional[int] = None # for type=key + priority: int = 0 + + @model_validator(mode="after") + def check_fields(self) -> "ObjectSSHCredentialCreate": + if self.type == "password" and not self.password: + raise ValueError("password is required for type=password") + if self.type == "key" and self.ssh_key_id is None: + raise ValueError("ssh_key_id is required for type=key") + return self + + +class ObjectSSHCredentialRead(BaseModel): + id: int + type: str + username: str + ssh_key_id: Optional[int] = None + priority: int + + model_config = {"from_attributes": True} + + +# --- Per-category credentials --- + +class ObjectCategoryCredentialCreate(BaseModel): + category: str + protocol: Literal["ssh", "http"] + type: Literal["password", "key"] = "password" + username: str + password: Optional[str] = None # plaintext, encrypted on write + ssh_key_id: Optional[int] = None # for type=key, ssh only + priority: int = 0 + + @model_validator(mode="after") + def check_fields(self) -> "ObjectCategoryCredentialCreate": + if self.type == "password" and not self.password: + raise ValueError("password is required for type=password") + if self.type == "key" and self.ssh_key_id is None: + raise ValueError("ssh_key_id is required for type=key") + return self + + +class ObjectCategoryCredentialRead(BaseModel): + id: int + category: str + protocol: str + type: str + username: str + ssh_key_id: Optional[int] = None + priority: int + + model_config = {"from_attributes": True} diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 236a2df..6a0966e 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -11,6 +11,7 @@ Limitations (devices not discoverable automatically): - Physical MikroTik router (not in rack configs) """ +import asyncio import logging import re from collections.abc import Callable, Awaitable @@ -27,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.device import Device from app.models.object import Object -from app.services.crypto import decrypt +from app.services.crypto import decrypt, encrypt logger = logging.getLogger(__name__) @@ -36,8 +37,12 @@ logger = logging.getLogger(__name__) _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.""" +def _extract_device_info(uri: str) -> tuple[str, str, str] | None: + """Return (ip, username, password) from a URI, or None if not a network device. + + Username and password are extracted from URI credentials (http://user:pass@ip). + They may be empty strings if not present in the URI. + """ if not uri: return None try: @@ -50,12 +55,18 @@ def _extract_ip(uri: str) -> str | None: return None # Validate it looks like an IP (simple check) if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname): - return hostname + return hostname, parsed.username or "", parsed.password or "" except Exception: pass return None +def _extract_ip(uri: str) -> str | None: + """Return IP from a URI string, or None if not a network device.""" + info = _extract_device_info(uri) + return info[0] if info else None + + # ── Plugin class → device category ─────────────────────────────────────────── def _cls_to_category(cls: str) -> str: @@ -96,6 +107,8 @@ class DiscoveredDevice: role: str plugin_name: str cls: str + http_username: str = "" # from URI credentials (e.g. http://user:pass@ip) + http_password: str = "" def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] | None = None) -> list[DiscoveredDevice]: @@ -144,10 +157,11 @@ def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] | logger.info(f"[ssh_discovery] {field_name}: (not set)") continue logger.info(f"[ssh_discovery] {field_name}='{raw}'") - ip = _extract_ip(raw) - if not ip: + info = _extract_device_info(raw) + if not info: logger.info(f"[ssh_discovery] → IGNORED (not a valid network IP)") continue + ip, http_user, http_pass = info if ip in all_rack_hosts: logger.info(f"[ssh_discovery] → IGNORED (is a rack server IP: {ip})") continue @@ -156,13 +170,16 @@ def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] | continue seen_ips.add(ip) - logger.info(f"[ssh_discovery] → EXTRACTED {ip} (category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)})") + cred_str = f", http_user={http_user}" if http_user else "" + logger.info(f"[ssh_discovery] → EXTRACTED {ip} (category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)}{cred_str})") discovered.append(DiscoveredDevice( ip=ip, category=_cls_to_category(cls), role=_cls_to_role(cls, plugin_name), plugin_name=plugin_name, cls=cls, + http_username=http_user, + http_password=http_pass, )) found_ip_in_plugin = True @@ -412,6 +429,110 @@ async def _autodetect_db_from_server( return None +async def _autodetect_db_from_server_multi( + server_ip: str, + ssh_port: int, + ssh_options: list, + server_config_path: str = "/etc/caps/config.ini", +) -> dict | None: + """Multi-auth version of _autodetect_db_from_server.""" + try: + config_text = await _ssh_run_multi(server_ip, ssh_port, ssh_options, f"cat {server_config_path}") + except Exception: + return None + + parser = RawConfigParser(strict=False) + parser.read_string(config_text) + + sections_to_check = list(parser.sections()) + ["DEFAULT"] + for section in sections_to_check: + try: + db_uri = parser.get(section, "db_uri", fallback=None) + if db_uri and db_uri.startswith("postgresql"): + return _parse_db_uri(db_uri) + except Exception: + continue + + db_uri = parser.defaults().get("db_uri") + if db_uri and db_uri.startswith("postgresql"): + return _parse_db_uri(db_uri) + return None + + +async def _ssh_run_multi(host: str, port: int, options: list, command: str, timeout: int = 15) -> str: + """Try multiple SSH auth options to run a command; return stdout on success or raise.""" + last_exc: Exception = Exception("No auth options provided") + for opt in options: + try: + connect_kwargs: dict = dict( + host=host, port=port, username=opt.username, + known_hosts=None, connect_timeout=10, + ) + if opt.type == "key": + connect_kwargs["client_keys"] = [opt.key_path] + connect_kwargs["passphrase"] = None + else: + connect_kwargs["password"] = opt.password + async with asyncssh.connect(**connect_kwargs) as conn: + result = await asyncio.wait_for(conn.run(command, check=True), timeout=timeout) + return result.stdout + except Exception as exc: + last_exc = exc + logger.debug(f"[ssh_discovery] Auth {opt.type}/{opt.username} failed for {host}: {exc}") + raise last_exc + + +async def _get_ip_neighbors_multi(host: str, port: int, options: list) -> list[str]: + """Multi-auth version of _get_ip_neighbors.""" + try: + stdout = await _ssh_run_multi(host, port, options, "ip neigh show", timeout=15) + ips = [] + for line in stdout.strip().splitlines(): + parts = line.split() + if not parts: + continue + ip = parts[0] + state = parts[-1] if parts else "" + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip) and state in ("REACHABLE", "DELAY"): + ips.append(ip) + return ips + except Exception as e: + logger.warning(f"[ssh_discovery] ip neigh failed on {host}: {e}") + return [] + + +async def _discover_mikrotik_multi(host: str, port: int, options: list) -> str | None: + """Multi-auth version of _discover_mikrotik_via_ssh_client.""" + try: + stdout = await _ssh_run_multi(host, port, options, "echo $SSH_CLIENT", timeout=15) + ssh_client_output = stdout.strip() + if ssh_client_output: + parts = ssh_client_output.split() + if parts: + return parts[0] + except Exception as e: + logger.warning(f"[ssh_discovery] Failed to discover MikroTik via SSH_CLIENT: {e}") + return None + + +async def _check_if_slave_multi( + host: str, port: int, options: list, master_ip: str, config_path: str +) -> tuple[bool, str, str]: + """Multi-auth version of _check_if_slave.""" + try: + config_text = await _ssh_run_multi(host, port, options, f"cat {config_path}", timeout=15) + parser = RawConfigParser(strict=False) + parser.read_string(config_text) + linking_cls = parser.get("plugin:linking", "cls", fallback="") + linking_uri = parser.get("plugin:linking", "uri", fallback="") + identifier = parser.get("caps.rack", "identifier", fallback="").strip() + if "secondary" in linking_cls.lower() and master_ip in linking_uri: + return True, identifier or host, config_text + except Exception: + pass + return False, "", "" + + async def _get_rack_hosts_direct( host: str, port: int, @@ -494,16 +615,18 @@ async def discover_via_ssh( if progress_cb is not None: await progress_cb(event_type, data) - if not obj.ssh_user or not obj.ssh_pass_enc: - result.errors.append("SSH credentials not configured on this object (ssh_user / ssh_password)") - return result - - try: - ssh_password = decrypt(obj.ssh_pass_enc) - except Exception: - result.errors.append("Failed to decrypt SSH password") + # Build ordered list of SSH auth options for this object + from app.services.ssh import build_object_auth_options, _AuthOption + ssh_options = await build_object_auth_options(obj, db) + + if not ssh_options: + result.errors.append("SSH credentials not configured on this object") return result + # Legacy single-cred vars for calls that still use them (tunnels etc.) + # Use the first option as the primary; fall back to legacy fields + _first = ssh_options[0] + ssh_password = _first.password if _first.type == "password" else "" ssh_port = obj.ssh_port or 22 # ── Resolve DB connection ───────────────────────────────────────────────── @@ -527,22 +650,15 @@ async def discover_via_ssh( elif obj.server_ip: # Autodetect: SSH into server, read its caps.conf to get DB URI await _emit("disc_action", {"message": f"Чтение конфигурации сервера {obj.server_ip}..."}) - detected = await _autodetect_db_from_server( + detected = await _autodetect_db_from_server_multi( server_ip=obj.server_ip, ssh_port=ssh_port, - ssh_user=obj.ssh_user, - ssh_password=ssh_password, + ssh_options=ssh_options, ) if not detected or not all([detected.get("host"), detected.get("user"), detected.get("dbname")]): # Diagnostic: try to read the config and list what sections/keys are there try: - raw = await _read_remote_config( - host=obj.server_ip, - port=ssh_port, - username=obj.ssh_user, - password=ssh_password, - config_path="/etc/caps/config.ini", - ) + raw = await _ssh_run_multi(obj.server_ip, ssh_port, ssh_options, "cat /etc/caps/config.ini") diag_parser = RawConfigParser(strict=False) diag_parser.read_string(raw) found_sections = diag_parser.sections() @@ -728,13 +844,7 @@ async def discover_via_ssh( config_text = None await _emit("disc_action", {"message": f"SSH → {rack_host} ({rack_name})"}) 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, - ) + config_text = await _ssh_run_multi(rack_host, ssh_port, ssh_options, f"cat {effective_config_path}") rack_ssh_ok = True except Exception as exc: result.errors.append(f"SSH to {rack_host} failed: {exc}") @@ -821,7 +931,7 @@ async def discover_via_ssh( continue # no config to parse # Collect ARP neighbours for slave rack discovery (best-effort) - neigh_ips = await _get_ip_neighbors(rack_host, ssh_port, obj.ssh_user, ssh_password) + neigh_ips = await _get_ip_neighbors_multi(rack_host, ssh_port, ssh_options) neighbor_candidates.update(neigh_ips) discovered_device_ips.add(rack_host) # rack PC itself is a known device @@ -911,6 +1021,11 @@ async def discover_via_ssh( role=existing.role, )) else: + # Populate connection_params with HTTP credentials from URI if present + init_connection_params: dict = {} + if found.http_username: + init_connection_params["username"] = found.http_username + init_connection_params["password_enc"] = encrypt(found.http_password) if found.http_password else "" db.add(Device( object_id=obj.id, ip=found.ip, @@ -921,8 +1036,10 @@ async def discover_via_ssh( rack_id=rack_id, location=device_location, device_meta={"plugin": found.plugin_name, "cls": found.cls}, + connection_params=init_connection_params, )) - logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ADDED: category={found.category}, role={found.role}, hostname={hostname}, rack_id={rack_id}, location={device_location}, source=auto_ssh") + cred_note = f", http_user={found.http_username}" if found.http_username else "" + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ADDED: category={found.category}, role={found.role}, hostname={hostname}, rack_id={rack_id}, location={device_location}, source=auto_ssh{cred_note}") result.created += 1 result.actions.append(DeviceAction( ip=found.ip, @@ -985,11 +1102,10 @@ async def discover_via_ssh( slave_config_text = "" master_ip_for_slave = "" for _, master_host, _, _ in rack_hosts: - ok, identifier, slave_cfg = await _check_if_slave( + ok, identifier, slave_cfg = await _check_if_slave_multi( host=candidate_ip, port=ssh_port, - username=obj.ssh_user, - password=ssh_password, + options=ssh_options, master_ip=master_host, config_path=config_path, ) @@ -1093,11 +1209,16 @@ async def discover_via_ssh( select(Device).where(Device.object_id == obj.id, Device.ip == found.ip) )).scalar_one_or_none() if existing is None: + slave_conn_params: dict = {} + if found.http_username: + slave_conn_params["username"] = found.http_username + slave_conn_params["password_enc"] = encrypt(found.http_password) if found.http_password else "" db.add(Device( object_id=obj.id, ip=found.ip, hostname=hostname, category=found.category, role=found.role, source="auto_ssh", rack_id=s_rack_id, location=device_location, device_meta={"plugin": found.plugin_name, "cls": found.cls}, + connection_params=slave_conn_params, )) logger.info(f"[ssh_discovery] {found.ip} ({hostname}) - ADDED from slave {slave_identifier}") result.created += 1 @@ -1123,11 +1244,10 @@ async def discover_via_ssh( await _emit("disc_phase", {"phase": "mikrotik"}) await _emit("disc_action", {"message": "Поиск MikroTik (SSH_CLIENT)..."}) logger.info(f"[ssh_discovery] Step 3: Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}") - mikrotik_ip = await _discover_mikrotik_via_ssh_client( + mikrotik_ip = await _discover_mikrotik_multi( host=obj.server_ip or rack_hosts[0][1], - port=obj.ssh_port or 22, - username=obj.ssh_user, - password=ssh_password, + port=ssh_port, + options=ssh_options, ) if mikrotik_ip: logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}") diff --git a/backend/app/services/ssh.py b/backend/app/services/ssh.py index c0cee68..b5368d1 100644 --- a/backend/app/services/ssh.py +++ b/backend/app/services/ssh.py @@ -3,18 +3,35 @@ SSHService — asyncssh wrapper with per-device/per-object/global concurrency li Each call to run_command() opens a fresh connection (no pooling). Semaphores prevent overwhelming devices or the central server. + +Auth resolution order for a device: + 1. device.ssh_last_auth (last successful method — tried first) + 2. device.ssh_key_id_override (specific SSH key for this device) + 3. device.ssh_pass_override_enc (legacy password override for this device) + 4. object_category_credentials WHERE category=device.category AND protocol='ssh' + 5. object.ssh_credentials (ordered credential list) + 6. object.ssh_user + object.ssh_pass_enc (legacy fallback) + +On success the working method is persisted to device.ssh_last_auth. +If the last_auth method fails it is cleared and the full list is tried. """ import asyncio +import logging import time -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional import asyncssh +from sqlalchemy.ext.asyncio import AsyncSession from app.models.device import Device from app.models.object import Object +from app.models.ssh_key import SSHKey from app.services.crypto import decrypt +logger = logging.getLogger(__name__) + # Concurrency limits _GLOBAL_LIMIT = 50 _OBJECT_LIMIT = 10 @@ -47,8 +64,213 @@ class SSHResult: error: str = "" +@dataclass +class _AuthOption: + """Internal representation of one auth attempt.""" + type: str # "key" or "password" + username: str + password: str = "" # for type=password + key_path: str = "" # for type=key + # Identifiers used to persist last_auth + cred_id: Optional[int] = None # ObjectSSHCredential.id (if applicable) + key_id: Optional[int] = None # SSHKey.id (if applicable) + + +def _opt_key(opt: _AuthOption) -> str: + """Deduplication key for an auth option.""" + if opt.type == "key": + return f"key:{opt.key_id}:{opt.username}" + return f"password:{opt.cred_id}:{opt.username}" + + +async def _load_key_path(db: AsyncSession, key_id: int) -> Optional[str]: + """Return the filesystem path for an SSHKey id, or None if not found.""" + from sqlalchemy import select + result = await db.execute(select(SSHKey).where(SSHKey.id == key_id)) + key = result.scalar_one_or_none() + return key.path if key else None + + +async def build_auth_options(device: Device, obj: Object, db: Optional[AsyncSession] = None) -> list[_AuthOption]: + """Build an ordered list of auth options to try for this device. + + If db is provided, key paths are resolved from the database. + Without db, key-based options referencing the DB are skipped. + """ + options: list[_AuthOption] = [] + seen: set[str] = set() + + def _add(opt: _AuthOption) -> None: + k = _opt_key(opt) + if k not in seen: + seen.add(k) + options.append(opt) + + port = device.ssh_port_override or obj.ssh_port or 22 # not used in options, used by caller + + # 1. Last successful method — try first + if device.ssh_last_auth: + la = device.ssh_last_auth + if la.get("type") == "key" and la.get("key_id") and db: + path = await _load_key_path(db, la["key_id"]) + if path: + _add(_AuthOption( + type="key", + username=la.get("username", obj.ssh_user or "root"), + key_path=path, + key_id=la["key_id"], + )) + elif la.get("type") == "password": + # We store the password reference; reconstruct from device/object + username = la.get("username", obj.ssh_user or "root") + cred_id = la.get("cred_id") + # Try to get password from matching cred + password = "" + if cred_id: + for cred in obj.ssh_credentials: + if cred.id == cred_id and cred.password_enc: + password = decrypt(cred.password_enc) + break + if not password: + if device.ssh_pass_override_enc: + password = decrypt(device.ssh_pass_override_enc) + elif obj.ssh_pass_enc: + password = decrypt(obj.ssh_pass_enc) + _add(_AuthOption(type="password", username=username, password=password, cred_id=cred_id)) + + # 2. Device-specific SSH key override + if device.ssh_key_id_override and db: + path = await _load_key_path(db, device.ssh_key_id_override) + if path: + username = device.ssh_user_override or obj.ssh_user or "root" + _add(_AuthOption(type="key", username=username, key_path=path, key_id=device.ssh_key_id_override)) + + # 3. Device-specific password override (legacy) + if device.ssh_pass_override_enc: + username = device.ssh_user_override or obj.ssh_user or "root" + password = decrypt(device.ssh_pass_override_enc) + _add(_AuthOption(type="password", username=username, password=password)) + + # 4. Per-category SSH credentials from object + for cat_cred in obj.category_credentials: + if cat_cred.category != device.category or cat_cred.protocol != "ssh": + continue + if cat_cred.type == "key" and cat_cred.ssh_key_id and db: + path = await _load_key_path(db, cat_cred.ssh_key_id) + if path: + _add(_AuthOption(type="key", username=cat_cred.username, key_path=path, key_id=cat_cred.ssh_key_id)) + elif cat_cred.type == "password" and cat_cred.password_enc: + _add(_AuthOption( + type="password", + username=cat_cred.username, + password=decrypt(cat_cred.password_enc), + cred_id=cat_cred.id, + )) + + # 5. Object-level SSH credential list + for cred in obj.ssh_credentials: + if cred.type == "key" and cred.ssh_key_id and db: + path = await _load_key_path(db, cred.ssh_key_id) + if path: + _add(_AuthOption(type="key", username=cred.username, key_path=path, key_id=cred.ssh_key_id)) + elif cred.type == "password" and cred.password_enc: + _add(_AuthOption( + type="password", + username=cred.username, + password=decrypt(cred.password_enc), + cred_id=cred.id, + )) + + # 6. Legacy object ssh_user + ssh_pass_enc fallback + if obj.ssh_user and obj.ssh_pass_enc: + _add(_AuthOption( + type="password", + username=obj.ssh_user, + password=decrypt(obj.ssh_pass_enc), + )) + + return options + + +async def _try_connect( + host: str, + port: int, + opt: _AuthOption, + command: str, + timeout: int, +) -> SSHResult: + """Attempt a single SSH connection with the given auth option.""" + start = time.monotonic() + connect_kwargs: dict = dict( + host=host, + port=port, + username=opt.username, + known_hosts=None, + connect_timeout=10, + ) + if opt.type == "key": + connect_kwargs["client_keys"] = [opt.key_path] + connect_kwargs["passphrase"] = None + else: + connect_kwargs["password"] = opt.password + + try: + conn = await asyncio.wait_for(asyncssh.connect(**connect_kwargs), timeout=15) + async with conn: + result = await asyncio.wait_for(conn.run(command, check=False), timeout=timeout) + duration = int((time.monotonic() - start) * 1000) + return SSHResult( + success=result.exit_status == 0, + stdout=(result.stdout or "").strip(), + stderr=(result.stderr or "").strip(), + exit_code=result.exit_status or 0, + duration_ms=duration, + ) + except asyncio.TimeoutError: + return SSHResult( + success=False, + error="Connection timed out", + duration_ms=int((time.monotonic() - start) * 1000), + ) + except (asyncssh.DisconnectError, asyncssh.PermissionDenied, asyncssh.ConnectionLost) as e: + return SSHResult( + success=False, + error=str(e), + duration_ms=int((time.monotonic() - start) * 1000), + ) + except Exception as e: + return SSHResult( + success=False, + error=str(e), + duration_ms=int((time.monotonic() - start) * 1000), + ) + + +async def _save_last_auth(device: Device, db: AsyncSession, opt: _AuthOption) -> None: + """Persist the successful auth method to device.ssh_last_auth.""" + payload: dict = {"type": opt.type, "username": opt.username} + if opt.type == "key": + payload["key_id"] = opt.key_id + else: + if opt.cred_id: + payload["cred_id"] = opt.cred_id + device.ssh_last_auth = payload + try: + await db.flush() + except Exception: + pass # non-critical + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +# Legacy helper kept for callers that don't have device/object context def resolve_ssh_credentials(device: Device, obj: Object) -> tuple[str, str, int]: - """Returns (username, password, port) — device overrides take priority.""" + """Returns (username, password, port) — device overrides take priority. + + DEPRECATED: use run_command() with db= for full multi-auth support. + """ user = device.ssh_user_override or obj.ssh_user or "root" port = device.ssh_port_override or obj.ssh_port or 22 @@ -67,41 +289,129 @@ async def run_command( obj: Object, command: str, timeout: int = 30, + db: Optional[AsyncSession] = None, ) -> SSHResult: - user, password, port = resolve_ssh_credentials(device, obj) - start = time.monotonic() + """Run command on device, trying all configured auth methods in order. + + If db is provided: + - SSH keys are resolved from the database. + - The successful auth method is persisted to device.ssh_last_auth. + + If last_auth method fails, it is cleared and remaining methods are tried. + """ + port = device.ssh_port_override or obj.ssh_port or 22 async with _global_sem: async with _get_object_sem(device.object_id): async with _get_device_sem(device.id): - try: - conn = await asyncio.wait_for( - asyncssh.connect( - device.ip, - port=port, - username=user, - password=password, - known_hosts=None, - connect_timeout=10, - ), - timeout=15, + options = await build_auth_options(device, obj, db) + + if not options: + # Fallback: no creds configured at all + user = device.ssh_user_override or obj.ssh_user or "root" + options = [_AuthOption(type="password", username=user, password="")] + + last_auth_option = options[0] if device.ssh_last_auth else None + last_result: Optional[SSHResult] = None + + for i, opt in enumerate(options): + result = await _try_connect(device.ip, port, opt, command, timeout) + + if result.success: + if db: + await _save_last_auth(device, db, opt) + return result + + # If last_auth method failed, clear it + if i == 0 and opt is last_auth_option and db: + device.ssh_last_auth = None + try: + await db.flush() + except Exception: + pass + + last_result = result + logger.debug( + "SSH auth failed for %s with %s(%s): %s", + device.ip, opt.type, opt.username, result.error, ) - async with conn: - result = await asyncio.wait_for( - conn.run(command, check=False), - timeout=timeout, - ) - duration = int((time.monotonic() - start) * 1000) - return SSHResult( - success=result.exit_status == 0, - stdout=(result.stdout or "").strip(), - stderr=(result.stderr or "").strip(), - exit_code=result.exit_status or 0, - duration_ms=duration, - ) - except asyncio.TimeoutError: - return SSHResult(success=False, error="Connection timed out", duration_ms=int((time.monotonic() - start) * 1000)) - except asyncssh.DisconnectError as e: - return SSHResult(success=False, error=f"SSH disconnect: {e}", duration_ms=int((time.monotonic() - start) * 1000)) - except Exception as e: - return SSHResult(success=False, error=str(e), duration_ms=int((time.monotonic() - start) * 1000)) + + # All methods exhausted + if last_result: + last_result.error = f"All auth methods exhausted. Last error: {last_result.error}" + return last_result + return SSHResult(success=False, error="No auth methods available") + + +async def run_command_direct( + host: str, + port: int, + user: str, + password: str, + command: str, + timeout: int = 30, +) -> SSHResult: + """Connect directly with explicit credentials (for object-level hosts like rack PCs). + + Used by device_discovery when connecting to a specific IP that isn't a Device record. + """ + opt = _AuthOption(type="password", username=user, password=password) + async with _global_sem: + return await _try_connect(host, port, opt, command, timeout) + + +async def run_command_direct_with_options( + host: str, + port: int, + options: list[_AuthOption], + command: str, + timeout: int = 30, +) -> SSHResult: + """Try multiple auth options for a non-Device host (e.g. rack PC during discovery).""" + async with _global_sem: + last_result: Optional[SSHResult] = None + for opt in options: + result = await _try_connect(host, port, opt, command, timeout) + if result.success: + return result + last_result = result + if last_result: + last_result.error = f"All auth methods exhausted. Last error: {last_result.error}" + return last_result + return SSHResult(success=False, error="No auth methods available") + + +async def build_object_auth_options(obj: Object, db: Optional[AsyncSession] = None) -> list[_AuthOption]: + """Build auth options for connecting to object-level hosts (server_ip, rack PCs). + + Uses the object's credential list + legacy ssh_user/ssh_pass_enc. + No device-level overrides. + """ + options: list[_AuthOption] = [] + seen: set[str] = set() + + def _add(opt: _AuthOption) -> None: + k = _opt_key(opt) + if k not in seen: + seen.add(k) + options.append(opt) + + # Object SSH credential list + for cred in obj.ssh_credentials: + if cred.type == "key" and cred.ssh_key_id and db: + path = await _load_key_path(db, cred.ssh_key_id) + if path: + _add(_AuthOption(type="key", username=cred.username, key_path=path, key_id=cred.ssh_key_id)) + elif cred.type == "password" and cred.password_enc: + _add(_AuthOption( + type="password", + username=cred.username, + password=decrypt(cred.password_enc), + cred_id=cred.id, + )) + + # Legacy fallback + if obj.ssh_user and obj.ssh_pass_enc: + _add(_AuthOption(type="password", username=obj.ssh_user, password=decrypt(obj.ssh_pass_enc))) + + return options diff --git a/frontend/src/api/credentials.ts b/frontend/src/api/credentials.ts new file mode 100644 index 0000000..445e360 --- /dev/null +++ b/frontend/src/api/credentials.ts @@ -0,0 +1,81 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from './client' + +export interface SSHKeyItem { + id: number + name: string + path: string + description: string | null + created_at: string +} + +export interface SSHCredentialItem { + id: number + type: 'password' | 'key' + username: string + ssh_key_id: number | null + priority: number +} + +export interface CategoryCredentialItem { + id: number + category: string + protocol: 'ssh' | 'http' + type: 'password' | 'key' + username: string + ssh_key_id: number | null + priority: number +} + +// Input types for create/update +export interface SSHCredentialInput { + type: 'password' | 'key' + username: string + password?: string + ssh_key_id?: number | null + priority?: number +} + +export interface CategoryCredentialInput { + category: string + protocol: 'ssh' | 'http' + type: 'password' | 'key' + username: string + password?: string + ssh_key_id?: number | null + priority?: number +} + +// --- SSH Key hooks --- + +export const useSSHKeys = () => + useQuery({ + queryKey: ['ssh-keys'], + queryFn: () => api.get('/api/v1/ssh-keys').then((r) => r.data), + }) + +export const useCreateSSHKey = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { name: string; path: string; description?: string }) => + api.post('/api/v1/ssh-keys', body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['ssh-keys'] }), + }) +} + +export const useUpdateSSHKey = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: Partial<{ name: string; path: string; description: string }> }) => + api.patch(`/api/v1/ssh-keys/${id}`, body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['ssh-keys'] }), + }) +} + +export const useDeleteSSHKey = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/ssh-keys/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['ssh-keys'] }), + }) +} diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index 14d561a..8931d78 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -12,6 +12,8 @@ export interface ObjectItem { client: string | null is_active: boolean tags: { id: number; name: string; color: string }[] + ssh_credentials: { id: number; type: string; username: string; ssh_key_id: number | null; priority: number }[] + category_credentials: { id: number; category: string; protocol: string; type: string; username: string; ssh_key_id: number | null; priority: number }[] created_at: string updated_at: string device_count: number @@ -41,6 +43,8 @@ export interface DeviceItem { location: string | null ssh_user_override: string | null ssh_port_override: number | null + ssh_key_id_override: number | null + ssh_last_auth: Record | null device_meta: Record connection_params: Record capabilities: string[] diff --git a/frontend/src/components/DeviceEditModal.tsx b/frontend/src/components/DeviceEditModal.tsx index 2848181..90b9c6d 100644 --- a/frontend/src/components/DeviceEditModal.tsx +++ b/frontend/src/components/DeviceEditModal.tsx @@ -17,6 +17,7 @@ import { type DeviceItem, } from '../api/objects' import { useRacks } from '../api/racks' +import { useSSHKeys } from '../api/credentials' import { stripEmpty } from '../utils/form' const DEVICE_CATEGORIES = [ @@ -54,6 +55,7 @@ interface Props { export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) { const [form] = Form.useForm() const { data: racks } = useRacks(objId) + const { data: sshKeys } = useSSHKeys() const createDevice = useCreateDevice(objId) const updateDevice = useUpdateDevice(objId) @@ -75,6 +77,7 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props monitor_enabled: device.monitor_enabled ?? false, ssh_user_override: device.ssh_user_override ?? '', ssh_port_override: device.ssh_port_override ?? undefined, + ssh_key_id_override: device.ssh_key_id_override ?? undefined, }) } else { form.resetFields() @@ -203,6 +206,13 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props + + + + + + + + + + + + + ) +} + // ── Main Page ───────────────────────────────────────────────────────────────── export function AdminPage() { return ( @@ -316,6 +403,15 @@ export function AdminPage() { ), children: , }, + { + key: 'ssh-keys', + label: ( + + SSH-ключи + + ), + children: , + }, ]} /> diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx index 0ee3c8b..39c835b 100644 --- a/frontend/src/pages/DeviceDetail.tsx +++ b/frontend/src/pages/DeviceDetail.tsx @@ -478,7 +478,7 @@ export function DeviceDetailPage() { - {(device.ssh_user_override || device.ssh_port_override) && ( + {(device.ssh_user_override || device.ssh_port_override || device.ssh_key_id_override || device.ssh_last_auth) && ( {device.ssh_user_override && ( {device.ssh_user_override} @@ -486,6 +486,27 @@ export function DeviceDetailPage() { {device.ssh_port_override && ( {device.ssh_port_override} )} + {device.ssh_key_id_override != null && ( + + key_id={device.ssh_key_id_override} + + )} + {device.ssh_last_auth && ( + + + {String(device.ssh_last_auth.type ?? '—')} + {device.ssh_last_auth.username && ( + {String(device.ssh_last_auth.username)} + )} + {device.ssh_last_auth.key_id != null && ( + key_id={String(device.ssh_last_auth.key_id)} + )} + {device.ssh_last_auth.cred_id != null && ( + cred_id={String(device.ssh_last_auth.cred_id)} + )} + + + )} )} diff --git a/frontend/src/pages/Objects.tsx b/frontend/src/pages/Objects.tsx index 80bcc57..e8ce36f 100644 --- a/frontend/src/pages/Objects.tsx +++ b/frontend/src/pages/Objects.tsx @@ -20,6 +20,7 @@ import { Modal, Popconfirm, Row, + Select, Space, Table, Tag, @@ -41,6 +42,7 @@ import { type ObjectItem, type ObjectImportPreview, } from '../api/objects' +import { useSSHKeys } from '../api/credentials' import { stripEmpty } from '../utils/form' interface Props { @@ -53,6 +55,7 @@ export function ObjectsPage({ mode }: Props) { const navigate = useNavigate() const { data: objects, isLoading } = useObjects() const { data: meta } = useObjectsMeta() + const { data: sshKeys } = useSSHKeys() const createObject = useCreateObject() const updateObject = useUpdateObject() const deleteObject = useDeleteObject() @@ -117,6 +120,21 @@ export function ObjectsPage({ mode }: Props) { client: row.client ?? '', ssh_user: row.ssh_user ?? '', db_host: row.db_host ?? '', + // Load credential list (passwords not returned from API — user must re-enter if changing) + ssh_credentials: (row.ssh_credentials ?? []).map((c) => ({ + type: c.type, + username: c.username, + ssh_key_id: c.ssh_key_id, + priority: c.priority, + })), + category_credentials: (row.category_credentials ?? []).map((c) => ({ + category: c.category, + protocol: c.protocol, + type: c.type, + username: c.username, + ssh_key_id: c.ssh_key_id, + priority: c.priority, + })), }) setEditTarget(row) setModalOpen(true) @@ -526,6 +544,149 @@ export function ObjectsPage({ mode }: Props) { + {/* SSH Credential list */} + Список SSH-кредов (перебор при подключении) + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...rest }) => ( + + + + + + + + + {() => { + const credType = form.getFieldValue(['ssh_credentials', name, 'type']) + return credType === 'key' ? ( + + + + + + + + + + + + + + + + + {() => { + const credType = form.getFieldValue(['category_credentials', name, 'type']) + return credType === 'key' ? ( + +