feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+245
View File
@@ -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"}