feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import generate_registration_code, generate_device_token, hash_token
|
||||
from app.models.models import User, Client, ClientCredential, RegistrationCode
|
||||
from app.schemas.schemas import (
|
||||
ClientResponse, ClientRegisterRequest, ClientRegisterResponse,
|
||||
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest
|
||||
)
|
||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||
from app.services.event_service import log_event
|
||||
from app.ws.manager import ws_manager
|
||||
|
||||
router = APIRouter(prefix="/clients", tags=["Clients"])
|
||||
|
||||
@router.get("", response_model=List[ClientResponse])
|
||||
async def list_clients(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
result = await db.execute(select(Client).order_by(desc(Client.created_at)))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/registration-code", response_model=RegistrationCodeResponse)
|
||||
async def create_registration_code(
|
||||
payload: RegistrationCodeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
code_str = generate_registration_code()
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=payload.expires_in_hours)
|
||||
|
||||
reg_code = RegistrationCode(
|
||||
code=code_str,
|
||||
client_name_hint=payload.client_name_hint,
|
||||
expires_at=expires,
|
||||
is_used=False
|
||||
)
|
||||
db.add(reg_code)
|
||||
await db.commit()
|
||||
await db.refresh(reg_code)
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="REGISTRATION_CODE_GENERATED",
|
||||
message=f"Generated registration code {code_str} (hint: {payload.client_name_hint or 'None'}).",
|
||||
severity="INFO",
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return reg_code
|
||||
|
||||
@router.post("/register", response_model=ClientRegisterResponse)
|
||||
async def register_client(
|
||||
payload: ClientRegisterRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Called by the Windows Agent during initial setup to register against the server."""
|
||||
# Find valid registration code
|
||||
now = datetime.now(timezone.utc)
|
||||
res = await db.execute(
|
||||
select(RegistrationCode).where(
|
||||
RegistrationCode.code == payload.registration_code.strip().upper(),
|
||||
RegistrationCode.is_used == False,
|
||||
RegistrationCode.expires_at > now
|
||||
)
|
||||
)
|
||||
reg_code = res.scalar_one_or_none()
|
||||
if not reg_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid, expired, or already used registration code."
|
||||
)
|
||||
|
||||
# Determine next client code (e.g., CLIENT-0001)
|
||||
count_res = await db.execute(select(func.count(Client.id)))
|
||||
client_count = count_res.scalar() or 0
|
||||
client_code = f"CLIENT-{client_count + 1:04d}"
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
# Create Client
|
||||
client = Client(
|
||||
client_code=client_code,
|
||||
name=payload.name or reg_code.client_name_hint or payload.hostname,
|
||||
hostname=payload.hostname,
|
||||
os_info=payload.os_info,
|
||||
ip_address=client_ip,
|
||||
agent_version=payload.agent_version,
|
||||
status="ONLINE",
|
||||
last_seen_at=now,
|
||||
is_active=True
|
||||
)
|
||||
db.add(client)
|
||||
await db.flush()
|
||||
|
||||
# Generate device unique ID and secret token
|
||||
device_id = str(uuid.uuid4())
|
||||
device_token = generate_device_token()
|
||||
token_hash = hash_token(device_token)
|
||||
|
||||
credential = ClientCredential(
|
||||
client_id=client.id,
|
||||
device_id=device_id,
|
||||
token_hash=token_hash,
|
||||
name=f"{payload.hostname} Agent",
|
||||
is_revoked=False,
|
||||
created_at=now,
|
||||
last_used_at=now
|
||||
)
|
||||
db.add(credential)
|
||||
|
||||
# Mark registration code as used
|
||||
reg_code.is_used = True
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CLIENT_REGISTERED",
|
||||
message=f"Windows client registered: {client.name} ({client.client_code}, Hostname: {client.hostname}, IP: {client_ip})",
|
||||
severity="INFO",
|
||||
client_id=client.id,
|
||||
ip_address=client_ip,
|
||||
details={"device_id": device_id, "os_info": payload.os_info}
|
||||
)
|
||||
|
||||
await ws_manager.broadcast("CLIENT_REGISTERED", {
|
||||
"id": client.id,
|
||||
"client_code": client.client_code,
|
||||
"name": client.name,
|
||||
"hostname": client.hostname,
|
||||
"status": client.status
|
||||
})
|
||||
|
||||
return {
|
||||
"client_code": client.client_code,
|
||||
"device_id": device_id,
|
||||
"device_token": device_token,
|
||||
"name": client.name,
|
||||
"server_time": now
|
||||
}
|
||||
|
||||
@router.post("/{client_id}/heartbeat")
|
||||
async def client_heartbeat(
|
||||
client_id: int,
|
||||
payload: ClientHeartbeatRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""Heartbeat endpoint invoked periodically by the Windows Agent."""
|
||||
if current_client.id != client_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client ID mismatch")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_client.last_seen_at = now
|
||||
current_client.status = payload.status
|
||||
if payload.agent_version:
|
||||
current_client.agent_version = payload.agent_version
|
||||
|
||||
client_ip = payload.ip_address or (request.client.host if request.client else None)
|
||||
if client_ip:
|
||||
current_client.ip_address = client_ip
|
||||
|
||||
await db.commit()
|
||||
|
||||
await ws_manager.broadcast("CLIENT_HEARTBEAT", {
|
||||
"client_id": current_client.id,
|
||||
"status": current_client.status,
|
||||
"last_seen_at": now.isoformat()
|
||||
})
|
||||
|
||||
return {"status": "ok", "server_time": now}
|
||||
|
||||
@router.get("/{client_id}", response_model=ClientResponse)
|
||||
async def get_client(
|
||||
client_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
res = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = res.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
||||
return client
|
||||
|
||||
@router.post("/{client_id}/revoke")
|
||||
async def revoke_client_credentials(
|
||||
client_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Revokes all active authentication tokens for a client."""
|
||||
res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id))
|
||||
creds = res.scalars().all()
|
||||
for cred in creds:
|
||||
cred.is_revoked = True
|
||||
|
||||
client_res = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = client_res.scalar_one_or_none()
|
||||
if client:
|
||||
client.status = "OFFLINE"
|
||||
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CREDENTIALS_REVOKED",
|
||||
message=f"Revoked credentials for client ID {client_id}.",
|
||||
severity="WARNING",
|
||||
client_id=client_id,
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return {"message": "Client credentials successfully revoked"}
|
||||
|
||||
@router.delete("/{client_id}")
|
||||
async def delete_client(
|
||||
client_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
res = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = res.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
||||
|
||||
await db.delete(client)
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CLIENT_DELETED",
|
||||
message=f"Deleted client {client.name} ({client.client_code}).",
|
||||
severity="WARNING",
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return {"message": f"Client {client.client_code} deleted"}
|
||||
Reference in New Issue
Block a user