ssh подбор

This commit is contained in:
dv 2026-04-09 14:09:53 +03:00
parent 948ebe5d3b
commit 783e6f80d4
20 changed files with 1351 additions and 84 deletions

View file

@ -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")

View file

@ -7,7 +7,7 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.plugins.loader import load_plugins 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.monitor import start_monitor, stop_monitor
from app.workers.startup import recover_stale_tasks from app.workers.startup import recover_stale_tasks
@ -53,6 +53,7 @@ def create_app() -> FastAPI:
app.include_router(racks.router) app.include_router(racks.router)
app.include_router(zones.router) app.include_router(zones.router)
app.include_router(tags.router) app.include_router(tags.router)
app.include_router(ssh_keys.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(alerts.router) app.include_router(alerts.router)
app.include_router(snapshots.router) app.include_router(snapshots.router)

View file

@ -9,6 +9,7 @@ from .zone import Zone
from .snapshot import ConfigSnapshot, SnapshotContent from .snapshot import ConfigSnapshot, SnapshotContent
from .alert import Alert from .alert import Alert
from .plugin import Plugin from .plugin import Plugin
from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential
__all__ = [ __all__ = [
"Base", "Base",
@ -28,4 +29,7 @@ __all__ = [
"SnapShotContent", "SnapShotContent",
"Alert", "Alert",
"Plugin", "Plugin",
"SSHKey",
"ObjectSSHCredential",
"ObjectCategoryCredential",
] ]

View file

@ -59,6 +59,14 @@ class Device(Base, TimestampMixin):
ssh_user_override: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) 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_pass_override_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ssh_port_override: Mapped[Optional[int]] = mapped_column(Integer, 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-specific metadata (Mikrotik API port, RTSP URL, etc.)
device_meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) 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) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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") object: Mapped["Object"] = relationship("Object", back_populates="devices")
rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices") rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices")
config_snapshots: Mapped[list["ConfigSnapshot"]] = relationship( config_snapshots: Mapped[list["ConfigSnapshot"]] = relationship(

View file

@ -56,3 +56,15 @@ class Object(Base, TimestampMixin):
"Zone", back_populates="object", cascade="all, delete-orphan" "Zone", back_populates="object", cascade="all, delete-orphan"
) )
tags: Mapped[list["Tag"]] = relationship("Tag", secondary=object_tags) 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",
)

View file

@ -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")

View file

@ -88,11 +88,13 @@ async def create_device(
): ):
await _get_object(db, obj_id) 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) device = Device(object_id=obj_id, **data)
if body.ssh_password_override: if body.ssh_password_override:
device.ssh_pass_override_enc = crypto.encrypt(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) db.add(device)
await db.flush() await db.flush()
@ -239,7 +241,7 @@ async def update_device(
): ):
device = await _get_device(db, obj_id, device_id) 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(): for field, value in data.items():
setattr(device, field, value) setattr(device, field, value)
@ -247,6 +249,8 @@ async def update_device(
device.ssh_pass_override_enc = ( device.ssh_pass_override_enc = (
crypto.encrypt(body.ssh_password_override) if body.ssh_password_override else None 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.flush()
await db.refresh(device) await db.refresh(device)

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import selectinload
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.device import Device from app.models.device import Device
from app.models.object import Object from app.models.object import Object
from app.models.ssh_key import ObjectSSHCredential, ObjectCategoryCredential
from app.models.tag import Tag from app.models.tag import Tag
from app.models.user import User from app.models.user import User
from app.schemas.admin import SheetsSyncRequest 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: async def _load_object(db: AsyncSession, obj_id: int) -> Object:
result = await db.execute( 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() obj = result.scalar_one_or_none()
if obj is None: if obj is None:
@ -53,6 +60,41 @@ async def _load_object(db: AsyncSession, obj_id: int) -> Object:
return obj 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) @router.get("/meta", response_model=ObjectMetaResponse)
async def get_objects_meta( async def get_objects_meta(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -80,7 +122,11 @@ async def list_objects(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: 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: if is_active is not None:
query = query.where(Object.is_active == is_active) query = query.where(Object.is_active == is_active)
if tag_id is not None: if tag_id is not None:
@ -111,7 +157,7 @@ async def create_object(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: 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) obj = Object(**data)
_apply_credentials(obj, body.db_password, body.ssh_password) _apply_credentials(obj, body.db_password, body.ssh_password)
@ -121,7 +167,12 @@ async def create_object(
db.add(obj) db.add(obj)
await db.flush() 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 return obj
@ -143,7 +194,7 @@ async def update_object(
): ):
obj = await _load_object(db, obj_id) 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(): for field, value in data.items():
setattr(obj, field, value) 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))) tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids)))
obj.tags = list(tags_result.scalars().all()) 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.flush()
await db.refresh(obj) await db.refresh(obj)
return obj return obj

View file

@ -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)

View file

@ -19,6 +19,7 @@ class DeviceCreate(BaseModel):
ssh_user_override: Optional[str] = None ssh_user_override: Optional[str] = None
ssh_password_override: Optional[str] = None # plaintext, encrypted on write ssh_password_override: Optional[str] = None # plaintext, encrypted on write
ssh_port_override: Optional[int] = None ssh_port_override: Optional[int] = None
ssh_key_id_override: Optional[int] = None
device_meta: dict[str, Any] = {} device_meta: dict[str, Any] = {}
monitor_enabled: bool = False monitor_enabled: bool = False
notes: Optional[str] = None notes: Optional[str] = None
@ -51,6 +52,7 @@ class DeviceUpdate(BaseModel):
ssh_user_override: Optional[str] = None ssh_user_override: Optional[str] = None
ssh_password_override: Optional[str] = None ssh_password_override: Optional[str] = None
ssh_port_override: Optional[int] = None ssh_port_override: Optional[int] = None
ssh_key_id_override: Optional[int] = None
device_meta: Optional[dict[str, Any]] = None device_meta: Optional[dict[str, Any]] = None
monitor_enabled: Optional[bool] = None monitor_enabled: Optional[bool] = None
notes: Optional[str] = None notes: Optional[str] = None
@ -74,6 +76,8 @@ class DeviceRead(BaseModel):
rack_type: Optional[str] = None rack_type: Optional[str] = None
ssh_user_override: Optional[str] = None ssh_user_override: Optional[str] = None
ssh_port_override: Optional[int] = 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] = {} device_meta: dict[str, Any] = {}
capabilities: list[str] = [] capabilities: list[str] = []
monitor_enabled: bool monitor_enabled: bool

View file

@ -4,6 +4,12 @@ from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.tag import TagRead from app.schemas.tag import TagRead
from app.schemas.ssh_key import (
ObjectSSHCredentialCreate,
ObjectSSHCredentialRead,
ObjectCategoryCredentialCreate,
ObjectCategoryCredentialRead,
)
class ObjectCreate(BaseModel): class ObjectCreate(BaseModel):
@ -29,6 +35,8 @@ class ObjectCreate(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True is_active: bool = True
tag_ids: list[int] = [] tag_ids: list[int] = []
ssh_credentials: list[ObjectSSHCredentialCreate] = []
category_credentials: list[ObjectCategoryCredentialCreate] = []
class ObjectUpdate(BaseModel): class ObjectUpdate(BaseModel):
@ -52,6 +60,8 @@ class ObjectUpdate(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
tag_ids: Optional[list[int]] = None tag_ids: Optional[list[int]] = None
ssh_credentials: Optional[list[ObjectSSHCredentialCreate]] = None
category_credentials: Optional[list[ObjectCategoryCredentialCreate]] = None
class ObjectRead(BaseModel): class ObjectRead(BaseModel):
@ -76,6 +86,8 @@ class ObjectRead(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tags: list[TagRead] = [] tags: list[TagRead] = []
ssh_credentials: list[ObjectSSHCredentialRead] = []
category_credentials: list[ObjectCategoryCredentialRead] = []
device_count: int = 0 device_count: int = 0
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View file

@ -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}

View file

@ -11,6 +11,7 @@ Limitations (devices not discoverable automatically):
- Physical MikroTik router (not in rack configs) - Physical MikroTik router (not in rack configs)
""" """
import asyncio
import logging import logging
import re import re
from collections.abc import Callable, Awaitable 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.device import Device
from app.models.object import Object from app.models.object import Object
from app.services.crypto import decrypt from app.services.crypto import decrypt, encrypt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,8 +37,12 @@ logger = logging.getLogger(__name__)
_SERIAL_SCHEMES = {"serial", "com"} _SERIAL_SCHEMES = {"serial", "com"}
_SERVICE_HOSTS = {"localhost", "127.0.0.1"} _SERVICE_HOSTS = {"localhost", "127.0.0.1"}
def _extract_ip(uri: str) -> str | None: def _extract_device_info(uri: str) -> tuple[str, str, str] | None:
"""Return IP from a URI string, or None if not a network device.""" """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: if not uri:
return None return None
try: try:
@ -50,12 +55,18 @@ def _extract_ip(uri: str) -> str | None:
return None return None
# Validate it looks like an IP (simple check) # Validate it looks like an IP (simple check)
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname): if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname):
return hostname return hostname, parsed.username or "", parsed.password or ""
except Exception: except Exception:
pass pass
return None 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 ─────────────────────────────────────────── # ── Plugin class → device category ───────────────────────────────────────────
def _cls_to_category(cls: str) -> str: def _cls_to_category(cls: str) -> str:
@ -96,6 +107,8 @@ class DiscoveredDevice:
role: str role: str
plugin_name: str plugin_name: str
cls: 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]: 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)") logger.info(f"[ssh_discovery] {field_name}: (not set)")
continue continue
logger.info(f"[ssh_discovery] {field_name}='{raw}'") logger.info(f"[ssh_discovery] {field_name}='{raw}'")
ip = _extract_ip(raw) info = _extract_device_info(raw)
if not ip: if not info:
logger.info(f"[ssh_discovery] → IGNORED (not a valid network IP)") logger.info(f"[ssh_discovery] → IGNORED (not a valid network IP)")
continue continue
ip, http_user, http_pass = info
if ip in all_rack_hosts: if ip in all_rack_hosts:
logger.info(f"[ssh_discovery] → IGNORED (is a rack server IP: {ip})") logger.info(f"[ssh_discovery] → IGNORED (is a rack server IP: {ip})")
continue continue
@ -156,13 +170,16 @@ def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] |
continue continue
seen_ips.add(ip) 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( discovered.append(DiscoveredDevice(
ip=ip, ip=ip,
category=_cls_to_category(cls), category=_cls_to_category(cls),
role=_cls_to_role(cls, plugin_name), role=_cls_to_role(cls, plugin_name),
plugin_name=plugin_name, plugin_name=plugin_name,
cls=cls, cls=cls,
http_username=http_user,
http_password=http_pass,
)) ))
found_ip_in_plugin = True found_ip_in_plugin = True
@ -412,6 +429,110 @@ async def _autodetect_db_from_server(
return None 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( async def _get_rack_hosts_direct(
host: str, host: str,
port: int, port: int,
@ -494,16 +615,18 @@ async def discover_via_ssh(
if progress_cb is not None: if progress_cb is not None:
await progress_cb(event_type, data) await progress_cb(event_type, data)
if not obj.ssh_user or not obj.ssh_pass_enc: # Build ordered list of SSH auth options for this object
result.errors.append("SSH credentials not configured on this object (ssh_user / ssh_password)") from app.services.ssh import build_object_auth_options, _AuthOption
return result ssh_options = await build_object_auth_options(obj, db)
try: if not ssh_options:
ssh_password = decrypt(obj.ssh_pass_enc) result.errors.append("SSH credentials not configured on this object")
except Exception:
result.errors.append("Failed to decrypt SSH password")
return result 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 ssh_port = obj.ssh_port or 22
# ── Resolve DB connection ───────────────────────────────────────────────── # ── Resolve DB connection ─────────────────────────────────────────────────
@ -527,22 +650,15 @@ async def discover_via_ssh(
elif obj.server_ip: elif obj.server_ip:
# Autodetect: SSH into server, read its caps.conf to get DB URI # Autodetect: SSH into server, read its caps.conf to get DB URI
await _emit("disc_action", {"message": f"Чтение конфигурации сервера {obj.server_ip}..."}) 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, server_ip=obj.server_ip,
ssh_port=ssh_port, ssh_port=ssh_port,
ssh_user=obj.ssh_user, ssh_options=ssh_options,
ssh_password=ssh_password,
) )
if not detected or not all([detected.get("host"), detected.get("user"), detected.get("dbname")]): 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 # Diagnostic: try to read the config and list what sections/keys are there
try: try:
raw = await _read_remote_config( raw = await _ssh_run_multi(obj.server_ip, ssh_port, ssh_options, "cat /etc/caps/config.ini")
host=obj.server_ip,
port=ssh_port,
username=obj.ssh_user,
password=ssh_password,
config_path="/etc/caps/config.ini",
)
diag_parser = RawConfigParser(strict=False) diag_parser = RawConfigParser(strict=False)
diag_parser.read_string(raw) diag_parser.read_string(raw)
found_sections = diag_parser.sections() found_sections = diag_parser.sections()
@ -728,13 +844,7 @@ async def discover_via_ssh(
config_text = None config_text = None
await _emit("disc_action", {"message": f"SSH → {rack_host} ({rack_name})"}) await _emit("disc_action", {"message": f"SSH → {rack_host} ({rack_name})"})
try: try:
config_text = await _read_remote_config( config_text = await _ssh_run_multi(rack_host, ssh_port, ssh_options, f"cat {effective_config_path}")
host=rack_host,
port=obj.ssh_port or 22,
username=obj.ssh_user,
password=ssh_password,
config_path=effective_config_path,
)
rack_ssh_ok = True rack_ssh_ok = True
except Exception as exc: except Exception as exc:
result.errors.append(f"SSH to {rack_host} failed: {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 continue # no config to parse
# Collect ARP neighbours for slave rack discovery (best-effort) # 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) neighbor_candidates.update(neigh_ips)
discovered_device_ips.add(rack_host) # rack PC itself is a known device 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, role=existing.role,
)) ))
else: 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( db.add(Device(
object_id=obj.id, object_id=obj.id,
ip=found.ip, ip=found.ip,
@ -921,8 +1036,10 @@ async def discover_via_ssh(
rack_id=rack_id, rack_id=rack_id,
location=device_location, location=device_location,
device_meta={"plugin": found.plugin_name, "cls": found.cls}, 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.created += 1
result.actions.append(DeviceAction( result.actions.append(DeviceAction(
ip=found.ip, ip=found.ip,
@ -985,11 +1102,10 @@ async def discover_via_ssh(
slave_config_text = "" slave_config_text = ""
master_ip_for_slave = "" master_ip_for_slave = ""
for _, master_host, _, _ in rack_hosts: 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, host=candidate_ip,
port=ssh_port, port=ssh_port,
username=obj.ssh_user, options=ssh_options,
password=ssh_password,
master_ip=master_host, master_ip=master_host,
config_path=config_path, 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) select(Device).where(Device.object_id == obj.id, Device.ip == found.ip)
)).scalar_one_or_none() )).scalar_one_or_none()
if existing is 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( db.add(Device(
object_id=obj.id, ip=found.ip, hostname=hostname, object_id=obj.id, ip=found.ip, hostname=hostname,
category=found.category, role=found.role, category=found.category, role=found.role,
source="auto_ssh", rack_id=s_rack_id, location=device_location, source="auto_ssh", rack_id=s_rack_id, location=device_location,
device_meta={"plugin": found.plugin_name, "cls": found.cls}, 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}") logger.info(f"[ssh_discovery] {found.ip} ({hostname}) - ADDED from slave {slave_identifier}")
result.created += 1 result.created += 1
@ -1123,11 +1244,10 @@ async def discover_via_ssh(
await _emit("disc_phase", {"phase": "mikrotik"}) await _emit("disc_phase", {"phase": "mikrotik"})
await _emit("disc_action", {"message": "Поиск MikroTik (SSH_CLIENT)..."}) 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'}") 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], host=obj.server_ip or rack_hosts[0][1],
port=obj.ssh_port or 22, port=ssh_port,
username=obj.ssh_user, options=ssh_options,
password=ssh_password,
) )
if mikrotik_ip: if mikrotik_ip:
logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}") logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}")

View file

@ -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). Each call to run_command() opens a fresh connection (no pooling).
Semaphores prevent overwhelming devices or the central server. 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 asyncio
import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Optional
import asyncssh import asyncssh
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.device import Device from app.models.device import Device
from app.models.object import Object from app.models.object import Object
from app.models.ssh_key import SSHKey
from app.services.crypto import decrypt from app.services.crypto import decrypt
logger = logging.getLogger(__name__)
# Concurrency limits # Concurrency limits
_GLOBAL_LIMIT = 50 _GLOBAL_LIMIT = 50
_OBJECT_LIMIT = 10 _OBJECT_LIMIT = 10
@ -47,8 +64,213 @@ class SSHResult:
error: str = "" 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]: 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" user = device.ssh_user_override or obj.ssh_user or "root"
port = device.ssh_port_override or obj.ssh_port or 22 port = device.ssh_port_override or obj.ssh_port or 22
@ -67,41 +289,129 @@ async def run_command(
obj: Object, obj: Object,
command: str, command: str,
timeout: int = 30, timeout: int = 30,
db: Optional[AsyncSession] = None,
) -> SSHResult: ) -> SSHResult:
user, password, port = resolve_ssh_credentials(device, obj) """Run command on device, trying all configured auth methods in order.
start = time.monotonic()
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 _global_sem:
async with _get_object_sem(device.object_id): async with _get_object_sem(device.object_id):
async with _get_device_sem(device.id): async with _get_device_sem(device.id):
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: try:
conn = await asyncio.wait_for( await db.flush()
asyncssh.connect( except Exception:
device.ip, pass
port=port,
username=user, last_result = result
password=password, logger.debug(
known_hosts=None, "SSH auth failed for %s with %s(%s): %s",
connect_timeout=10, device.ip, opt.type, opt.username, result.error,
),
timeout=15,
) )
async with conn:
result = await asyncio.wait_for( # All methods exhausted
conn.run(command, check=False), if last_result:
timeout=timeout, last_result.error = f"All auth methods exhausted. Last error: {last_result.error}"
) return last_result
duration = int((time.monotonic() - start) * 1000) return SSHResult(success=False, error="No auth methods available")
return SSHResult(
success=result.exit_status == 0,
stdout=(result.stdout or "").strip(), async def run_command_direct(
stderr=(result.stderr or "").strip(), host: str,
exit_code=result.exit_status or 0, port: int,
duration_ms=duration, user: str,
) password: str,
except asyncio.TimeoutError: command: str,
return SSHResult(success=False, error="Connection timed out", duration_ms=int((time.monotonic() - start) * 1000)) timeout: int = 30,
except asyncssh.DisconnectError as e: ) -> SSHResult:
return SSHResult(success=False, error=f"SSH disconnect: {e}", duration_ms=int((time.monotonic() - start) * 1000)) """Connect directly with explicit credentials (for object-level hosts like rack PCs).
except Exception as e:
return SSHResult(success=False, error=str(e), duration_ms=int((time.monotonic() - start) * 1000)) 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

View file

@ -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<SSHKeyItem[]>('/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<SSHKeyItem>('/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<SSHKeyItem>(`/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'] }),
})
}

View file

@ -12,6 +12,8 @@ export interface ObjectItem {
client: string | null client: string | null
is_active: boolean is_active: boolean
tags: { id: number; name: string; color: string }[] 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 created_at: string
updated_at: string updated_at: string
device_count: number device_count: number
@ -41,6 +43,8 @@ export interface DeviceItem {
location: string | null location: string | null
ssh_user_override: string | null ssh_user_override: string | null
ssh_port_override: number | null ssh_port_override: number | null
ssh_key_id_override: number | null
ssh_last_auth: Record<string, unknown> | null
device_meta: Record<string, unknown> device_meta: Record<string, unknown>
connection_params: Record<string, unknown> connection_params: Record<string, unknown>
capabilities: string[] capabilities: string[]

View file

@ -17,6 +17,7 @@ import {
type DeviceItem, type DeviceItem,
} from '../api/objects' } from '../api/objects'
import { useRacks } from '../api/racks' import { useRacks } from '../api/racks'
import { useSSHKeys } from '../api/credentials'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
const DEVICE_CATEGORIES = [ const DEVICE_CATEGORIES = [
@ -54,6 +55,7 @@ interface Props {
export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) { export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) {
const [form] = Form.useForm() const [form] = Form.useForm()
const { data: racks } = useRacks(objId) const { data: racks } = useRacks(objId)
const { data: sshKeys } = useSSHKeys()
const createDevice = useCreateDevice(objId) const createDevice = useCreateDevice(objId)
const updateDevice = useUpdateDevice(objId) const updateDevice = useUpdateDevice(objId)
@ -75,6 +77,7 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
monitor_enabled: device.monitor_enabled ?? false, monitor_enabled: device.monitor_enabled ?? false,
ssh_user_override: device.ssh_user_override ?? '', ssh_user_override: device.ssh_user_override ?? '',
ssh_port_override: device.ssh_port_override ?? undefined, ssh_port_override: device.ssh_port_override ?? undefined,
ssh_key_id_override: device.ssh_key_id_override ?? undefined,
}) })
} else { } else {
form.resetFields() form.resetFields()
@ -203,6 +206,13 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="ssh_key_id_override" label="SSH-ключ (override)">
<Select
placeholder="Использовать ключ из объекта"
allowClear
options={(sshKeys ?? []).map((k) => ({ value: k.id, label: `${k.name} (${k.path})` }))}
/>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
) )

View file

@ -1,6 +1,7 @@
import { import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
KeyOutlined,
PlusOutlined, PlusOutlined,
UserOutlined, UserOutlined,
ApiOutlined, ApiOutlined,
@ -35,6 +36,7 @@ import {
useUpdatePlugin, useUpdatePlugin,
type UserItem, type UserItem,
} from '../api/admin' } from '../api/admin'
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
// ── Plugins Tab ─────────────────────────────────────────────────────────────── // ── Plugins Tab ───────────────────────────────────────────────────────────────
@ -286,6 +288,91 @@ function UsersTab() {
) )
} }
// ── SSH Keys Tab ──────────────────────────────────────────────────────────────
function SSHKeysTab() {
const { data: keys, isLoading } = useSSHKeys()
const createKey = useCreateSSHKey()
const deleteKey = useDeleteSSHKey()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const handleCreate = async () => {
const values = await form.validateFields()
try {
await createKey.mutateAsync(values)
message.success('Ключ зарегистрирован')
setModalOpen(false)
form.resetFields()
} catch (err: any) {
message.error(err?.response?.data?.detail ?? 'Ошибка при создании')
}
}
const columns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{ title: 'Путь на сервере', dataIndex: 'path', key: 'path', render: (v: string) => <Typography.Text code>{v}</Typography.Text> },
{ title: 'Описание', dataIndex: 'description', key: 'description' },
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: SSHKeyItem) => (
<Popconfirm
title="Удалить ключ?"
description="Устройства, использующие этот ключ, потеряют его привязку."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteKey.mutateAsync(row.id).catch(() => message.error('Ошибка при удалении'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
return (
<>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
Зарегистрировать ключ
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Файлы SSH-ключей должны находиться на сервере. Укажите путь к существующему файлу.
</Typography.Text>
<Table
dataSource={keys ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={false}
/>
<Modal
title="Зарегистрировать SSH-ключ"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleCreate}
confirmLoading={createKey.isPending}
okText="Зарегистрировать"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="Название" rules={[{ required: true }]}>
<Input placeholder="main-key" />
</Form.Item>
<Form.Item name="path" label="Путь на сервере" rules={[{ required: true }]}>
<Input placeholder="/app/ssh_keys/id_rsa" />
</Form.Item>
<Form.Item name="description" label="Описание">
<Input placeholder="Основной ключ доступа к объектам" />
</Form.Item>
</Form>
</Modal>
</>
)
}
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
export function AdminPage() { export function AdminPage() {
return ( return (
@ -316,6 +403,15 @@ export function AdminPage() {
), ),
children: <UsersTab />, children: <UsersTab />,
}, },
{
key: 'ssh-keys',
label: (
<span>
<KeyOutlined /> SSH-ключи
</span>
),
children: <SSHKeysTab />,
},
]} ]}
/> />
</div> </div>

View file

@ -478,7 +478,7 @@ export function DeviceDetailPage() {
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{(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) && (
<Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}> <Descriptions bordered size="small" column={1} labelStyle={{ width: 180 }} style={{ marginTop: 16 }}>
{device.ssh_user_override && ( {device.ssh_user_override && (
<Descriptions.Item label="SSH логин (override)">{device.ssh_user_override}</Descriptions.Item> <Descriptions.Item label="SSH логин (override)">{device.ssh_user_override}</Descriptions.Item>
@ -486,6 +486,27 @@ export function DeviceDetailPage() {
{device.ssh_port_override && ( {device.ssh_port_override && (
<Descriptions.Item label="SSH порт (override)">{device.ssh_port_override}</Descriptions.Item> <Descriptions.Item label="SSH порт (override)">{device.ssh_port_override}</Descriptions.Item>
)} )}
{device.ssh_key_id_override != null && (
<Descriptions.Item label="SSH ключ (override)">
<Typography.Text code>key_id={device.ssh_key_id_override}</Typography.Text>
</Descriptions.Item>
)}
{device.ssh_last_auth && (
<Descriptions.Item label="Последний успешный SSH">
<Space size={4} wrap>
<Tag color="green">{String(device.ssh_last_auth.type ?? '—')}</Tag>
{device.ssh_last_auth.username && (
<Typography.Text code>{String(device.ssh_last_auth.username)}</Typography.Text>
)}
{device.ssh_last_auth.key_id != null && (
<Typography.Text type="secondary">key_id={String(device.ssh_last_auth.key_id)}</Typography.Text>
)}
{device.ssh_last_auth.cred_id != null && (
<Typography.Text type="secondary">cred_id={String(device.ssh_last_auth.cred_id)}</Typography.Text>
)}
</Space>
</Descriptions.Item>
)}
</Descriptions> </Descriptions>
)} )}

View file

@ -20,6 +20,7 @@ import {
Modal, Modal,
Popconfirm, Popconfirm,
Row, Row,
Select,
Space, Space,
Table, Table,
Tag, Tag,
@ -41,6 +42,7 @@ import {
type ObjectItem, type ObjectItem,
type ObjectImportPreview, type ObjectImportPreview,
} from '../api/objects' } from '../api/objects'
import { useSSHKeys } from '../api/credentials'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
interface Props { interface Props {
@ -53,6 +55,7 @@ export function ObjectsPage({ mode }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const { data: objects, isLoading } = useObjects() const { data: objects, isLoading } = useObjects()
const { data: meta } = useObjectsMeta() const { data: meta } = useObjectsMeta()
const { data: sshKeys } = useSSHKeys()
const createObject = useCreateObject() const createObject = useCreateObject()
const updateObject = useUpdateObject() const updateObject = useUpdateObject()
const deleteObject = useDeleteObject() const deleteObject = useDeleteObject()
@ -117,6 +120,21 @@ export function ObjectsPage({ mode }: Props) {
client: row.client ?? '', client: row.client ?? '',
ssh_user: row.ssh_user ?? '', ssh_user: row.ssh_user ?? '',
db_host: row.db_host ?? '', 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) setEditTarget(row)
setModalOpen(true) setModalOpen(true)
@ -526,6 +544,149 @@ export function ObjectsPage({ mode }: Props) {
</Col> </Col>
</Row> </Row>
{/* SSH Credential list */}
<Divider orientation="left" plain>Список SSH-кредов (перебор при подключении)</Divider>
<Form.List name="ssh_credentials">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Row gutter={8} key={key} style={{ marginBottom: 4 }} align="middle">
<Col span={5}>
<Form.Item {...rest} name={[name, 'type']} style={{ marginBottom: 0 }}>
<Select placeholder="Тип" options={[{ value: 'password', label: 'Пароль' }, { value: 'key', label: 'SSH-ключ' }]} />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...rest} name={[name, 'username']} style={{ marginBottom: 0 }}>
<Input placeholder="Логин" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item noStyle shouldUpdate>
{() => {
const credType = form.getFieldValue(['ssh_credentials', name, 'type'])
return credType === 'key' ? (
<Form.Item {...rest} name={[name, 'ssh_key_id']} style={{ marginBottom: 0 }}>
<Select
placeholder="Выбрать ключ"
allowClear
options={(sshKeys ?? []).map((k) => ({ value: k.id, label: k.name }))}
/>
</Form.Item>
) : (
<Form.Item {...rest} name={[name, 'password']} style={{ marginBottom: 0 }}>
<Input.Password placeholder={editTarget ? 'Новый пароль' : 'Пароль'} />
</Form.Item>
)
}}
</Form.Item>
</Col>
<Col span={4}>
<Form.Item {...rest} name={[name, 'priority']} style={{ marginBottom: 0 }}>
<InputNumber placeholder="Приоритет" style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={2}>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => remove(name)} />
</Col>
</Row>
))}
<Button
type="dashed"
onClick={() => add({ type: 'password', username: '', priority: fields.length * 10 })}
icon={<PlusOutlined />}
size="small"
style={{ marginTop: 4 }}
>
Добавить учётные данные
</Button>
</>
)}
</Form.List>
{/* Category credential list */}
<Divider orientation="left" plain>Креды по категории устройств (SSH / HTTP)</Divider>
<Form.List name="category_credentials">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Row gutter={8} key={key} style={{ marginBottom: 4 }} align="middle">
<Col span={5}>
<Form.Item {...rest} name={[name, 'category']} style={{ marginBottom: 0 }}>
<Select placeholder="Категория" options={[
{ value: 'camera', label: 'Камера' },
{ value: 'router', label: 'Роутер' },
{ value: 'main_server', label: 'Сервер' },
{ value: 'vm', label: 'ВМ' },
{ value: 'embedded', label: 'Embedded' },
{ value: 'io_board', label: 'IO Board' },
{ value: 'bank_terminal', label: 'Терминал' },
{ value: 'cash_register', label: 'Касса' },
{ value: 'other', label: 'Прочее' },
]} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item {...rest} name={[name, 'protocol']} style={{ marginBottom: 0 }}>
<Select placeholder="Протокол" options={[
{ value: 'ssh', label: 'SSH' },
{ value: 'http', label: 'HTTP' },
]} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item {...rest} name={[name, 'type']} style={{ marginBottom: 0 }}>
<Select placeholder="Тип" options={[
{ value: 'password', label: 'Пароль' },
{ value: 'key', label: 'SSH-ключ' },
]} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item {...rest} name={[name, 'username']} style={{ marginBottom: 0 }}>
<Input placeholder="Логин" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item noStyle shouldUpdate>
{() => {
const credType = form.getFieldValue(['category_credentials', name, 'type'])
return credType === 'key' ? (
<Form.Item {...rest} name={[name, 'ssh_key_id']} style={{ marginBottom: 0 }}>
<Select
placeholder="Выбрать ключ"
allowClear
options={(sshKeys ?? []).map((k) => ({ value: k.id, label: k.name }))}
/>
</Form.Item>
) : (
<Form.Item {...rest} name={[name, 'password']} style={{ marginBottom: 0 }}>
<Input.Password placeholder={editTarget ? 'Новый пароль' : 'Пароль'} />
</Form.Item>
)
}}
</Form.Item>
</Col>
<Col span={2}>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => remove(name)} />
</Col>
</Row>
))}
<Button
type="dashed"
onClick={() => add({ category: 'camera', protocol: 'http', type: 'password', username: '' })}
icon={<PlusOutlined />}
size="small"
style={{ marginTop: 4 }}
>
Добавить
</Button>
</>
)}
</Form.List>
<Divider orientation="left" plain> <Divider orientation="left" plain>
Подключение к БД объекта{' '} Подключение к БД объекта{' '}
<Typography.Text type="secondary" style={{ fontWeight: 400 }}> <Typography.Text type="secondary" style={{ fontWeight: 400 }}>