глобал ссх

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 .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",
]

View file

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

View file

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

View file

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

View file

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

View file

@ -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<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 {
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) => <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 ─────────────────────────────────────────────────────────────────
export function AdminPage() {
return (
@ -412,6 +565,15 @@ export function AdminPage() {
),
children: <SSHKeysTab />,
},
{
key: 'global-creds',
label: (
<span>
<GlobalOutlined /> Глобальные SSH-кредентиалы
</span>
),
children: <GlobalCredsTab />,
},
]}
/>
</div>