глобал ссх
This commit is contained in:
parent
ee83f74fb9
commit
f77a5be371
8 changed files with 391 additions and 2 deletions
39
backend/alembic/versions/0012_global_credentials.py
Normal file
39
backend/alembic/versions/0012_global_credentials.py
Normal 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")
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue