second commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/admin/__init__.py
Normal file
0
app/admin/__init__.py
Normal file
340
app/admin/routes.py
Normal file
340
app/admin/routes.py
Normal file
@ -0,0 +1,340 @@
|
||||
"""
|
||||
Administrative CRUD-API: User, Hostgruppen, Hosts, Rollenvergabe, SSH-Keys.
|
||||
|
||||
Alle zustandsaendernden Endpunkte sind auf globale Admins beschraenkt
|
||||
(`require_global_admin`) und schreiben einen Audit-Log-Eintrag (Konzept 4.7).
|
||||
Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im
|
||||
Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht
|
||||
vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.auth.deps import CurrentUser, require_global_admin
|
||||
from app.db import get_db
|
||||
from app.models.schemas import (
|
||||
HostCreateRequest,
|
||||
HostGroupCreateRequest,
|
||||
RdpCredentialsRequest,
|
||||
RoleGrantRequest,
|
||||
SshKeyCreateRequest,
|
||||
UserCreateRequest,
|
||||
)
|
||||
from app.security.audit import verify_chain, write_audit_event
|
||||
from app.security.crypto import encrypt_secret
|
||||
from app.security.passwords import hash_password
|
||||
from app.ssh_proxy.proxy import discover_and_store_host_key
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# --- Users -----------------------------------------------------------------
|
||||
|
||||
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,))
|
||||
if await cursor.fetchone() is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
|
||||
|
||||
pw_hash = hash_password(payload.initial_password)
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||
"VALUES (?, ?, ?, 1)",
|
||||
(payload.username, pw_hash, int(payload.is_admin)),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
await write_audit_event(
|
||||
conn, event_type="user_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"new_user_id": new_id, "username": payload.username, "is_admin": payload.is_admin},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"id": new_id, "username": payload.username}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(admin: CurrentUser = Depends(require_global_admin)):
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0], "username": r[1], "is_admin": bool(r[2]), "is_active": bool(r[3]),
|
||||
"totp_enrolled": bool(r[4]), "created_at": r[5],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/deactivate")
|
||||
async def deactivate_user(
|
||||
user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
await conn.execute(
|
||||
"UPDATE users SET is_active = 0, session_version = session_version + 1 WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="user_deactivated", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"target_user_id": user_id},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- Hostgruppen -------------------------------------------------------------
|
||||
|
||||
@router.post("/host-groups", status_code=status.HTTP_201_CREATED)
|
||||
async def create_host_group(
|
||||
payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO host_groups (name, description) VALUES (?, ?)",
|
||||
(payload.name, payload.description),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
await write_audit_event(
|
||||
conn, event_type="host_group_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"id": new_id, "name": payload.name},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"id": new_id, "name": payload.name}
|
||||
|
||||
|
||||
@router.get("/host-groups")
|
||||
async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)):
|
||||
conn = get_db()
|
||||
cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
return [{"id": r[0], "name": r[1], "description": r[2]} for r in rows]
|
||||
|
||||
|
||||
# --- Hosts -------------------------------------------------------------------
|
||||
|
||||
@router.post("/hosts", status_code=status.HTTP_201_CREATED)
|
||||
async def create_host(
|
||||
payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
INSERT INTO hosts (
|
||||
host_group_id, hostname, address, protocol, port, os_type,
|
||||
ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain,
|
||||
rdp_require_nla, clipboard_enabled, file_transfer_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.host_group_id, payload.hostname, payload.address, payload.protocol,
|
||||
payload.port, payload.os_type, payload.ssh_host_key_fingerprint,
|
||||
payload.ssh_username, payload.rdp_username, payload.rdp_domain,
|
||||
int(payload.rdp_require_nla), int(payload.clipboard_enabled),
|
||||
int(payload.file_transfer_enabled),
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
await write_audit_event(
|
||||
conn, event_type="host_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"id": new_id, "hostname": payload.hostname, "protocol": payload.protocol},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"id": new_id}
|
||||
|
||||
|
||||
@router.get("/hosts")
|
||||
async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depends(require_global_admin)):
|
||||
conn = get_db()
|
||||
if host_group_id is not None:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, protocol, port, os_type, host_group_id "
|
||||
"FROM hosts WHERE host_group_id = ? ORDER BY id",
|
||||
(host_group_id,),
|
||||
)
|
||||
else:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, protocol, port, os_type, host_group_id FROM hosts ORDER BY id"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0], "hostname": r[1], "address": r[2], "protocol": r[3],
|
||||
"port": r[4], "os_type": r[5], "host_group_id": r[6],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/discover-host-key")
|
||||
async def discover_host_key(
|
||||
host_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
|
||||
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
|
||||
alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird
|
||||
prominent im Audit-Log vermerkt."""
|
||||
conn = get_db()
|
||||
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
|
||||
await write_audit_event(
|
||||
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
|
||||
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"host_id": host_id, "fingerprint": fingerprint}
|
||||
|
||||
|
||||
@router.put("/hosts/{host_id}/rdp-credentials")
|
||||
async def set_rdp_credentials(
|
||||
host_id: int, payload: RdpCredentialsRequest, request: Request,
|
||||
admin: CurrentUser = Depends(require_global_admin),
|
||||
):
|
||||
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
|
||||
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
|
||||
conn = get_db()
|
||||
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
||||
await conn.execute(
|
||||
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
|
||||
"VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
|
||||
"ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, "
|
||||
"updated_at = excluded.updated_at",
|
||||
(host_id, encrypted),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"host_id": host_id},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- Rollenvergabe -------------------------------------------------------------
|
||||
|
||||
@router.post("/roles/grant")
|
||||
async def grant_role(
|
||||
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||
role_row = await role_cursor.fetchone()
|
||||
if role_row is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
||||
|
||||
await conn.execute(
|
||||
"INSERT OR REPLACE INTO user_hostgroup_roles "
|
||||
"(user_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(payload.user_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="role_granted", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={
|
||||
"target_user_id": payload.user_id, "host_group_id": payload.host_group_id,
|
||||
"role": payload.role_name, "expires_at": payload.expires_at,
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/roles/revoke")
|
||||
async def revoke_role(
|
||||
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
|
||||
role_row = await role_cursor.fetchone()
|
||||
if role_row is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM user_hostgroup_roles WHERE user_id = ? AND host_group_id = ? AND role_id = ?",
|
||||
(payload.user_id, payload.host_group_id, role_row[0]),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="role_revoked", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={
|
||||
"target_user_id": payload.user_id, "host_group_id": payload.host_group_id,
|
||||
"role": payload.role_name,
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- SSH-Keyverwaltung ---------------------------------------------------------
|
||||
|
||||
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||
async def create_ssh_key(
|
||||
payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
"""Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit
|
||||
dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses
|
||||
Requests im Prozessspeicher (siehe Konzept 6.4: Key verlaesst den Server nie)."""
|
||||
conn = get_db()
|
||||
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"id": new_id, "label": payload.label, "key_type": payload.key_type},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"id": new_id, "label": payload.label}
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
|
||||
async def map_ssh_key_to_host(
|
||||
host_id: int, key_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
conn = get_db()
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
|
||||
(host_id, key_id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_key_mapped", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"host_id": host_id, "ssh_key_id": key_id},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- Audit-Log -----------------------------------------------------------------
|
||||
|
||||
@router.get("/audit-log")
|
||||
async def get_audit_log(
|
||||
limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_global_admin)
|
||||
):
|
||||
limit = max(1, min(limit, 1000))
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log "
|
||||
"ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{"id": r[0], "ts": r[1], "user_id": r[2], "client_ip": r[3], "event_type": r[4], "details": r[5]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/audit-log/verify")
|
||||
async def verify_audit_log(admin: CurrentUser = Depends(require_global_admin)):
|
||||
"""Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7)."""
|
||||
conn = get_db()
|
||||
intact, broken_at = await verify_chain(conn)
|
||||
return {"intact": intact, "first_broken_id": broken_at}
|
||||
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
122
app/auth/deps.py
Normal file
122
app/auth/deps.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status
|
||||
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role, user_has_role_for_host
|
||||
from app.security.sessions import (
|
||||
SESSION_COOKIE_NAME,
|
||||
decode_session_token,
|
||||
is_expired,
|
||||
refresh_session_token,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentUser:
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
response: Response,
|
||||
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||
) -> CurrentUser:
|
||||
if jh_session is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet")
|
||||
|
||||
payload = decode_session_token(jh_session)
|
||||
if payload is None or is_expired(payload):
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen oder ungueltig")
|
||||
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
|
||||
(payload.user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or not row[3] or row[4] != payload.session_version:
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session ungueltig")
|
||||
|
||||
# Gleitenden Idle-Timeout verlaengern (gleiche session_version/login_ts).
|
||||
new_token = refresh_session_token(payload)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
new_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
max_age=None, # Session-Cookie; Ablauf wird serverseitig durchgesetzt
|
||||
path="/",
|
||||
)
|
||||
|
||||
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
|
||||
|
||||
|
||||
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
|
||||
"""Wie get_current_user(), aber fuer WebSocket-Handshakes: kein Cookie-Refresh
|
||||
(WebSockets erlauben nach dem Handshake kein Set-Cookie mehr), stattdessen
|
||||
wird der Idle-Timeout beim naechsten regulaeren HTTP-Request durchgesetzt."""
|
||||
token = websocket.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token is None:
|
||||
return None
|
||||
payload = decode_session_token(token)
|
||||
if payload is None or is_expired(payload):
|
||||
return None
|
||||
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
|
||||
(payload.user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or not row[3] or row[4] != payload.session_version:
|
||||
return None
|
||||
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
|
||||
|
||||
|
||||
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
||||
return user
|
||||
|
||||
|
||||
def require_host_group_role(role_name: str):
|
||||
"""Dependency-Factory: prueft Rolle des Users fuer eine per Pfad-/Query-Param
|
||||
uebergebene host_group_id. Globale Admins duerfen immer."""
|
||||
|
||||
async def _dep(host_group_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if user.is_admin:
|
||||
return user
|
||||
conn = get_db()
|
||||
allowed = await user_has_role(
|
||||
conn, user_id=user.id, host_group_id=host_group_id, role_name=role_name
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Hostgruppe")
|
||||
return user
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def require_host_role(role_name: str):
|
||||
"""Dependency-Factory: prueft Rolle des Users fuer einen konkreten host_id."""
|
||||
|
||||
async def _dep(host_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if user.is_admin:
|
||||
return user
|
||||
conn = get_db()
|
||||
allowed = await user_has_role_for_host(
|
||||
conn, user_id=user.id, host_id=host_id, role_name=role_name
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diesen Host")
|
||||
return user
|
||||
|
||||
return _dep
|
||||
330
app/auth/routes.py
Normal file
330
app/auth/routes.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""Login-Flow: Passwort -> Pflicht-TOTP -> Session-Cookie (siehe Konzept 4.5/6.2)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
from app.auth.deps import CurrentUser, get_current_user
|
||||
from app.db import get_db
|
||||
from app.models.schemas import (
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
TotpConfirmRequest,
|
||||
TotpLoginRequest,
|
||||
)
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.passwords import hash_password, needs_rehash, verify_password
|
||||
from app.security.pending_totp import create_pending_token, decode_pending_token
|
||||
from app.security.rate_limit import login_rate_limiter
|
||||
from app.security.sessions import SESSION_COOKIE_NAME, create_session_token
|
||||
from app.security.totp import (
|
||||
decrypt_totp_secret,
|
||||
encrypt_totp_secret,
|
||||
generate_recovery_codes,
|
||||
generate_totp_secret,
|
||||
hash_recovery_code,
|
||||
provisioning_uri,
|
||||
verify_totp_code,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# Konstante Dummy-Hash-Verifikation gegen Username-Enumeration per Timing-Seitenkanal.
|
||||
_DUMMY_HASH = hash_password(secrets.token_hex(16))
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# Nur die direkte Peer-IP; X-Forwarded-For wird ausschliesslich vertrauenswuerdig
|
||||
# ausgewertet, wenn nginx mit set_real_ip_from konfiguriert ist (Konzept 7.2a).
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginRequest, request: Request):
|
||||
ip = _client_ip(request)
|
||||
if not login_rate_limiter.allow(ip):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche, bitte warten.")
|
||||
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, password_hash, is_active, failed_logins, locked_until, totp_enrolled "
|
||||
"FROM users WHERE username = ?",
|
||||
(payload.username,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
verify_password(_DUMMY_HASH, payload.password) # Timing angleichen
|
||||
await write_audit_event(
|
||||
conn, event_type="login_failed", user_id=None, client_ip=ip,
|
||||
details={"reason": "unknown_user", "username": payload.username},
|
||||
)
|
||||
await conn.commit()
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
|
||||
|
||||
user_id, pw_hash, is_active, failed_logins, locked_until, totp_enrolled = row
|
||||
|
||||
if locked_until and locked_until > datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"):
|
||||
await write_audit_event(
|
||||
conn, event_type="login_failed", user_id=user_id, client_ip=ip,
|
||||
details={"reason": "locked"},
|
||||
)
|
||||
await conn.commit()
|
||||
raise HTTPException(status.HTTP_423_LOCKED, "Konto vorruebergehend gesperrt")
|
||||
|
||||
if not is_active or not verify_password(pw_hash, payload.password):
|
||||
new_failed = failed_logins + 1
|
||||
# Bewusst OHNE dynamisch zusammengesetztes SQL (kein f-String mit
|
||||
# Query-Fragmenten, auch wenn hier nie Nutzereingaben einfliessen) --
|
||||
# zwei feste, vollstaendig parametrisierte Statements statt eines
|
||||
# "SQL-Query-Building"-Musters, das Scanner (z.B. bandit B608) und
|
||||
# Reviewer sonst jedes Mal erneut pruefen muessten.
|
||||
if new_failed >= 5:
|
||||
delay_s = 30 * (2 ** min(new_failed - 5, 6)) # progressive Verzoegerung, gedeckelt
|
||||
locked_until_ts = (datetime.now(timezone.utc) + timedelta(seconds=delay_s)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?",
|
||||
(new_failed, locked_until_ts, user_id),
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"UPDATE users SET failed_logins = ? WHERE id = ?", (new_failed, user_id)
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="login_failed", user_id=user_id, client_ip=ip,
|
||||
details={"reason": "bad_password", "failed_logins": new_failed},
|
||||
)
|
||||
await conn.commit()
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?", (user_id,)
|
||||
)
|
||||
if needs_rehash(pw_hash):
|
||||
await conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(hash_password(payload.password), user_id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="login_password_ok", user_id=user_id, client_ip=ip, details={}
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
pending_token = create_pending_token(user_id)
|
||||
return {"pending_token": pending_token, "totp_enrolled": bool(totp_enrolled)}
|
||||
|
||||
|
||||
@router.post("/totp/enroll/start")
|
||||
async def totp_enroll_start(body: dict, request: Request):
|
||||
"""Erster Schritt der TOTP-Pflicht-Einrichtung (nur wenn noch nicht enrolled)."""
|
||||
pending_token = body.get("pending_token", "")
|
||||
user_id = decode_pending_token(pending_token)
|
||||
if user_id is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
|
||||
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT username, totp_enrolled FROM users WHERE id = ?", (user_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltig")
|
||||
username, totp_enrolled = row
|
||||
if totp_enrolled:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP ist bereits eingerichtet")
|
||||
|
||||
secret = generate_totp_secret()
|
||||
await conn.execute(
|
||||
"UPDATE users SET totp_secret_enc = ? WHERE id = ?",
|
||||
(encrypt_totp_secret(secret), user_id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="totp_enroll_started", user_id=user_id,
|
||||
client_ip=_client_ip(request), details={},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
uri = provisioning_uri(secret, username)
|
||||
qr_img = qrcode.make(uri)
|
||||
buf = io.BytesIO()
|
||||
qr_img.save(buf, format="PNG")
|
||||
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return {
|
||||
"provisioning_uri": uri,
|
||||
"qr_png_base64": qr_b64,
|
||||
"recovery_codes_hint": "Recovery-Codes werden erst nach erfolgreicher Bestaetigung angezeigt.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/totp/enroll/confirm")
|
||||
async def totp_enroll_confirm(body: dict, request: Request, response: Response):
|
||||
pending_token = body.get("pending_token", "")
|
||||
code = str(body.get("code", ""))
|
||||
user_id = decode_pending_token(pending_token)
|
||||
if user_id is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
|
||||
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT totp_secret_enc, session_version FROM users WHERE id = ?", (user_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or row[0] is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP-Einrichtung wurde nicht gestartet")
|
||||
|
||||
secret = decrypt_totp_secret(row[0])
|
||||
if not verify_totp_code(secret, code):
|
||||
await write_audit_event(
|
||||
conn, event_type="totp_enroll_failed", user_id=user_id,
|
||||
client_ip=_client_ip(request), details={},
|
||||
)
|
||||
await conn.commit()
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Code ungueltig")
|
||||
|
||||
recovery_codes = generate_recovery_codes()
|
||||
await conn.execute("UPDATE users SET totp_enrolled = 1 WHERE id = ?", (user_id,))
|
||||
for rc in recovery_codes:
|
||||
await conn.execute(
|
||||
"INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)",
|
||||
(user_id, hash_recovery_code(rc)),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="totp_enroll_confirmed", user_id=user_id,
|
||||
client_ip=_client_ip(request), details={},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
token = create_session_token(user_id, row[1])
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
|
||||
)
|
||||
return {"recovery_codes": recovery_codes}
|
||||
|
||||
|
||||
@router.post("/login/totp")
|
||||
async def login_totp(payload: TotpLoginRequest, request: Request, response: Response):
|
||||
user_id = decode_pending_token(payload.pending_token)
|
||||
if user_id is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token")
|
||||
|
||||
conn = get_db()
|
||||
cursor = await conn.execute(
|
||||
"SELECT totp_secret_enc, totp_enrolled, session_version FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or not row[1]:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP nicht eingerichtet")
|
||||
|
||||
secret_enc, _enrolled, session_version = row
|
||||
ip = _client_ip(request)
|
||||
ok = verify_totp_code(decrypt_totp_secret(secret_enc), payload.code)
|
||||
|
||||
if not ok:
|
||||
# Recovery-Code als Fallback pruefen.
|
||||
code_hash = hash_recovery_code(payload.code)
|
||||
rc_cursor = await conn.execute(
|
||||
"SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used_at IS NULL",
|
||||
(user_id, code_hash),
|
||||
)
|
||||
rc_row = await rc_cursor.fetchone()
|
||||
if rc_row is not None:
|
||||
await conn.execute(
|
||||
"UPDATE recovery_codes SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(rc_row[0],),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="login_recovery_code_used", user_id=user_id, client_ip=ip, details={}
|
||||
)
|
||||
ok = True
|
||||
|
||||
if not ok:
|
||||
await write_audit_event(
|
||||
conn, event_type="login_totp_failed", user_id=user_id, client_ip=ip, details={}
|
||||
)
|
||||
await conn.commit()
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "TOTP-Code ungueltig")
|
||||
|
||||
await write_audit_event(
|
||||
conn, event_type="login_success", user_id=user_id, client_ip=ip, details={}
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
token = create_session_token(user_id, session_version)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, response: Response, user: CurrentUser = Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
await write_audit_event(
|
||||
conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={}
|
||||
)
|
||||
await conn.commit()
|
||||
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/logout-everywhere")
|
||||
async def logout_everywhere(
|
||||
request: Request, response: Response, user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""Invalidiert alle ausgestellten Session-Cookies dieses Users sofort."""
|
||||
conn = get_db()
|
||||
await conn.execute(
|
||||
"UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user.id,)
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={}
|
||||
)
|
||||
await conn.commit()
|
||||
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
conn = get_db()
|
||||
cursor = await conn.execute("SELECT password_hash, session_version FROM users WHERE id = ?", (user.id,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None or not verify_password(row[0], payload.current_password):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Aktuelles Passwort falsch")
|
||||
|
||||
new_hash = hash_password(payload.new_password)
|
||||
new_version = row[1] + 1 # invalidiert alle anderen laufenden Sessions dieses Users
|
||||
await conn.execute(
|
||||
"UPDATE users SET password_hash = ?, session_version = ?, must_change_password = 0, "
|
||||
"password_changed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(new_hash, new_version, user.id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="password_changed", user_id=user.id, client_ip=_client_ip(request), details={}
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
token = create_session_token(user.id, new_version)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: CurrentUser = Depends(get_current_user)):
|
||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
||||
0
app/catalog/__init__.py
Normal file
0
app/catalog/__init__.py
Normal file
67
app/catalog/routes.py
Normal file
67
app/catalog/routes.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine
|
||||
Rolle in der jeweiligen Hostgruppe vergeben hat (Konzept 4.6)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.deps import CurrentUser, get_current_user
|
||||
from app.db import get_db
|
||||
|
||||
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
||||
|
||||
|
||||
@router.get("/hosts")
|
||||
async def my_hosts(user: CurrentUser = Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
if user.is_admin:
|
||||
cursor = await conn.execute(
|
||||
"SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, "
|
||||
"g.name, h.clipboard_enabled, h.file_transfer_enabled "
|
||||
"FROM hosts h JOIN host_groups g ON g.id = h.host_group_id "
|
||||
"WHERE h.is_active = 1 ORDER BY g.name, h.hostname"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
hosts = [dict(zip(
|
||||
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
|
||||
"clipboard_enabled", "file_transfer_enabled"), r
|
||||
)) for r in rows]
|
||||
for h in hosts:
|
||||
h["can_connect"] = True
|
||||
h["can_file_transfer"] = True
|
||||
return hosts
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id,
|
||||
g.name, h.clipboard_enabled, h.file_transfer_enabled
|
||||
FROM hosts h
|
||||
JOIN host_groups g ON g.id = h.host_group_id
|
||||
JOIN user_hostgroup_roles uhr ON uhr.host_group_id = h.host_group_id
|
||||
JOIN roles r ON r.id = uhr.role_id
|
||||
WHERE h.is_active = 1 AND uhr.user_id = ?
|
||||
AND r.name IN ('ssh_connect', 'rdp_connect')
|
||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ORDER BY g.name, h.hostname
|
||||
""",
|
||||
(user.id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
hosts = [dict(zip(
|
||||
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
|
||||
"clipboard_enabled", "file_transfer_enabled"), r
|
||||
)) for r in rows]
|
||||
|
||||
ft_cursor = await conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT uhr.host_group_id FROM user_hostgroup_roles uhr
|
||||
JOIN roles r ON r.id = uhr.role_id
|
||||
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
|
||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
""",
|
||||
(user.id,),
|
||||
)
|
||||
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
|
||||
for h in hosts:
|
||||
h["can_connect"] = True
|
||||
h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"])
|
||||
return hosts
|
||||
75
app/config.py
Normal file
75
app/config.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""
|
||||
Zentrale Konfiguration der Jumphost-Anwendung.
|
||||
|
||||
Secrets (KEK, Session-Signaturschluessel) werden bevorzugt ueber systemd
|
||||
Credentials geladen (siehe systemd LoadCredentialEncrypted= im Unit-File,
|
||||
$CREDENTIALS_DIRECTORY zur Laufzeit). Fuer lokale Entwicklung/Tests wird auf
|
||||
Umgebungsvariablen bzw. eine lokale .env-Datei zurueckgefallen -- das ist
|
||||
NICHT fuer den Produktivbetrieb gedacht.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _read_credential(name: str, env_fallback: str | None = None, *, required: bool = True) -> bytes | None:
|
||||
"""Liest ein Secret aus $CREDENTIALS_DIRECTORY (systemd-creds) oder Fallback-Env."""
|
||||
cred_dir = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||
if cred_dir:
|
||||
cred_path = Path(cred_dir) / name
|
||||
if cred_path.exists():
|
||||
return cred_path.read_bytes().strip()
|
||||
if env_fallback and env_fallback in os.environ:
|
||||
return os.environ[env_fallback].encode()
|
||||
if required:
|
||||
raise RuntimeError(
|
||||
f"Secret '{name}' weder ueber systemd-creds noch ueber Env-Variable "
|
||||
f"'{env_fallback}' verfuegbar. In Produktion MUSS dies ueber "
|
||||
f"systemd LoadCredentialEncrypted= bereitgestellt werden."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
app_env: str = os.environ.get("JUMPHOST_ENV", "development")
|
||||
data_dir: Path = Path(os.environ.get("JUMPHOST_DATA_DIR", "/var/lib/jumphost"))
|
||||
db_path: Path = field(init=False)
|
||||
recordings_dir: Path = field(init=False)
|
||||
|
||||
# Key-Encryption-Key fuer AES-256-GCM (verschluesselt SSH-Keys/TOTP-Secrets in der DB)
|
||||
kek: bytes = field(init=False)
|
||||
# separater Schluessel fuer Session-Cookie-Signatur (Schluesseltrennung, siehe Konzept 6.2)
|
||||
session_secret: bytes = field(init=False)
|
||||
|
||||
listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock")
|
||||
guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1")
|
||||
guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822"))
|
||||
|
||||
session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900"))
|
||||
session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800"))
|
||||
max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5"))
|
||||
lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30"))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.db_path = self.data_dir / "jumphost.db"
|
||||
self.recordings_dir = self.data_dir / "recordings"
|
||||
|
||||
if self.app_env == "development":
|
||||
# Nur fuer lokale Entwicklung: deterministisch aus Env oder Zufallswert je Prozessstart.
|
||||
kek_hex = os.environ.get("JUMPHOST_DEV_KEK")
|
||||
self.kek = bytes.fromhex(kek_hex) if kek_hex else secrets.token_bytes(32)
|
||||
sess_hex = os.environ.get("JUMPHOST_DEV_SESSION_SECRET")
|
||||
self.session_secret = bytes.fromhex(sess_hex) if sess_hex else secrets.token_bytes(32)
|
||||
else:
|
||||
self.kek = _read_credential("jumphost_kek", "JUMPHOST_KEK")
|
||||
self.session_secret = _read_credential("jumphost_session_secret", "JUMPHOST_SESSION_SECRET")
|
||||
|
||||
if len(self.kek) != 32:
|
||||
raise RuntimeError("KEK muss genau 32 Bytes (256 Bit) lang sein.")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
69
app/db.py
Normal file
69
app/db.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""
|
||||
Datenbankzugriff (SQLite, WAL-Modus) + einfacher, idempotenter Migration-Runner.
|
||||
|
||||
Bewusst ohne schweres ORM gehalten: alle Queries sind strikt parametrisiert
|
||||
(nie String-Concat mit Nutzereingaben), siehe Security-Konzept 6.6.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("jumphost.db")
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "db" / "migrations"
|
||||
|
||||
_connection: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Legt das Datenverzeichnis an, oeffnet die DB und wendet Migrationen an."""
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
settings.recordings_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
|
||||
global _connection
|
||||
_connection = await aiosqlite.connect(settings.db_path, isolation_level=None)
|
||||
await _connection.execute("PRAGMA journal_mode = WAL;")
|
||||
await _connection.execute("PRAGMA foreign_keys = ON;")
|
||||
await _connection.execute("PRAGMA busy_timeout = 5000;")
|
||||
await _apply_migrations(_connection)
|
||||
|
||||
try:
|
||||
settings.db_path.chmod(0o600)
|
||||
except OSError:
|
||||
logger.warning("Konnte Dateirechte der DB nicht setzen (%s)", settings.db_path)
|
||||
|
||||
|
||||
async def _apply_migrations(conn: aiosqlite.Connection) -> None:
|
||||
await conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations "
|
||||
"(filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')))"
|
||||
)
|
||||
applied = {row[0] async for row in await conn.execute("SELECT filename FROM schema_migrations")}
|
||||
|
||||
for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
||||
if migration_file.name in applied:
|
||||
continue
|
||||
logger.info("Wende Migration an: %s", migration_file.name)
|
||||
sql = migration_file.read_text(encoding="utf-8")
|
||||
await conn.executescript(sql)
|
||||
await conn.execute(
|
||||
"INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,)
|
||||
)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
global _connection
|
||||
if _connection is not None:
|
||||
await _connection.close()
|
||||
_connection = None
|
||||
|
||||
|
||||
def get_db() -> aiosqlite.Connection:
|
||||
if _connection is None:
|
||||
raise RuntimeError("Datenbank ist nicht initialisiert (init_db() aufrufen).")
|
||||
return _connection
|
||||
144
app/db/migrations/0001_initial.sql
Normal file
144
app/db/migrations/0001_initial.sql
Normal file
@ -0,0 +1,144 @@
|
||||
-- Initiales Schema. Siehe Konzeptdokument Kap. 5.
|
||||
-- Wird vom Migration-Runner (app/db.py) einmalig und idempotent angewendet.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
totp_secret_enc BLOB,
|
||||
totp_enrolled INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
failed_logins INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
password_changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
must_change_password INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user ON recovery_codes(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO roles (id, name) VALUES
|
||||
(1, 'ssh_connect'),
|
||||
(2, 'rdp_connect'),
|
||||
(3, 'file_transfer'),
|
||||
(4, 'clipboard'),
|
||||
(5, 'session_recording_view'),
|
||||
(6, 'admin_hostgroup');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_group_id INTEGER NOT NULL REFERENCES host_groups(id),
|
||||
hostname TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL CHECK (protocol IN ('ssh','rdp')),
|
||||
port INTEGER NOT NULL,
|
||||
os_type TEXT NOT NULL CHECK (os_type IN ('linux','windows')),
|
||||
ssh_host_key_fingerprint TEXT,
|
||||
ssh_username TEXT,
|
||||
rdp_username TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_require_nla INTEGER NOT NULL DEFAULT 1,
|
||||
clipboard_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
file_transfer_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hosts_group ON hosts(host_group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
owner_user_id INTEGER REFERENCES users(id),
|
||||
private_key_enc BLOB NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
rotated_at TEXT,
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_ssh_key_map (
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
ssh_key_id INTEGER NOT NULL REFERENCES ssh_keys(id),
|
||||
PRIMARY KEY (host_id, ssh_key_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_hostgroup_roles (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_group_id INTEGER NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id),
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
expires_at TEXT,
|
||||
PRIMARY KEY (user_id, host_group_id, role_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uhr_user ON user_hostgroup_roles(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id),
|
||||
protocol TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
ended_at TEXT,
|
||||
client_ip TEXT NOT NULL,
|
||||
recording_path TEXT,
|
||||
end_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_host ON sessions(host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_transfers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id),
|
||||
direction TEXT NOT NULL CHECK (direction IN ('upload','download')),
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
av_scan_result TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ft_session ON file_transfers(session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
client_ip TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
entry_hash TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||
|
||||
-- Audit-Log ist auf DB-Ebene append-only: UPDATE/DELETE werden hart verweigert.
|
||||
CREATE TRIGGER IF NOT EXISTS no_audit_update BEFORE UPDATE ON audit_log
|
||||
BEGIN SELECT RAISE(ABORT, 'audit_log ist append-only'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS no_audit_delete BEFORE DELETE ON audit_log
|
||||
BEGIN SELECT RAISE(ABORT, 'audit_log ist append-only'); END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
5
app/db/migrations/0002_session_version.sql
Normal file
5
app/db/migrations/0002_session_version.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- Ermoeglicht serverseitiges Invalidieren aller Sessions eines Users
|
||||
-- (Passwortaenderung, "ueberall abmelden", Admin-Sperre) ohne eigene
|
||||
-- Session-Tabelle: das Session-Cookie enthaelt session_version und wird nur
|
||||
-- akzeptiert, wenn der Wert mit dem aktuellen DB-Wert uebereinstimmt.
|
||||
ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 1;
|
||||
7
app/db/migrations/0003_rdp_credentials.sql
Normal file
7
app/db/migrations/0003_rdp_credentials.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- RDP-Zugangsdaten getrennt von SSH-Keys, ebenfalls AES-256-GCM-verschluesselt
|
||||
-- (KEK, associated_data="rdp_password"). Ein Datensatz pro Host.
|
||||
CREATE TABLE IF NOT EXISTS rdp_credentials (
|
||||
host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
password_enc BLOB NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
99
app/main.py
Normal file
99
app/main.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""
|
||||
FastAPI-Einstiegspunkt. Bindet Security-Header, Router und Static/Template-
|
||||
Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.admin.routes import router as admin_router
|
||||
from app.auth.deps import get_current_user
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.catalog.routes import router as catalog_router
|
||||
from app.db import close_db, init_db
|
||||
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
|
||||
from app.ssh_proxy.sftp import router as sftp_router
|
||||
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
|
||||
app = FastAPI(title="Jumphost Gateway", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(ssh_ws_router)
|
||||
app.include_router(sftp_router)
|
||||
app.include_router(rdp_ws_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
"""Setzt die in Konzept 6.6 geforderten Security-Header auf jede Antwort.
|
||||
Laeuft unabhaengig davon, ob nginx vorgeschaltet ist (Defense-in-Depth --
|
||||
nginx setzt in der Praxis dieselben Header zusaetzlich, siehe ansible/roles/nginx_proxy)."""
|
||||
response = await call_next(request)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = "clipboard-read=(self), clipboard-write=(self), fullscreen=(self)"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self' ws: wss:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
# Hinweis: seit Starlette >=1.x ist "TemplateResponse(request, name, ...)"
|
||||
# die aktuelle Aufrufkonvention (die alte "TemplateResponse(name, {...})"
|
||||
# wurde entfernt) -- beim Dependency-Upgrade im Rahmen der pip-audit-
|
||||
# Bereinigung angepasst und per Pentest-Testsuite regressionsgetestet.
|
||||
return templates.TemplateResponse(request, "login.html", {})
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
# Auth-Pruefung erfolgt clientseitig ueber GET /auth/me (401 -> Redirect zu /);
|
||||
# serverseitig zusaetzlich abgesichert, sobald Templates dynamische Inhalte rendern.
|
||||
return templates.TemplateResponse(request, "dashboard.html", {})
|
||||
|
||||
|
||||
@app.get("/terminal/{host_id}", response_class=HTMLResponse)
|
||||
async def terminal_page(request: Request, host_id: int):
|
||||
return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id})
|
||||
|
||||
|
||||
@app.get("/rdp/{host_id}", response_class=HTMLResponse)
|
||||
async def rdp_page(request: Request, host_id: int):
|
||||
return templates.TemplateResponse(request, "rdp.html", {"host_id": host_id})
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
109
app/models/schemas.py
Normal file
109
app/models/schemas.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""
|
||||
Pydantic-Schemas fuer alle API-Eingaben/-Ausgaben.
|
||||
|
||||
Strikte Validierung ist Teil des Hardening-Konzepts (6.6): Laenge, Typ und
|
||||
erlaubte Zeichen werden hier durchgesetzt, bevor irgendein Wert die
|
||||
Business-Logik oder die Datenbank erreicht.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
|
||||
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def check_username(cls, v: str) -> str:
|
||||
if not USERNAME_RE.match(v):
|
||||
raise ValueError("Ungueltiger Benutzername")
|
||||
return v
|
||||
|
||||
|
||||
class TotpLoginRequest(BaseModel):
|
||||
pending_token: str
|
||||
code: str = Field(min_length=6, max_length=64)
|
||||
|
||||
|
||||
class TotpConfirmRequest(BaseModel):
|
||||
code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$")
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=256)
|
||||
new_password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
initial_password: str = Field(min_length=12, max_length=256)
|
||||
is_admin: bool = False
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def check_username(cls, v: str) -> str:
|
||||
if not USERNAME_RE.match(v):
|
||||
raise ValueError("Ungueltiger Benutzername")
|
||||
return v
|
||||
|
||||
|
||||
class HostGroupCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class HostCreateRequest(BaseModel):
|
||||
host_group_id: int
|
||||
hostname: str = Field(min_length=1, max_length=128)
|
||||
address: str = Field(min_length=1, max_length=255)
|
||||
protocol: Literal["ssh", "rdp"]
|
||||
port: int = Field(gt=0, le=65535)
|
||||
os_type: Literal["linux", "windows"]
|
||||
ssh_host_key_fingerprint: str | None = Field(default=None, max_length=512)
|
||||
ssh_username: str | None = Field(default=None, max_length=128)
|
||||
rdp_username: str | None = Field(default=None, max_length=128)
|
||||
rdp_domain: str | None = Field(default=None, max_length=128)
|
||||
rdp_require_nla: bool = True
|
||||
clipboard_enabled: bool = True
|
||||
file_transfer_enabled: bool = True
|
||||
|
||||
@field_validator("hostname")
|
||||
@classmethod
|
||||
def check_hostname(cls, v: str) -> str:
|
||||
if not HOSTNAME_LABEL_RE.match(v):
|
||||
raise ValueError("Ungueltiger Hostname")
|
||||
return v
|
||||
|
||||
|
||||
class RoleGrantRequest(BaseModel):
|
||||
user_id: int
|
||||
host_group_id: int
|
||||
role_name: Literal[
|
||||
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
|
||||
"session_recording_view", "admin_hostgroup",
|
||||
]
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
class SshKeyCreateRequest(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=128)
|
||||
owner_user_id: int | None = None
|
||||
private_key_pem: str = Field(min_length=1, max_length=32_768)
|
||||
public_key: str = Field(min_length=1, max_length=8192)
|
||||
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
host_id: int
|
||||
|
||||
|
||||
class RdpCredentialsRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=512)
|
||||
33
app/rbac.py
Normal file
33
app/rbac.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def user_has_role(
|
||||
conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str
|
||||
) -> bool:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM user_hostgroup_roles uhr
|
||||
JOIN roles r ON r.id = uhr.role_id
|
||||
WHERE uhr.user_id = ?
|
||||
AND uhr.host_group_id = ?
|
||||
AND r.name = ?
|
||||
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, host_group_id, role_name),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
async def user_has_role_for_host(
|
||||
conn: aiosqlite.Connection, *, user_id: int, host_id: int, role_name: str
|
||||
) -> bool:
|
||||
cursor = await conn.execute("SELECT host_group_id FROM hosts WHERE id = ?", (host_id,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name)
|
||||
0
app/rdp_proxy/__init__.py
Normal file
0
app/rdp_proxy/__init__.py
Normal file
166
app/rdp_proxy/guacd_client.py
Normal file
166
app/rdp_proxy/guacd_client.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""
|
||||
Guacamole-Protokoll-Tunnel zu guacd (siehe Konzept 3, 4.3).
|
||||
|
||||
guacd selbst spricht RDP zum Windows-Ziel; der Jumphost tauscht mit guacd nur
|
||||
das textbasierte Guacamole-Protokoll aus (laengenpraefigierte Elemente,
|
||||
Instruktionen durch ';' abgeschlossen). Zugangsdaten werden ausschliesslich
|
||||
serverseitig in die "connect"-Instruktion eingefuegt -- der Browser sieht sie
|
||||
nie (analog zum SSH-Keyhandling in app/ssh_proxy/proxy.py).
|
||||
|
||||
Hinweis: Die exakten von guacd erwarteten RDP-Parameter (Namen/Reihenfolge)
|
||||
haengen von der eingesetzten guacd/FreeRDP-Version ab. Diese Implementierung
|
||||
fragt sie dynamisch per "args"-Instruktion ab (kein Hardcoding einer festen
|
||||
Parameterliste) und ist daher robust gegen kleinere Versionsunterschiede --
|
||||
sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
|
||||
|
||||
|
||||
class GuacamoleProtocolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def encode_instruction(*elements: str) -> str:
|
||||
parts = []
|
||||
for element in elements:
|
||||
encoded = element.encode("utf-8")
|
||||
parts.append(f"{len(encoded)}.{element}")
|
||||
return ",".join(parts) + ";"
|
||||
|
||||
|
||||
async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
|
||||
buf = bytearray()
|
||||
while True:
|
||||
b = await reader.readexactly(1)
|
||||
if b == delimiter:
|
||||
return bytes(buf)
|
||||
buf += b
|
||||
|
||||
|
||||
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
|
||||
elements: list[str] = []
|
||||
while True:
|
||||
length_bytes = await _read_until(reader, b".")
|
||||
try:
|
||||
length = int(length_bytes)
|
||||
except ValueError as exc:
|
||||
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
|
||||
content = (await reader.readexactly(length)).decode("utf-8")
|
||||
elements.append(content)
|
||||
sep = await reader.readexactly(1)
|
||||
if sep == b";":
|
||||
return elements
|
||||
if sep != b",":
|
||||
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
|
||||
|
||||
|
||||
def parse_instruction_text(text: str) -> list[str]:
|
||||
"""Parst genau EINE Instruktion aus einem bereits vollstaendig vorliegenden
|
||||
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser)."""
|
||||
elements: list[str] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
dot = text.index(".", i)
|
||||
length = int(text[i:dot])
|
||||
start = dot + 1
|
||||
end = start + length
|
||||
elements.append(text[start:end])
|
||||
sep = text[end] if end < n else ""
|
||||
i = end + 1
|
||||
if sep == ";":
|
||||
break
|
||||
if sep != ",":
|
||||
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}")
|
||||
return elements
|
||||
|
||||
|
||||
class GuacdTunnel:
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None:
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.connection_id = connection_id
|
||||
|
||||
async def close(self) -> None:
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
# Aufraeumpfad: ein bereits getrenntes/fehlerhaftes Socket beim
|
||||
# Schliessen darf den Session-Teardown (Audit-Log-Eintrag,
|
||||
# DB-Update in ws_tunnel.py) nicht verhindern. Bewusst breit
|
||||
# gefangen, aber protokolliert statt stillschweigend verschluckt.
|
||||
logger.debug("Fehler beim Schliessen des guacd-Tunnels (ignoriert)", exc_info=True)
|
||||
|
||||
|
||||
async def open_tunnel(
|
||||
*,
|
||||
guacd_host: str,
|
||||
guacd_port: int,
|
||||
protocol: str,
|
||||
params: dict[str, str],
|
||||
screen_width: int = 1024,
|
||||
screen_height: int = 768,
|
||||
dpi: int = 96,
|
||||
) -> GuacdTunnel:
|
||||
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
|
||||
|
||||
writer.write(encode_instruction("select", protocol).encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
args_instr = await read_instruction(reader)
|
||||
if args_instr[0] != "args":
|
||||
raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}")
|
||||
arg_names = args_instr[1:]
|
||||
|
||||
handshake = (
|
||||
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
|
||||
+ encode_instruction("audio")
|
||||
+ encode_instruction("video")
|
||||
+ encode_instruction("image", "image/png", "image/jpeg")
|
||||
)
|
||||
writer.write(handshake.encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
values = [params.get(name, "") for name in arg_names]
|
||||
writer.write(encode_instruction("connect", *values).encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
ready_instr = await read_instruction(reader)
|
||||
if ready_instr[0] != "ready":
|
||||
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")
|
||||
connection_id = ready_instr[1] if len(ready_instr) > 1 else ""
|
||||
|
||||
return GuacdTunnel(reader, writer, connection_id)
|
||||
|
||||
|
||||
def build_rdp_params(host: dict, password: str) -> dict[str, str]:
|
||||
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
|
||||
|
||||
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
|
||||
rdp_require_nla gesetzt ist (Standard), Zertifikatspruefung ist standardmaessig
|
||||
AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den
|
||||
Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller
|
||||
Bypass.
|
||||
"""
|
||||
return {
|
||||
"hostname": host["address"],
|
||||
"port": str(host["port"]),
|
||||
"username": host.get("rdp_username") or "",
|
||||
"password": password,
|
||||
"domain": host.get("rdp_domain") or "",
|
||||
"security": "nla" if host.get("rdp_require_nla", True) else "any",
|
||||
"ignore-cert": "false",
|
||||
"disable-audio": "true",
|
||||
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
|
||||
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
|
||||
"create-drive-path": "true",
|
||||
"disable-copy": "false" if host.get("clipboard_enabled") else "true",
|
||||
"disable-paste": "false" if host.get("clipboard_enabled") else "true",
|
||||
"resize-method": "display-update",
|
||||
}
|
||||
170
app/rdp_proxy/ws_tunnel.py
Normal file
170
app/rdp_proxy/ws_tunnel.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""
|
||||
WebSocket-Bruecke Browser (guacamole-common-js) <-> guacd (Konzept 4.3).
|
||||
|
||||
Setzt pro Hostgruppe/Host konfigurierbare Policies durch, die guacd selbst
|
||||
zwar schon per connect-Parameter bekommt (disable-copy/-paste, enable-drive),
|
||||
zusaetzlich werden Clipboard-Instruktionen aber auch hier auf Protokollebene
|
||||
gefiltert -- Defense-in-Depth, falls sich guacd-Parameter je nach Version
|
||||
unterscheiden.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.auth.deps import get_current_user_ws
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.crypto import decrypt_secret
|
||||
from app.rdp_proxy.guacd_client import (
|
||||
GuacamoleProtocolError,
|
||||
build_rdp_params,
|
||||
encode_instruction,
|
||||
open_tunnel,
|
||||
parse_instruction_text,
|
||||
read_instruction,
|
||||
)
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
|
||||
|
||||
logger = logging.getLogger("jumphost.rdp_proxy.ws")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
|
||||
while True:
|
||||
instr = await read_instruction(tunnel.reader)
|
||||
text = encode_instruction(*instr)
|
||||
recorder.record("output", text)
|
||||
await websocket.send_text(text)
|
||||
|
||||
|
||||
async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool) -> None:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
try:
|
||||
instr = parse_instruction_text(message)
|
||||
except GuacamoleProtocolError:
|
||||
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
|
||||
|
||||
if not clipboard_enabled and instr and instr[0] == "clipboard":
|
||||
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
||||
|
||||
recorder.record("input", message)
|
||||
tunnel.writer.write(message.encode("utf-8"))
|
||||
await tunnel.writer.drain()
|
||||
|
||||
|
||||
@router.websocket("/ws/rdp/{host_id}")
|
||||
async def rdp_tunnel(
|
||||
websocket: WebSocket,
|
||||
host_id: int,
|
||||
width: int = Query(default=1280, ge=320, le=7680),
|
||||
height: int = Query(default=800, ge=240, le=4320),
|
||||
dpi: int = Query(default=96, ge=48, le=384),
|
||||
):
|
||||
user = await get_current_user_ws(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
conn = get_db()
|
||||
if not user.is_admin and not await user_has_role_for_host(
|
||||
conn, user_id=user.id, host_id=host_id, role_name="rdp_connect"
|
||||
):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
client_ip = websocket.client.host if websocket.client else "unknown"
|
||||
|
||||
try:
|
||||
host = await load_host(conn, host_id)
|
||||
except HostNotConfiguredError as exc:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if host["protocol"] != "rdp":
|
||||
await websocket.close(code=4400)
|
||||
return
|
||||
|
||||
cred_cursor = await conn.execute(
|
||||
"SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
)
|
||||
cred_row = await cred_cursor.fetchone()
|
||||
if cred_row is None:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'rdp', ?)",
|
||||
(user.id, host_id, client_ip),
|
||||
)
|
||||
session_id = cursor.lastrowid
|
||||
recorder = SessionRecorder(session_id)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip,
|
||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
end_reason = "logout"
|
||||
tunnel = None
|
||||
tasks: list[asyncio.Task] = []
|
||||
try:
|
||||
params = build_rdp_params(host, password.decode())
|
||||
tunnel = await open_tunnel(
|
||||
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
||||
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
||||
)
|
||||
clipboard_enabled = bool(host.get("clipboard_enabled", True))
|
||||
tasks = [
|
||||
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
||||
asyncio.create_task(
|
||||
_ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled)
|
||||
),
|
||||
]
|
||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
exc = task.exception()
|
||||
if exc:
|
||||
raise exc
|
||||
except WebSocketDisconnect:
|
||||
end_reason = "logout"
|
||||
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
||||
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
finally:
|
||||
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tunnel:
|
||||
await tunnel.close()
|
||||
recorder.close()
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"WHERE id = ?",
|
||||
(end_reason, session_id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip,
|
||||
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
|
||||
)
|
||||
await conn.commit()
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
# Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind
|
||||
# zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client
|
||||
# getrenntes WebSocket darf das nicht rueckwirkend fehlschlagen lassen.
|
||||
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|
||||
0
app/recordings/__init__.py
Normal file
0
app/recordings/__init__.py
Normal file
63
app/recordings/recorder.py
Normal file
63
app/recordings/recorder.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""
|
||||
Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5).
|
||||
|
||||
Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir.
|
||||
Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das
|
||||
Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der
|
||||
Aufzeichnung erkennbar ist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
class SessionRecorder:
|
||||
def __init__(self, session_id: int) -> None:
|
||||
self.session_id = session_id
|
||||
self.path = settings.recordings_dir / f"session_{session_id}.jsonl"
|
||||
self._prev_hash = GENESIS_HASH
|
||||
self._start_ts = time.time()
|
||||
self._fh = open(self.path, "a", encoding="utf-8")
|
||||
try:
|
||||
self.path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def record(self, direction: str, data: str) -> None:
|
||||
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe)."""
|
||||
offset = round(time.time() - self._start_ts, 4)
|
||||
entry = {"t": offset, "dir": direction, "data": data}
|
||||
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
|
||||
entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest()
|
||||
line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash})
|
||||
self._fh.write(line + "\n")
|
||||
self._fh.flush()
|
||||
self._prev_hash = entry_hash
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._fh.closed:
|
||||
self._fh.close()
|
||||
|
||||
|
||||
def verify_recording(path: Path) -> bool:
|
||||
prev_hash = GENESIS_HASH
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
row = json.loads(line)
|
||||
if row["prev_hash"] != prev_hash:
|
||||
return False
|
||||
entry_json = json.dumps(row["entry"], ensure_ascii=False, sort_keys=True)
|
||||
expected = hashlib.sha256((prev_hash + "|" + entry_json).encode()).hexdigest()
|
||||
if expected != row["hash"]:
|
||||
return False
|
||||
prev_hash = row["hash"]
|
||||
return True
|
||||
0
app/security/__init__.py
Normal file
0
app/security/__init__.py
Normal file
70
app/security/audit.py
Normal file
70
app/security/audit.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""
|
||||
Manipulationssicheres, hash-verkettetes Audit-Log (siehe Konzept 4.7 / 6.1).
|
||||
|
||||
Jeder Eintrag verkettet sich kryptographisch mit seinem Vorgaenger:
|
||||
entry_hash = sha256(prev_hash || ts || event_type || details_json)
|
||||
|
||||
Nachtraegliches Aendern oder Herausloeschen eines Eintrags bricht die Kette
|
||||
ab dieser Stelle - erkennbar durch verify_chain(). Zusaetzlich verhindern
|
||||
DB-Trigger (0001_initial.sql) UPDATE/DELETE auf Anwendungsebene.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) -> str:
|
||||
payload = f"{prev_hash}|{ts}|{event_type}|{details_json}".encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
async def write_audit_event(
|
||||
conn: aiosqlite.Connection,
|
||||
*,
|
||||
event_type: str,
|
||||
user_id: int | None,
|
||||
client_ip: str | None,
|
||||
details: dict[str, Any],
|
||||
) -> None:
|
||||
"""Schreibt einen Audit-Eintrag; haengt ihn an die bestehende Hash-Chain an.
|
||||
|
||||
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
|
||||
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
|
||||
"""
|
||||
cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1")
|
||||
row = await cursor.fetchone()
|
||||
prev_hash = row[0] if row else GENESIS_HASH
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
||||
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
|
||||
)
|
||||
|
||||
|
||||
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:
|
||||
"""Prueft die gesamte Audit-Log-Kette. Rueckgabe: (intakt?, erste kaputte id)."""
|
||||
prev_hash = GENESIS_HASH
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, ts, event_type, details_json, prev_hash, entry_hash FROM audit_log ORDER BY id ASC"
|
||||
)
|
||||
async for row in cursor:
|
||||
entry_id, ts, event_type, details_json, stored_prev, stored_entry = row
|
||||
if stored_prev != prev_hash:
|
||||
return False, entry_id
|
||||
expected = _entry_hash(prev_hash, ts, event_type, details_json)
|
||||
if expected != stored_entry:
|
||||
return False, entry_id
|
||||
prev_hash = stored_entry
|
||||
return True, None
|
||||
38
app/security/av_scan.py
Normal file
38
app/security/av_scan.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""
|
||||
AV-Scan-Hook fuer Datei-Uploads (Konzept 4.3/6.6).
|
||||
|
||||
Bewusst als duenner Wrapper um ein optionales ClamAV (clamd) gehalten: ist
|
||||
kein Scanner konfiguriert/erreichbar, wird das Ergebnis "skipped" vermerkt
|
||||
statt die Datei stillschweigend als "sauber" zu markieren -- Admins sehen im
|
||||
Audit-/Filetransfer-Log damit ehrlich, ob wirklich gescannt wurde.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess # nosec B404 -- benoetigt fuer den optionalen ClamAV-Aufruf, siehe unten
|
||||
|
||||
|
||||
def scan_bytes(data: bytes) -> str:
|
||||
"""Rueckgabe: 'clean', 'infected:<signature>' oder 'skipped:<grund>'."""
|
||||
clamdscan = shutil.which("clamdscan")
|
||||
if not clamdscan:
|
||||
return "skipped:clamdscan_not_installed"
|
||||
try:
|
||||
# Argumentliste ist vollstaendig fest (kein shell=True, keine
|
||||
# Nutzereingabe im Kommando selbst); die hochgeladenen Datei-Bytes
|
||||
# werden ausschliesslich ueber stdin (input=data) uebergeben, nie als
|
||||
# Kommandozeilen-/Pfadargument -- Command-Injection ueber Dateinamen
|
||||
# o.ae. ist damit ausgeschlossen.
|
||||
proc = subprocess.run( # nosec B603
|
||||
[clamdscan, "--stdout", "--no-summary", "-"],
|
||||
input=data, capture_output=True, timeout=30,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return "skipped:scan_error"
|
||||
|
||||
output = proc.stdout.decode(errors="replace")
|
||||
if proc.returncode == 0:
|
||||
return "clean"
|
||||
if proc.returncode == 1:
|
||||
return f"infected:{output.strip()}"
|
||||
return "skipped:scan_error"
|
||||
32
app/security/crypto.py
Normal file
32
app/security/crypto.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""
|
||||
AES-256-GCM Verschluesselung fuer Secrets at rest (SSH-Private-Keys, TOTP-Secrets).
|
||||
|
||||
Prinzip (siehe Konzept 6.4): Der Key-Encryption-Key (KEK) liegt NICHT in der
|
||||
Datenbank, sondern kommt aus app.config.settings (systemd-creds/Env). Jeder
|
||||
verschluesselte Datensatz erhaelt einen frischen, zufaelligen Nonce; Nonce +
|
||||
Ciphertext + Auth-Tag werden gemeinsam gespeichert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from app.config import settings
|
||||
|
||||
NONCE_LEN = 12 # 96 Bit, empfohlene GCM-Noncelaenge
|
||||
|
||||
|
||||
def encrypt_secret(plaintext: bytes, *, associated_data: bytes = b"") -> bytes:
|
||||
"""Verschluesselt plaintext mit dem globalen KEK. Rueckgabe: nonce || ciphertext."""
|
||||
aesgcm = AESGCM(settings.kek)
|
||||
nonce = os.urandom(NONCE_LEN)
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data or None)
|
||||
return nonce + ciphertext
|
||||
|
||||
|
||||
def decrypt_secret(blob: bytes, *, associated_data: bytes = b"") -> bytes:
|
||||
"""Entschluesselt einen mit encrypt_secret() erzeugten Blob."""
|
||||
aesgcm = AESGCM(settings.kek)
|
||||
nonce, ciphertext = blob[:NONCE_LEN], blob[NONCE_LEN:]
|
||||
return aesgcm.decrypt(nonce, ciphertext, associated_data or None)
|
||||
28
app/security/passwords.py
Normal file
28
app/security/passwords.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Argon2id-Passwort-Hashing (siehe Konzept 6.2)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, InvalidHash
|
||||
|
||||
# Parameter angelehnt an aktuelle OWASP-Empfehlung; in Produktion je nach
|
||||
# Server-Hardware kalibrieren (siehe Konzept 6.2).
|
||||
_hasher = PasswordHasher(time_cost=2, memory_cost=19 * 1024, parallelism=1)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
_hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, InvalidHash):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
except InvalidHash:
|
||||
return True
|
||||
29
app/security/pending_totp.py
Normal file
29
app/security/pending_totp.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Kurzlebige, signierte Tokens fuer den Zwischenschritt Passwort -> TOTP.
|
||||
|
||||
Es wird bewusst KEIN Session-Cookie ausgestellt, solange der zweite Faktor
|
||||
nicht bestaetigt ist (siehe Konzept 6.2: "kein Login ohne TOTP moeglich").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_PENDING_MAX_AGE_S = 300 # 5 Minuten Zeitfenster fuer den TOTP-Schritt
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.session_secret.hex(), salt="jumphost-pending-totp")
|
||||
|
||||
|
||||
def create_pending_token(user_id: int) -> str:
|
||||
return _serializer.dumps({"uid": user_id})
|
||||
|
||||
|
||||
def decode_pending_token(token: str) -> int | None:
|
||||
try:
|
||||
data = _serializer.loads(token, max_age=_PENDING_MAX_AGE_S)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
try:
|
||||
return int(data["uid"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
34
app/security/rate_limit.py
Normal file
34
app/security/rate_limit.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""
|
||||
Einfacher In-Memory Rate-Limiter fuer den Login-Endpunkt (pro Quell-IP).
|
||||
|
||||
Fuer einen Single-Process-ASGI-Deployment (siehe Konzept: kleine/mittlere
|
||||
Umgebung) ausreichend. Bei horizontaler Skalierung auf mehrere Prozesse/Hosts
|
||||
muss dies durch einen geteilten Store (z.B. Redis) ersetzt werden -- als
|
||||
Erweiterungspunkt bewusst hinter einer kleinen Klasse gekapselt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class SlidingWindowRateLimiter:
|
||||
def __init__(self, max_events: int, window_seconds: int) -> None:
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
window = self._events[key]
|
||||
while window and now - window[0] > self.window_seconds:
|
||||
window.popleft()
|
||||
if len(window) >= self.max_events:
|
||||
return False
|
||||
window.append(now)
|
||||
return True
|
||||
|
||||
|
||||
# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den
|
||||
# Account-basierten Lockout in app/auth/routes.py.
|
||||
login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60)
|
||||
70
app/security/sessions.py
Normal file
70
app/security/sessions.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""
|
||||
Signierte, serverseitig invalidierbare Session-Cookies.
|
||||
|
||||
Kein separates Session-Store noetig: Das Cookie traegt user_id,
|
||||
session_version (fuer harte Invalidierung, z.B. bei Passwortwechsel) und
|
||||
zwei Zeitstempel (Login-Zeit fuer den absoluten Timeout, Last-Seen fuer den
|
||||
gleitenden Idle-Timeout). Signatur ueber einen vom KEK getrennten Secret
|
||||
(Schluesseltrennung, Konzept 6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_serializer = URLSafeSerializer(settings.session_secret.hex(), salt="jumphost-session")
|
||||
|
||||
SESSION_COOKIE_NAME = "jh_session"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionPayload:
|
||||
user_id: int
|
||||
session_version: int
|
||||
login_ts: float
|
||||
last_seen_ts: float
|
||||
|
||||
|
||||
def create_session_token(user_id: int, session_version: int) -> str:
|
||||
now = time.time()
|
||||
payload = {"uid": user_id, "sv": session_version, "iat": now, "seen": now}
|
||||
return _serializer.dumps(payload)
|
||||
|
||||
|
||||
def refresh_session_token(payload: SessionPayload) -> str:
|
||||
data = {
|
||||
"uid": payload.user_id,
|
||||
"sv": payload.session_version,
|
||||
"iat": payload.login_ts,
|
||||
"seen": time.time(),
|
||||
}
|
||||
return _serializer.dumps(data)
|
||||
|
||||
|
||||
def decode_session_token(token: str) -> SessionPayload | None:
|
||||
try:
|
||||
data = _serializer.loads(token)
|
||||
except BadSignature:
|
||||
return None
|
||||
try:
|
||||
return SessionPayload(
|
||||
user_id=int(data["uid"]),
|
||||
session_version=int(data["sv"]),
|
||||
login_ts=float(data["iat"]),
|
||||
last_seen_ts=float(data["seen"]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_expired(payload: SessionPayload) -> bool:
|
||||
now = time.time()
|
||||
if now - payload.last_seen_ts > settings.session_idle_timeout_s:
|
||||
return True
|
||||
if now - payload.login_ts > settings.session_absolute_timeout_s:
|
||||
return True
|
||||
return False
|
||||
52
app/security/totp.py
Normal file
52
app/security/totp.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""
|
||||
TOTP-Enrollment und -Verifikation (RFC 6238) inkl. Recovery-Codes.
|
||||
|
||||
Pflicht-2FA: siehe Konzept 4.5 / 6.2. Das TOTP-Secret wird mit einem eigenen
|
||||
AAD-Kontext ("totp") verschluesselt gespeichert -- Schluesseltrennung vom
|
||||
SSH-Key-Material ist ueber den associated_data-Parameter realisiert (beide
|
||||
nutzen zwar denselben KEK, sind aber durch AAD kontextgebunden und nicht
|
||||
gegeneinander austauschbar).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
import pyotp
|
||||
|
||||
from app.security.crypto import decrypt_secret, encrypt_secret
|
||||
|
||||
_TOTP_AAD = b"totp_secret"
|
||||
|
||||
|
||||
def generate_totp_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def encrypt_totp_secret(secret: str) -> bytes:
|
||||
return encrypt_secret(secret.encode(), associated_data=_TOTP_AAD)
|
||||
|
||||
|
||||
def decrypt_totp_secret(blob: bytes) -> str:
|
||||
return decrypt_secret(blob, associated_data=_TOTP_AAD).decode()
|
||||
|
||||
|
||||
def provisioning_uri(secret: str, username: str, issuer: str = "Jumphost") -> str:
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer)
|
||||
|
||||
|
||||
def verify_totp_code(secret: str, code: str) -> bool:
|
||||
"""Verifiziert mit +-1 Zeitfenster Toleranz gegen Clock-Drift."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1)
|
||||
|
||||
|
||||
def generate_recovery_codes(count: int = 10) -> list[str]:
|
||||
"""Erzeugt Einmal-Recovery-Codes im Klartext (nur zur einmaligen Anzeige)."""
|
||||
return [secrets.token_hex(5) for _ in range(count)]
|
||||
|
||||
|
||||
def hash_recovery_code(code: str) -> str:
|
||||
# Recovery-Codes sind hochentropisch (40 Bit hex) -- ein schneller,
|
||||
# gesalzener Hash reicht hier aus; dennoch SHA-256 mit Pfeffer aus KEK-Kontext.
|
||||
return hashlib.sha256(code.encode() + b"recovery_code_pepper").hexdigest()
|
||||
0
app/ssh_proxy/__init__.py
Normal file
0
app/ssh_proxy/__init__.py
Normal file
130
app/ssh_proxy/proxy.py
Normal file
130
app/ssh_proxy/proxy.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""
|
||||
Serverseitiger SSH-Verbindungsaufbau (asyncssh).
|
||||
|
||||
Zentrales Sicherheitsprinzip (Konzept 4.2/4.4/6.4): der private Schluessel
|
||||
wird pro Verbindung aus der DB geladen, entschluesselt, an asyncssh
|
||||
uebergeben und danach nicht weiter referenziert -- er verlaesst den
|
||||
Serverprozess nie und wird nicht geloggt. Strict Host Key Checking ist
|
||||
Pflicht: ohne gepinnten Fingerprint wird die Verbindung abgelehnt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import asyncssh
|
||||
import aiosqlite
|
||||
|
||||
from app.security.crypto import decrypt_secret
|
||||
|
||||
logger = logging.getLogger("jumphost.ssh_proxy")
|
||||
|
||||
|
||||
class HostNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostKeyMismatchError(Exception):
|
||||
def __init__(self, expected: str | None, observed: str | None) -> None:
|
||||
self.expected = expected
|
||||
self.observed = observed
|
||||
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
|
||||
|
||||
|
||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
|
||||
def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None:
|
||||
self.expected_fingerprint = expected_fingerprint
|
||||
self.discovery_mode = discovery_mode
|
||||
self.observed_fingerprint: str | None = None
|
||||
|
||||
def validate_host_public_key(self, host, addr, port, key) -> bool: # noqa: D102
|
||||
self.observed_fingerprint = key.get_fingerprint("sha256")
|
||||
if self.discovery_mode:
|
||||
# Nur ueber den expliziten Admin-Discovery-Endpunkt erreichbar,
|
||||
# niemals im regulaeren Verbindungspfad (siehe admin/routes.py).
|
||||
return True
|
||||
if not self.expected_fingerprint:
|
||||
return False
|
||||
return self.observed_fingerprint == self.expected_fingerprint
|
||||
|
||||
|
||||
async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
||||
"ssh_username, file_transfer_enabled, host_group_id FROM hosts WHERE id = ? AND is_active = 1",
|
||||
(host_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise HostNotConfiguredError(f"Host {host_id} nicht gefunden oder inaktiv")
|
||||
keys = (
|
||||
"id", "hostname", "address", "port", "os_type", "protocol",
|
||||
"ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id",
|
||||
)
|
||||
return dict(zip(keys, row))
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
cursor = await conn.execute(
|
||||
"SELECT sk.private_key_enc FROM ssh_keys sk "
|
||||
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
|
||||
"WHERE m.host_id = ? LIMIT 1",
|
||||
(host_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
|
||||
pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
|
||||
try:
|
||||
return asyncssh.import_private_key(pem)
|
||||
finally:
|
||||
# Bestpraxis: Referenz auf den Klartext-PEM-Bytes so schnell wie moeglich loslassen.
|
||||
del pem
|
||||
|
||||
|
||||
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
|
||||
|
||||
private_key = await load_private_key_for_host(conn, host_id)
|
||||
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
||||
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"],
|
||||
port=host["port"],
|
||||
username=host["ssh_username"],
|
||||
client_keys=[private_key],
|
||||
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
||||
client_factory=client_factory,
|
||||
connect_timeout=10,
|
||||
)
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||
raise
|
||||
return connection
|
||||
|
||||
|
||||
async def discover_and_store_host_key(
|
||||
conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int
|
||||
) -> str:
|
||||
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py)."""
|
||||
host = await load_host(conn, host_id)
|
||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
)
|
||||
connection.close()
|
||||
fingerprint = client.observed_fingerprint
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||
)
|
||||
await conn.commit()
|
||||
return fingerprint
|
||||
145
app/ssh_proxy/sftp.py
Normal file
145
app/ssh_proxy/sftp.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""Dateitransfer zu SSH-Zielen per SFTP (Upload/Download ueber den Jumphost).
|
||||
|
||||
Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept
|
||||
6.6). Jeder Transfer wird in file_transfers + audit_log protokolliert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.deps import CurrentUser, get_current_user
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.av_scan import scan_bytes
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||
|
||||
router = APIRouter(prefix="/ssh", tags=["file-transfer"])
|
||||
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
async def _require_file_transfer(host_id: int, request: Request, user: CurrentUser = Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
if not user.is_admin and not await user_has_role_for_host(
|
||||
conn, user_id=user.id, host_id=host_id, role_name="file_transfer"
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Filetransfer-Berechtigung fuer diesen Host")
|
||||
host = await load_host(conn, host_id)
|
||||
if not host["file_transfer_enabled"]:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Dateitransfer ist fuer diesen Host deaktiviert")
|
||||
return host
|
||||
|
||||
|
||||
async def _log_transfer(conn, *, user: CurrentUser, host_id: int, client_ip: str, direction: str,
|
||||
filename: str, size: int, sha256: str, av_result: str) -> None:
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) "
|
||||
"VALUES (?, ?, 'ssh', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'file_transfer')",
|
||||
(user.id, host_id, client_ip),
|
||||
)
|
||||
session_id = cursor.lastrowid
|
||||
await conn.execute(
|
||||
"INSERT INTO file_transfers (session_id, direction, filename, size_bytes, sha256, av_scan_result) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, direction, filename, size, sha256, av_result),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="file_transfer", user_id=user.id, client_ip=client_ip,
|
||||
details={
|
||||
"host_id": host_id, "direction": direction, "filename": filename,
|
||||
"size_bytes": size, "sha256": sha256, "av_scan_result": av_result,
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@router.post("/{host_id}/files/upload")
|
||||
async def upload_file(
|
||||
host_id: int,
|
||||
request: Request,
|
||||
remote_path: str = Query(..., max_length=1024),
|
||||
file: UploadFile = ...,
|
||||
host=Depends(_require_file_transfer),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
data = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
|
||||
|
||||
av_result = scan_bytes(data)
|
||||
if av_result.startswith("infected"):
|
||||
conn = get_db()
|
||||
await write_audit_event(
|
||||
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
|
||||
client_ip=_client_ip(request),
|
||||
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
|
||||
)
|
||||
await conn.commit()
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
|
||||
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
conn = get_db()
|
||||
try:
|
||||
ssh_conn = await connect_to_host(conn, host_id)
|
||||
try:
|
||||
async with ssh_conn.start_sftp_client() as sftp:
|
||||
async with sftp.open(remote_path, "wb") as remote_file:
|
||||
await remote_file.write(data)
|
||||
finally:
|
||||
ssh_conn.close()
|
||||
except HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
|
||||
await _log_transfer(
|
||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
||||
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_scan_result=av_result,
|
||||
)
|
||||
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
|
||||
|
||||
|
||||
@router.get("/{host_id}/files/download")
|
||||
async def download_file(
|
||||
host_id: int,
|
||||
request: Request,
|
||||
remote_path: str = Query(..., max_length=1024),
|
||||
host=Depends(_require_file_transfer),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
conn = get_db()
|
||||
try:
|
||||
ssh_conn = await connect_to_host(conn, host_id)
|
||||
try:
|
||||
async with ssh_conn.start_sftp_client() as sftp:
|
||||
stat = await sftp.stat(remote_path)
|
||||
if stat.size and stat.size > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
|
||||
async with sftp.open(remote_path, "rb") as remote_file:
|
||||
data = await remote_file.read()
|
||||
finally:
|
||||
ssh_conn.close()
|
||||
except HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
filename = remote_path.rsplit("/", 1)[-1]
|
||||
await _log_transfer(
|
||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
|
||||
filename=filename, size=len(data), sha256=sha256, av_scan_result="not_applicable_download",
|
||||
)
|
||||
|
||||
def _iter():
|
||||
yield data
|
||||
|
||||
return StreamingResponse(
|
||||
_iter(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
144
app/ssh_proxy/terminal_ws.py
Normal file
144
app/ssh_proxy/terminal_ws.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
Browser-Terminal <-> SSH-Ziel per WebSocket (xterm.js-kompatibel).
|
||||
|
||||
Framing: JSON-Textframes.
|
||||
Client -> Server: {"type":"input","data":"<base64>"} | {"type":"resize","cols":n,"rows":n}
|
||||
Server -> Client: {"type":"output","data":"<base64>"} | {"type":"error","message":"..."} | {"type":"closed"}
|
||||
|
||||
Jede Session wird aufgezeichnet (app.recordings.recorder) und im Audit-Log
|
||||
mit Start/Ende vermerkt (Konzept 4.2, 4.7, 6.5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import asyncssh
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.auth.deps import get_current_user_ws
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security.audit import write_audit_event
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||
|
||||
logger = logging.getLogger("jumphost.ssh_proxy.ws")
|
||||
router = APIRouter()
|
||||
|
||||
MAX_SESSION_SECONDS = 8 * 3600
|
||||
IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||
|
||||
|
||||
async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder):
|
||||
try:
|
||||
while True:
|
||||
data = await process.stdout.read(65536)
|
||||
if not data:
|
||||
break
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8", errors="replace")
|
||||
recorder.record("output", base64.b64encode(data).decode())
|
||||
await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()})
|
||||
except (asyncssh.Error, ConnectionResetError):
|
||||
pass
|
||||
|
||||
|
||||
@router.websocket("/ws/ssh/{host_id}")
|
||||
async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
user = await get_current_user_ws(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
conn = get_db()
|
||||
if not user.is_admin and not await user_has_role_for_host(
|
||||
conn, user_id=user.id, host_id=host_id, role_name="ssh_connect"
|
||||
):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
client_ip = websocket.client.host if websocket.client else "unknown"
|
||||
|
||||
try:
|
||||
host = await load_host(conn, host_id)
|
||||
except HostNotConfiguredError as exc:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', ?)",
|
||||
(user.id, host_id, client_ip),
|
||||
)
|
||||
session_id = cursor.lastrowid
|
||||
recorder = SessionRecorder(session_id)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_session_start", user_id=user.id, client_ip=client_ip,
|
||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
end_reason = "logout"
|
||||
ssh_conn = None
|
||||
process = None
|
||||
pump_task = None
|
||||
try:
|
||||
ssh_conn = await connect_to_host(conn, host_id)
|
||||
process = await ssh_conn.create_process(term_type="xterm-256color")
|
||||
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
end_reason = "idle_timeout"
|
||||
break
|
||||
|
||||
if msg.get("type") == "input":
|
||||
raw = base64.b64decode(msg.get("data", ""))
|
||||
recorder.record("input", base64.b64encode(raw).decode())
|
||||
process.stdin.write(raw.decode("utf-8", errors="replace"))
|
||||
elif msg.get("type") == "resize":
|
||||
cols, rows = int(msg.get("cols", 80)), int(msg.get("rows", 24))
|
||||
process.change_terminal_size(cols, rows)
|
||||
except WebSocketDisconnect:
|
||||
end_reason = "logout"
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": "Verbindung zum Zielsystem fehlgeschlagen"})
|
||||
except Exception:
|
||||
# Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client;
|
||||
# der eigentliche Fehler ist bereits oben geloggt (logger.warning).
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except HostNotConfiguredError:
|
||||
end_reason = "error"
|
||||
finally:
|
||||
if pump_task:
|
||||
pump_task.cancel()
|
||||
if process:
|
||||
process.close()
|
||||
if ssh_conn:
|
||||
ssh_conn.close()
|
||||
recorder.close()
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"WHERE id = ?",
|
||||
(end_reason, session_id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_session_end", user_id=user.id, client_ip=client_ip,
|
||||
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
|
||||
)
|
||||
await conn.commit()
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|
||||
Reference in New Issue
Block a user