feat: SSH key preset system — dropdown + backend auto-resolve
- Add SshKeyPreset model + repo (encrypted_private_key, public_key) - Add ssh_key_preset_id to Server model + schemas - Add CRUD + reveal API routes (/api/ssh-key-presets/) - servers.py: auto-resolve ssh_key_preset_id → decrypt → set ssh_key_private - servers.html: key preset dropdown in key auth section, hides path/textarea on select - Mirror password preset pattern: zero frontend credential exposure Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+243
-14
@@ -13,12 +13,13 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
|
||||
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, PresetUpdate, SettingUpdatePayload, ApiKeyRevealRequest, SshKeyPresetCreate, SshKeyPresetUpdate
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
|
||||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog, Admin
|
||||
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, SshKeyPreset, AuditLog, Admin
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -31,10 +32,6 @@ SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key"}
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
class ApiKeyRevealRequest(BaseModel):
|
||||
current_password: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
# ── System Settings ──
|
||||
|
||||
@router.get("/", response_model=list)
|
||||
@@ -176,8 +173,14 @@ async def update_schedule(
|
||||
schedule = await repo.get_by_id(id)
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
# Explicit allowlist to prevent mass-assignment to sensitive columns
|
||||
ALLOWED_FIELDS = {
|
||||
"name", "source_path", "server_ids", "run_mode", "cron_expr",
|
||||
"fire_at", "sync_mode", "enabled", "schedule_type",
|
||||
"script_id", "script_content", "exec_timeout", "long_task",
|
||||
}
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
if hasattr(schedule, key) and key != "id":
|
||||
if key in ALLOWED_FIELDS:
|
||||
setattr(schedule, key, value)
|
||||
updated = await repo.update(schedule)
|
||||
|
||||
@@ -257,6 +260,86 @@ async def create_preset(
|
||||
return {"id": created.id, "name": created.name}
|
||||
|
||||
|
||||
@preset_router.put("/{id}", response_model=dict)
|
||||
async def update_preset(
|
||||
id: int,
|
||||
payload: PresetUpdate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a password preset (name and/or password — password encrypted)"""
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
preset = await repo.get_by_id(id)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
new_pw = updates.pop("encrypted_pw", None)
|
||||
if updates.get("name"):
|
||||
preset.name = updates["name"]
|
||||
if new_pw:
|
||||
preset.encrypted_pw = encrypt_value(new_pw)
|
||||
|
||||
await repo.update(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="update_preset",
|
||||
target_type="preset",
|
||||
target_id=id,
|
||||
detail=f"name={preset.name}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return {"id": preset.id, "name": preset.name}
|
||||
|
||||
|
||||
@preset_router.post("/{id}/reveal", response_model=dict)
|
||||
async def reveal_preset(
|
||||
id: int,
|
||||
payload: ApiKeyRevealRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal a password preset's decrypted value (re-auth + audit-logged)."""
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
# Re-authenticate: require current password (same as reveal_api_key)
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(
|
||||
payload.current_password.encode(),
|
||||
current.password_hash.encode(),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
preset = await repo.get_by_id(id)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
try:
|
||||
plaintext = decrypt_value(preset.encrypted_pw)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=500, detail="解密失败")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="reveal_preset",
|
||||
target_type="preset", target_id=id,
|
||||
detail=f"name={preset.name}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return {"id": preset.id, "name": preset.name, "password": plaintext}
|
||||
|
||||
|
||||
@preset_router.delete("/{id}", status_code=204)
|
||||
async def delete_preset(
|
||||
id: int,
|
||||
@@ -280,6 +363,151 @@ async def delete_preset(
|
||||
))
|
||||
|
||||
|
||||
# ── SSH Key Presets ──
|
||||
|
||||
ssh_key_preset_router = APIRouter(prefix="/api/ssh-key-presets", tags=["ssh-key-presets"])
|
||||
|
||||
|
||||
@ssh_key_preset_router.get("/", response_model=list)
|
||||
async def list_ssh_key_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
|
||||
"""List all SSH key presets"""
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
presets = await repo.get_all()
|
||||
return [{"id": p.id, "name": p.name, "public_key": p.public_key or "", "created_at": str(p.created_at)} for p in presets]
|
||||
|
||||
|
||||
@ssh_key_preset_router.post("/", response_model=dict, status_code=201)
|
||||
async def create_ssh_key_preset(
|
||||
payload: SshKeyPresetCreate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create an SSH key preset (private key will be encrypted)"""
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
encrypted_private = encrypt_value(payload.private_key)
|
||||
preset = SshKeyPreset(name=payload.name, encrypted_private_key=encrypted_private, public_key=payload.public_key)
|
||||
created = await repo.create(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="create_ssh_key_preset",
|
||||
target_type="ssh_key_preset",
|
||||
target_id=created.id,
|
||||
detail=f"name={created.name}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return {"id": created.id, "name": created.name}
|
||||
|
||||
|
||||
@ssh_key_preset_router.put("/{id}", response_model=dict)
|
||||
async def update_ssh_key_preset(
|
||||
id: int,
|
||||
payload: SshKeyPresetUpdate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an SSH key preset (name and/or private key — private key encrypted)"""
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
preset = await repo.get_by_id(id)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="SSH key preset not found")
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
new_private_key = updates.pop("private_key", None)
|
||||
if updates.get("name"):
|
||||
preset.name = updates["name"]
|
||||
if new_private_key:
|
||||
preset.encrypted_private_key = encrypt_value(new_private_key)
|
||||
if "public_key" in updates:
|
||||
preset.public_key = updates["public_key"]
|
||||
|
||||
await repo.update(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="update_ssh_key_preset",
|
||||
target_type="ssh_key_preset",
|
||||
target_id=id,
|
||||
detail=f"name={preset.name}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return {"id": preset.id, "name": preset.name}
|
||||
|
||||
|
||||
@ssh_key_preset_router.post("/{id}/reveal", response_model=dict)
|
||||
async def reveal_ssh_key_preset(
|
||||
id: int,
|
||||
payload: ApiKeyRevealRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal an SSH key preset's decrypted private key (re-auth + audit-logged)."""
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
|
||||
|
||||
# Re-authenticate: require current password
|
||||
admin_repo = AdminRepositoryImpl(db)
|
||||
current = await admin_repo.get_by_id(admin.id)
|
||||
if not current or not bcrypt.checkpw(
|
||||
payload.current_password.encode(),
|
||||
current.password_hash.encode(),
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
preset = await repo.get_by_id(id)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="SSH key preset not found")
|
||||
|
||||
try:
|
||||
plaintext = decrypt_value(preset.encrypted_private_key)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=500, detail="解密失败")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="reveal_ssh_key_preset",
|
||||
target_type="ssh_key_preset", target_id=id,
|
||||
detail=f"name={preset.name}",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
return {"id": preset.id, "name": preset.name, "private_key": plaintext, "public_key": preset.public_key or ""}
|
||||
|
||||
|
||||
@ssh_key_preset_router.delete("/{id}", status_code=204)
|
||||
async def delete_ssh_key_preset(
|
||||
id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete an SSH key preset"""
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
result = await repo.delete(id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="SSH key preset not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_ssh_key_preset",
|
||||
target_type="ssh_key_preset",
|
||||
target_id=id,
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
|
||||
# ── Alert History ──
|
||||
|
||||
alert_history_router = APIRouter(prefix="/api/alert-history", tags=["alerts"])
|
||||
@@ -387,14 +615,15 @@ async def alert_stats(
|
||||
)
|
||||
type_rows = (await db.execute(type_q)).all()
|
||||
|
||||
# Daily counts
|
||||
# Daily counts (parameterized to avoid f-string SQL)
|
||||
days_val = int(days)
|
||||
daily_q = text(
|
||||
f"SELECT DATE(created_at) as day, "
|
||||
f"SUM(is_recovery=0) as alerts, SUM(is_recovery=1) as recoveries "
|
||||
f"FROM alert_logs "
|
||||
f"WHERE created_at >= DATE_SUB(NOW(), INTERVAL {int(days)} DAY) "
|
||||
f"GROUP BY DATE(created_at) ORDER BY day"
|
||||
)
|
||||
"SELECT DATE(created_at) as day, "
|
||||
"SUM(is_recovery=0) as alerts, SUM(is_recovery=1) as recoveries "
|
||||
"FROM alert_logs "
|
||||
"WHERE created_at >= DATE_SUB(NOW(), INTERVAL :days DAY) "
|
||||
"GROUP BY DATE(created_at) ORDER BY day"
|
||||
).bindparams(days=days_val)
|
||||
daily_rows = (await db.execute(daily_q)).all()
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user