глобал ссх

This commit is contained in:
dv 2026-04-09 14:33:34 +03:00
parent ee83f74fb9
commit f77a5be371
8 changed files with 391 additions and 2 deletions

View file

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

View file

@ -9,7 +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 from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential
__all__ = [ __all__ = [
"Base", "Base",
@ -32,4 +32,5 @@ __all__ = [
"SSHKey", "SSHKey",
"ObjectSSHCredential", "ObjectSSHCredential",
"ObjectCategoryCredential", "ObjectCategoryCredential",
"GlobalSSHCredential",
] ]

View file

@ -81,3 +81,29 @@ class ObjectCategoryCredential(Base):
object: Mapped["Object"] = relationship("Object", back_populates="category_credentials") object: Mapped["Object"] = relationship("Object", back_populates="category_credentials")
ssh_key: Mapped[Optional["SSHKey"]] = relationship("SSHKey") 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")

View file

@ -9,11 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_admin from app.dependencies import get_db, require_admin
from app.models.plugin import Plugin from app.models.plugin import Plugin
from app.models.ssh_key import GlobalSSHCredential
from app.models.user import User from app.models.user import User
from app.plugins.loader import BUILTIN_ACTIONS from app.plugins.loader import BUILTIN_ACTIONS
from app.plugins.registry import action_registry from app.plugins.registry import action_registry
from app.schemas.admin import PluginRead, PluginUpdate, UserCreate, UserRead, UserUpdate 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.auth import hash_password
from app.services.crypto import encrypt
router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
@ -158,3 +161,48 @@ async def delete_user(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await db.delete(user) 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)

View file

@ -86,3 +86,33 @@ class ObjectCategoryCredentialRead(BaseModel):
priority: int priority: int
model_config = {"from_attributes": True} 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}

View file

@ -62,6 +62,9 @@ class SSHResult:
exit_code: int = -1 exit_code: int = -1
duration_ms: int = 0 duration_ms: int = 0
error: str = "" 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 @dataclass
@ -91,6 +94,35 @@ async def _load_key_path(db: AsyncSession, key_id: int) -> Optional[str]:
return key.path if key else None 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]: 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. """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), password=decrypt(obj.ssh_pass_enc),
)) ))
# 7. Global app-wide credentials (last resort)
await _add_global_credentials(options, seen, db)
return options return options
@ -225,6 +260,7 @@ async def _try_connect(
stderr=(result.stderr or "").strip(), stderr=(result.stderr or "").strip(),
exit_code=result.exit_status or 0, exit_code=result.exit_status or 0,
duration_ms=duration, duration_ms=duration,
auth_ok=True, # Connection was established — auth succeeded
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
return SSHResult( return SSHResult(
@ -317,11 +353,14 @@ async def run_command(
for i, opt in enumerate(options): for i, opt in enumerate(options):
result = await _try_connect(device.ip, port, opt, command, timeout) 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: if db:
await _save_last_auth(device, db, opt) await _save_last_auth(device, db, opt)
return result return result
# Auth failed (wrong password / key / connection error)
# If last_auth method failed, clear it # If last_auth method failed, clear it
if i == 0 and opt is last_auth_option and db: if i == 0 and opt is last_auth_option and db:
device.ssh_last_auth = None 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: if obj.ssh_user and obj.ssh_pass_enc:
_add(_AuthOption(type="password", username=obj.ssh_user, password=decrypt(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 return options

View file

@ -86,3 +86,44 @@ export const useDeleteAdminUser = () => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-users'] }), 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<GlobalSSHCredentialItem[]>('/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<GlobalSSHCredentialItem>('/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'] }),
})
}

View file

@ -1,6 +1,7 @@
import { import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
GlobalOutlined,
KeyOutlined, KeyOutlined,
PlusOutlined, PlusOutlined,
UserOutlined, UserOutlined,
@ -14,6 +15,7 @@ import {
Divider, Divider,
Form, Form,
Input, Input,
InputNumber,
Modal, Modal,
Popconfirm, Popconfirm,
Row, Row,
@ -31,9 +33,13 @@ import {
useAdminUsers, useAdminUsers,
useCreateAdminUser, useCreateAdminUser,
useDeleteAdminUser, useDeleteAdminUser,
useGlobalSSHCredentials,
useCreateGlobalSSHCredential,
useDeleteGlobalSSHCredential,
usePlugins, usePlugins,
useUpdateAdminUser, useUpdateAdminUser,
useUpdatePlugin, useUpdatePlugin,
type GlobalSSHCredentialItem,
type UserItem, type UserItem,
} from '../api/admin' } from '../api/admin'
import { useSSHKeys, useCreateSSHKey, useDeleteSSHKey, type SSHKeyItem } from '../api/credentials' 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) => <Tag color={v === 'key' ? 'green' : 'blue'}>{v === 'key' ? 'SSH-ключ' : 'Пароль'}</Tag>,
},
{
title: 'Логин',
dataIndex: 'username',
key: 'username',
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{
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 ? <Tag color="green">{key.name}</Tag> : <Tag>key_id={v}</Tag>
},
},
{
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) => (
<Popconfirm
title="Удалить глобальные учётные данные?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteCred.mutateAsync(row.id).catch(() => message.error('Ошибка при удалении'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
return (
<>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Глобальные SSH-кредентиалы используются как последний резерв при подключении к устройствам,
если ни объектные, ни device-level настройки не подошли.
</Typography.Text>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
Добавить
</Button>
</div>
<Table
dataSource={creds ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={false}
/>
<Modal
title="Добавить глобальные SSH-кредентиалы"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleCreate}
confirmLoading={createCred.isPending}
okText="Добавить"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="type" label="Тип" rules={[{ required: true }]} initialValue="password">
<Select options={[
{ value: 'password', label: 'Пароль' },
{ value: 'key', label: 'SSH-ключ' },
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="priority" label="Приоритет" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="username" label="Логин" rules={[{ required: true }]}>
<Input placeholder="root" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
{({ getFieldValue }) =>
getFieldValue('type') === 'key' ? (
<Form.Item name="ssh_key_id" label="SSH-ключ" rules={[{ required: true }]}>
<Select
placeholder="Выбрать ключ"
options={(sshKeys ?? []).map((k) => ({ value: k.id, label: `${k.name} (${k.path})` }))}
/>
</Form.Item>
) : (
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
)
}
</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 (
@ -412,6 +565,15 @@ export function AdminPage() {
), ),
children: <SSHKeysTab />, children: <SSHKeysTab />,
}, },
{
key: 'global-creds',
label: (
<span>
<GlobalOutlined /> Глобальные SSH-кредентиалы
</span>
),
children: <GlobalCredsTab />,
},
]} ]}
/> />
</div> </div>