ssh подбор
This commit is contained in:
parent
948ebe5d3b
commit
783e6f80d4
20 changed files with 1351 additions and 84 deletions
114
backend/alembic/versions/0011_credentials.py
Normal file
114
backend/alembic/versions/0011_credentials.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
83
backend/app/models/ssh_key.py
Normal file
83
backend/app/models/ssh_key.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
77
backend/app/routers/ssh_keys.py
Normal file
77
backend/app/routers/ssh_keys.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
88
backend/app/schemas/ssh_key.py
Normal file
88
backend/app/schemas/ssh_key.py
Normal 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}
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
81
frontend/src/api/credentials.ts
Normal file
81
frontend/src/api/credentials.ts
Normal 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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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[]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue