From f77a5be371fe874e735c7562d00f0db6b2fa7c01 Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 9 Apr 2026 14:33:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=20=D1=81?= =?UTF-8?q?=D1=81=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0012_global_credentials.py | 39 +++++ backend/app/models/__init__.py | 3 +- backend/app/models/ssh_key.py | 26 +++ backend/app/routers/admin.py | 48 ++++++ backend/app/schemas/ssh_key.py | 30 ++++ backend/app/services/ssh.py | 44 ++++- frontend/src/api/admin.ts | 41 +++++ frontend/src/pages/Admin.tsx | 162 ++++++++++++++++++ 8 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0012_global_credentials.py diff --git a/backend/alembic/versions/0012_global_credentials.py b/backend/alembic/versions/0012_global_credentials.py new file mode 100644 index 0000000..aed5bcb --- /dev/null +++ b/backend/alembic/versions/0012_global_credentials.py @@ -0,0 +1,39 @@ +"""global_ssh_credentials table + +Revision ID: 0012 +Revises: 0011 +Create Date: 2026-04-09 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0012" +down_revision = "0011" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "global_ssh_credentials", + sa.Column("id", sa.Integer(), primary_key=True), + 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.Column("description", sa.String(200), nullable=True), + sa.CheckConstraint("type IN ('password', 'key')", name="ck_global_ssh_cred_type"), + ) + op.create_index("ix_global_ssh_creds_priority", "global_ssh_credentials", ["priority"]) + + +def downgrade() -> None: + op.drop_index("ix_global_ssh_creds_priority", table_name="global_ssh_credentials") + op.drop_table("global_ssh_credentials") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ac13a46..bfd6782 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,7 +9,7 @@ from .zone import Zone from .snapshot import ConfigSnapshot, SnapshotContent from .alert import Alert from .plugin import Plugin -from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential +from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential __all__ = [ "Base", @@ -32,4 +32,5 @@ __all__ = [ "SSHKey", "ObjectSSHCredential", "ObjectCategoryCredential", + "GlobalSSHCredential", ] diff --git a/backend/app/models/ssh_key.py b/backend/app/models/ssh_key.py index 1817975..a72cdc8 100644 --- a/backend/app/models/ssh_key.py +++ b/backend/app/models/ssh_key.py @@ -81,3 +81,29 @@ class ObjectCategoryCredential(Base): object: Mapped["Object"] = relationship("Object", back_populates="category_credentials") ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey") + + +class GlobalSSHCredential(Base): + """App-wide SSH credential used as last-resort fallback for all objects/devices. + + Tried after all object-level credentials are exhausted. + type='password': username + password_enc required. + type='key': username + ssh_key_id required. + """ + + __tablename__ = "global_ssh_credentials" + __table_args__ = ( + CheckConstraint("type IN ('password', 'key')", name="ck_global_ssh_cred_type"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=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) + description: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2aeb28c..26f79d0 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -9,11 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, require_admin from app.models.plugin import Plugin +from app.models.ssh_key import GlobalSSHCredential from app.models.user import User from app.plugins.loader import BUILTIN_ACTIONS from app.plugins.registry import action_registry from app.schemas.admin import PluginRead, PluginUpdate, UserCreate, UserRead, UserUpdate +from app.schemas.ssh_key import GlobalSSHCredentialCreate, GlobalSSHCredentialRead from app.services.auth import hash_password +from app.services.crypto import encrypt router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) @@ -158,3 +161,48 @@ async def delete_user( if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") await db.delete(user) + + +# ── Global SSH Credentials ───────────────────────────────────────────────────── + +@router.get("/global-ssh-credentials", response_model=list[GlobalSSHCredentialRead]) +async def list_global_ssh_credentials( + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + result = await db.execute( + select(GlobalSSHCredential).order_by(GlobalSSHCredential.priority, GlobalSSHCredential.id) + ) + return result.scalars().all() + + +@router.post("/global-ssh-credentials", response_model=GlobalSSHCredentialRead, status_code=status.HTTP_201_CREATED) +async def create_global_ssh_credential( + body: GlobalSSHCredentialCreate, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + cred = GlobalSSHCredential( + type=body.type, + username=body.username, + password_enc=encrypt(body.password) if body.password else None, + ssh_key_id=body.ssh_key_id, + priority=body.priority, + description=body.description, + ) + db.add(cred) + await db.flush() + await db.refresh(cred) + return cred + + +@router.delete("/global-ssh-credentials/{cred_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_global_ssh_credential( + cred_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_admin), +): + cred = await db.get(GlobalSSHCredential, cred_id) + if cred is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found") + await db.delete(cred) diff --git a/backend/app/schemas/ssh_key.py b/backend/app/schemas/ssh_key.py index 25dbf08..0e196c7 100644 --- a/backend/app/schemas/ssh_key.py +++ b/backend/app/schemas/ssh_key.py @@ -86,3 +86,33 @@ class ObjectCategoryCredentialRead(BaseModel): priority: int model_config = {"from_attributes": True} + + +# --- Global SSH credentials --- + +class GlobalSSHCredentialCreate(BaseModel): + type: Literal["password", "key"] + username: str + password: Optional[str] = None + ssh_key_id: Optional[int] = None + priority: int = 0 + description: Optional[str] = None + + @model_validator(mode="after") + def check_fields(self) -> "GlobalSSHCredentialCreate": + 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 GlobalSSHCredentialRead(BaseModel): + id: int + type: str + username: str + ssh_key_id: Optional[int] = None + priority: int + description: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/app/services/ssh.py b/backend/app/services/ssh.py index b5368d1..8da8506 100644 --- a/backend/app/services/ssh.py +++ b/backend/app/services/ssh.py @@ -62,6 +62,9 @@ class SSHResult: exit_code: int = -1 duration_ms: int = 0 error: str = "" + # True when SSH auth succeeded (connection established), even if command returned non-zero. + # Used to distinguish "wrong password" from "command failed". + auth_ok: bool = False @dataclass @@ -91,6 +94,35 @@ async def _load_key_path(db: AsyncSession, key_id: int) -> Optional[str]: return key.path if key else None +async def _add_global_credentials( + options: list[_AuthOption], + seen: set[str], + db: Optional[AsyncSession], +) -> None: + """Append global SSH credentials (app-wide fallback) to the options list.""" + if not db: + return + from sqlalchemy import select as sa_select + from app.models.ssh_key import GlobalSSHCredential + + def _add(opt: _AuthOption) -> None: + k = _opt_key(opt) + if k not in seen: + seen.add(k) + options.append(opt) + + result = await db.execute( + sa_select(GlobalSSHCredential).order_by(GlobalSSHCredential.priority, GlobalSSHCredential.id) + ) + for gcred in result.scalars().all(): + if gcred.type == "key" and gcred.ssh_key_id: + path = await _load_key_path(db, gcred.ssh_key_id) + if path: + _add(_AuthOption(type="key", username=gcred.username, key_path=path, key_id=gcred.ssh_key_id)) + elif gcred.type == "password" and gcred.password_enc: + _add(_AuthOption(type="password", username=gcred.username, password=decrypt(gcred.password_enc))) + + 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. @@ -189,6 +221,9 @@ async def build_auth_options(device: Device, obj: Object, db: Optional[AsyncSess password=decrypt(obj.ssh_pass_enc), )) + # 7. Global app-wide credentials (last resort) + await _add_global_credentials(options, seen, db) + return options @@ -225,6 +260,7 @@ async def _try_connect( stderr=(result.stderr or "").strip(), exit_code=result.exit_status or 0, duration_ms=duration, + auth_ok=True, # Connection was established — auth succeeded ) except asyncio.TimeoutError: return SSHResult( @@ -317,11 +353,14 @@ async def run_command( for i, opt in enumerate(options): result = await _try_connect(device.ip, port, opt, command, timeout) - if result.success: + if result.auth_ok: + # Connection was established — auth succeeded. + # Return the result regardless of exit_code (command may have failed, not auth). if db: await _save_last_auth(device, db, opt) return result + # Auth failed (wrong password / key / connection error) # If last_auth method failed, clear it if i == 0 and opt is last_auth_option and db: device.ssh_last_auth = None @@ -414,4 +453,7 @@ async def build_object_auth_options(obj: Object, db: Optional[AsyncSession] = No if obj.ssh_user and obj.ssh_pass_enc: _add(_AuthOption(type="password", username=obj.ssh_user, password=decrypt(obj.ssh_pass_enc))) + # Global app-wide credentials (last resort) + await _add_global_credentials(options, seen, db) + return options diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index f5401e6..6ba0a28 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -86,3 +86,44 @@ export const useDeleteAdminUser = () => { onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }), }) } + +// ── Global SSH Credentials ──────────────────────────────────────────────────── + +export interface GlobalSSHCredentialItem { + id: number + type: 'password' | 'key' + username: string + ssh_key_id: number | null + priority: number + description: string | null +} + +export const useGlobalSSHCredentials = () => + useQuery({ + queryKey: ['global-ssh-credentials'], + queryFn: () => + api.get('/api/v1/admin/global-ssh-credentials').then((r) => r.data), + }) + +export const useCreateGlobalSSHCredential = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { + type: 'password' | 'key' + username: string + password?: string + ssh_key_id?: number + priority?: number + description?: string + }) => api.post('/api/v1/admin/global-ssh-credentials', body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['global-ssh-credentials'] }), + }) +} + +export const useDeleteGlobalSSHCredential = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/admin/global-ssh-credentials/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['global-ssh-credentials'] }), + }) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 73649fd..03da479 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,6 +1,7 @@ import { DeleteOutlined, EditOutlined, + GlobalOutlined, KeyOutlined, PlusOutlined, UserOutlined, @@ -14,6 +15,7 @@ import { Divider, Form, Input, + InputNumber, Modal, Popconfirm, Row, @@ -31,9 +33,13 @@ import { useAdminUsers, useCreateAdminUser, useDeleteAdminUser, + useGlobalSSHCredentials, + useCreateGlobalSSHCredential, + useDeleteGlobalSSHCredential, usePlugins, useUpdateAdminUser, useUpdatePlugin, + type GlobalSSHCredentialItem, type UserItem, } from '../api/admin' import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials' @@ -373,6 +379,153 @@ function SSHKeysTab() { ) } +// ── Global SSH Credentials Tab ──────────────────────────────────────────────── +function GlobalCredsTab() { + const { data: creds, isLoading } = useGlobalSSHCredentials() + const { data: sshKeys } = useSSHKeys() + const createCred = useCreateGlobalSSHCredential() + const deleteCred = useDeleteGlobalSSHCredential() + const [form] = Form.useForm() + const [modalOpen, setModalOpen] = useState(false) + + const handleCreate = async () => { + const values = await form.validateFields() + const payload = stripEmpty(values) + try { + await createCred.mutateAsync(payload as any) + message.success('Учётные данные добавлены') + setModalOpen(false) + form.resetFields() + } catch (err: any) { + message.error(err?.response?.data?.detail ?? 'Ошибка при создании') + } + } + + const columns = [ + { + title: 'Тип', + dataIndex: 'type', + key: 'type', + width: 90, + render: (v: string) => {v === 'key' ? 'SSH-ключ' : 'Пароль'}, + }, + { + title: 'Логин', + dataIndex: 'username', + key: 'username', + render: (v: string) => {v}, + }, + { + title: 'SSH-ключ', + dataIndex: 'ssh_key_id', + key: 'ssh_key_id', + render: (v: number | null) => { + if (!v) return '—' + const key = (sshKeys ?? []).find((k) => k.id === v) + return key ? {key.name} : key_id={v} + }, + }, + { + title: 'Приоритет', + dataIndex: 'priority', + key: 'priority', + width: 100, + }, + { + title: 'Описание', + dataIndex: 'description', + key: 'description', + render: (v: string | null) => v ?? '—', + }, + { + title: '', + key: 'actions', + width: 60, + render: (_: unknown, row: GlobalSSHCredentialItem) => ( + deleteCred.mutateAsync(row.id).catch(() => message.error('Ошибка при удалении'))} + > + + + + setModalOpen(false)} + onOk={handleCreate} + confirmLoading={createCred.isPending} + okText="Добавить" + > +
+ +
+ + + + prev.type !== cur.type}> + {({ getFieldValue }) => + getFieldValue('type') === 'key' ? ( + + + + + + + ) +} + // ── Main Page ───────────────────────────────────────────────────────────────── export function AdminPage() { return ( @@ -412,6 +565,15 @@ export function AdminPage() { ), children: , }, + { + key: 'global-creds', + label: ( + + Глобальные SSH-кредентиалы + + ), + children: , + }, ]} />