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:
Your Name
2026-05-25 22:49:57 +08:00
parent 95aa8a610f
commit ccb78ed980
14 changed files with 935 additions and 140 deletions
+243 -14
View File
@@ -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 {