ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Authentication endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.auth import auth_service
|
||||
from models import Token
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(request: LoginRequest):
|
||||
"""
|
||||
Login with username and password
|
||||
Returns JWT access token
|
||||
"""
|
||||
user = auth_service.authenticate_user(request.username, request.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token = auth_service.create_access_token(request.username)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""
|
||||
Verify JWT token validity
|
||||
"""
|
||||
token = credentials.credentials
|
||||
username = auth_service.verify_token(token)
|
||||
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
return {"valid": True, "username": username}
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Dataset/Filesystem management endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.zfs_runner import zfs_runner
|
||||
from services.auth import auth_service
|
||||
from models import Dataset, DatasetType
|
||||
|
||||
router = APIRouter(prefix="/api/datasets", tags=["datasets"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
class CreateDatasetRequest(BaseModel):
|
||||
name: str
|
||||
properties: Optional[dict] = None
|
||||
|
||||
|
||||
class DatasetPropertiesRequest(BaseModel):
|
||||
compression: Optional[str] = None
|
||||
quota: Optional[int] = None
|
||||
reservation: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Dataset])
|
||||
async def list_datasets(
|
||||
pool: str = "tank",
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List datasets in pool (default: tank)
|
||||
"""
|
||||
try:
|
||||
datasets = zfs_runner.list_datasets(pool)
|
||||
return [
|
||||
Dataset(
|
||||
name=d["name"],
|
||||
type=DatasetType(d["type"]),
|
||||
used=d["used"],
|
||||
avail=d["avail"],
|
||||
refer=d["refer"],
|
||||
mountpoint=d["mountpoint"] if d["mountpoint"] != "-" else None
|
||||
)
|
||||
for d in datasets
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_dataset(
|
||||
request: CreateDatasetRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create new dataset
|
||||
"""
|
||||
try:
|
||||
result = zfs_runner.create_dataset(request.name, request.properties)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{dataset_name:path}")
|
||||
async def update_dataset_properties(
|
||||
dataset_name: str,
|
||||
request: DatasetPropertiesRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update dataset properties (compression, quota, reservation)
|
||||
"""
|
||||
try:
|
||||
props: dict = {}
|
||||
if request.compression is not None:
|
||||
props["compression"] = request.compression
|
||||
if request.quota is not None:
|
||||
props["quota"] = str(request.quota) if request.quota > 0 else "none"
|
||||
if request.reservation is not None:
|
||||
props["reservation"] = str(request.reservation) if request.reservation > 0 else "none"
|
||||
|
||||
if not props:
|
||||
return {"status": "ok", "message": "Nothing to update"}
|
||||
|
||||
result = zfs_runner.set_dataset_properties(dataset_name, props)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{dataset_name:path}")
|
||||
async def delete_dataset(
|
||||
dataset_name: str,
|
||||
recursive: bool = False,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete dataset
|
||||
"""
|
||||
try:
|
||||
result = zfs_runner.destroy_dataset(dataset_name, recursive=recursive)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
User and Group Management endpoints – cockpit-identities
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from services.identities import identities_manager
|
||||
from services.auth import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/identities", tags=["identities"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
home_dir: Optional[str] = None
|
||||
shell: str = "/bin/bash"
|
||||
gecos: Optional[str] = None
|
||||
|
||||
|
||||
class CreateGroupRequest(BaseModel):
|
||||
groupname: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class ChangeShellRequest(BaseModel):
|
||||
shell: str
|
||||
|
||||
|
||||
class AddUserToGroupRequest(BaseModel):
|
||||
groupname: str
|
||||
|
||||
|
||||
# ============== USERS ==============
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(current_user: str = Depends(get_current_user)):
|
||||
"""List all system users"""
|
||||
try:
|
||||
users = identities_manager.list_users()
|
||||
# Add group memberships for each user
|
||||
for user in users:
|
||||
user['groups'] = identities_manager.get_user_groups(user['username'])
|
||||
return {"users": users}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(
|
||||
request: CreateUserRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Create new system user"""
|
||||
try:
|
||||
success = identities_manager.create_user(
|
||||
request.username,
|
||||
request.home_dir,
|
||||
request.shell,
|
||||
request.gecos or ""
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to create user")
|
||||
return {"status": "created", "username": request.username}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
async def delete_user(
|
||||
username: str,
|
||||
remove_home: bool = True,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Delete system user"""
|
||||
try:
|
||||
success = identities_manager.delete_user(username, remove_home)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete user")
|
||||
return {"status": "deleted", "username": username}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/users/{username}/password")
|
||||
async def change_password(
|
||||
username: str,
|
||||
request: ChangePasswordRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Change user password"""
|
||||
try:
|
||||
success = identities_manager.change_password(username, request.password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to change password")
|
||||
return {"status": "updated", "username": username}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/users/{username}/shell")
|
||||
async def change_shell(
|
||||
username: str,
|
||||
request: ChangeShellRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Change user shell"""
|
||||
try:
|
||||
success = identities_manager.change_shell(username, request.shell)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to change shell")
|
||||
return {"status": "updated", "username": username}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/users/{username}/lock")
|
||||
async def lock_user(
|
||||
username: str,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Lock user account"""
|
||||
try:
|
||||
success = identities_manager.lock_user(username)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to lock user")
|
||||
return {"status": "locked", "username": username}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/users/{username}/unlock")
|
||||
async def unlock_user(
|
||||
username: str,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Unlock user account"""
|
||||
try:
|
||||
success = identities_manager.unlock_user(username)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to unlock user")
|
||||
return {"status": "unlocked", "username": username}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/users/{username}/samba-password")
|
||||
async def set_samba_password(
|
||||
username: str,
|
||||
request: ChangePasswordRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Set Samba password for user"""
|
||||
try:
|
||||
success = identities_manager.set_samba_password(username, request.password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to set Samba password")
|
||||
return {"status": "updated", "username": username, "type": "samba"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============== GROUPS ==============
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(current_user: str = Depends(get_current_user)):
|
||||
"""List all system groups"""
|
||||
try:
|
||||
groups = identities_manager.list_groups()
|
||||
return {"groups": groups}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(
|
||||
request: CreateGroupRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Create new system group"""
|
||||
try:
|
||||
success = identities_manager.create_group(request.groupname)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to create group")
|
||||
return {"status": "created", "groupname": request.groupname}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/groups/{groupname}")
|
||||
async def delete_group(
|
||||
groupname: str,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Delete system group"""
|
||||
try:
|
||||
success = identities_manager.delete_group(groupname)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete group")
|
||||
return {"status": "deleted", "groupname": groupname}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============== USER-GROUP MEMBERSHIP ==============
|
||||
|
||||
@router.post("/users/{username}/groups")
|
||||
async def add_user_to_group(
|
||||
username: str,
|
||||
request: AddUserToGroupRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Add user to group"""
|
||||
try:
|
||||
success = identities_manager.add_user_to_group(username, request.groupname)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to add user to group")
|
||||
return {"status": "added", "username": username, "groupname": request.groupname}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/users/{username}/groups/{groupname}")
|
||||
async def remove_user_from_group(
|
||||
username: str,
|
||||
groupname: str,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Remove user from group"""
|
||||
try:
|
||||
success = identities_manager.remove_user_from_group(username, groupname)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to remove user from group")
|
||||
return {"status": "removed", "username": username, "groupname": groupname}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============== SAMBA USERS ==============
|
||||
|
||||
@router.get("/samba-users")
|
||||
async def list_samba_users(current_user: str = Depends(get_current_user)):
|
||||
"""List all Samba users"""
|
||||
try:
|
||||
users = identities_manager.list_samba_users()
|
||||
return {"users": users}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============== LOGIN HISTORY ==============
|
||||
|
||||
@router.get("/login-history")
|
||||
async def get_login_history(
|
||||
limit: int = 50,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent login history"""
|
||||
try:
|
||||
logins = identities_manager.get_login_history(limit)
|
||||
return {"logins": logins, "total": len(logins)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
File Manager endpoints – Browse, upload, download /tank/share
|
||||
Similar to cockpit-files but minimalist
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Query
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import io
|
||||
import os
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from services.file_manager import file_manager
|
||||
from services.auth import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/navigator", tags=["navigator"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
class CreateFileRequest(BaseModel):
|
||||
path: str
|
||||
content: Optional[str] = ""
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
old_path: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class ChangePermissionsRequest(BaseModel):
|
||||
path: str
|
||||
mode: str # e.g. "755", "644"
|
||||
recursive: bool = False
|
||||
|
||||
|
||||
class ChangeOwnerRequest(BaseModel):
|
||||
path: str
|
||||
owner: str
|
||||
group: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def browse_directory(
|
||||
path: str = Query(""),
|
||||
admin: bool = Query(False),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List directory contents
|
||||
Query: ?path=/subdir&admin=false
|
||||
"""
|
||||
from pathlib import Path as PyPath
|
||||
from services.file_manager import FileManager
|
||||
|
||||
if admin:
|
||||
fm = FileManager(base_path=PyPath("/"))
|
||||
else:
|
||||
fm = file_manager
|
||||
|
||||
result = fm.list_directory(path)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/dirs")
|
||||
async def list_subdirectories(
|
||||
path: str = Query(""),
|
||||
admin: bool = Query(False),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List only subdirectories of a path (for tree sidebar navigation).
|
||||
Query: ?path=/subdir&admin=false
|
||||
Returns: { dirs: [{name, path, has_children}] }
|
||||
"""
|
||||
from pathlib import Path as PyPath
|
||||
from services.file_manager import FileManager
|
||||
|
||||
if admin:
|
||||
fm = FileManager(base_path=PyPath("/"))
|
||||
else:
|
||||
fm = file_manager
|
||||
|
||||
result = fm.list_subdirectories(path)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def get_info(
|
||||
path: str = Query(""),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get file/directory info
|
||||
Query: ?path=/filename.txt
|
||||
"""
|
||||
result = file_manager.get_file_info(path)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
async def read_file(
|
||||
path: str = Query(""),
|
||||
limit: Optional[int] = Query(None),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Read file content (text files only, max 10MB)
|
||||
Query: ?path=/file.txt&limit=1000
|
||||
"""
|
||||
result = file_manager.read_file(path, limit)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download_file(
|
||||
path: str = Query(""),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download file
|
||||
Returns binary file stream
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
target = file_manager._resolve_path(path)
|
||||
if not target or not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
try:
|
||||
return FileResponse(
|
||||
path=target,
|
||||
filename=target.name,
|
||||
media_type="application/octet-stream"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
path: str = Query(""),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Upload file to directory
|
||||
Query: ?path=/subdir
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
target = file_manager._resolve_path(path)
|
||||
if not target or not target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Invalid upload directory")
|
||||
|
||||
try:
|
||||
# Create target file path
|
||||
file_path = target / file.filename
|
||||
|
||||
if file_path.exists():
|
||||
raise HTTPException(status_code=400, detail="File already exists")
|
||||
|
||||
# Write uploaded file
|
||||
contents = await file.read()
|
||||
if len(contents) > file_manager.MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"size": len(contents),
|
||||
"path": str(file_path.relative_to(file_manager.base_path))
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_file(
|
||||
request: CreateFileRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create new file with optional content
|
||||
"""
|
||||
result = file_manager.create_file(request.path, request.content)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/mkdir")
|
||||
async def make_directory(
|
||||
request: MkdirRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create directory
|
||||
"""
|
||||
result = file_manager.mkdir(request.path)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rename")
|
||||
async def rename_file(
|
||||
request: RenameRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Rename file or directory
|
||||
"""
|
||||
result = file_manager.rename_file(request.old_path, request.new_name)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/delete")
|
||||
async def delete_file(
|
||||
path: str = Query(""),
|
||||
recursive: bool = Query(False),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete file or directory
|
||||
Query: ?path=/file.txt or ?path=/dir&recursive=true
|
||||
"""
|
||||
result = file_manager.delete_file_or_dir(path, recursive)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/space")
|
||||
async def get_space_info(current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
Get space usage of /tank/share
|
||||
"""
|
||||
result = file_manager.get_space_info()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/permissions")
|
||||
async def change_permissions(
|
||||
request: ChangePermissionsRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Change file/directory permissions (chmod)
|
||||
Request: {"path": "/file.txt", "mode": "755", "recursive": false}
|
||||
"""
|
||||
if request.recursive:
|
||||
result = file_manager.change_permissions_recursive(request.path, request.mode)
|
||||
else:
|
||||
result = file_manager.change_permissions(request.path, request.mode)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/owner")
|
||||
async def change_owner(
|
||||
request: ChangeOwnerRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Change file/directory owner (chown)
|
||||
Request: {"path": "/file.txt", "owner": "root", "group": "wheel"}
|
||||
"""
|
||||
result = file_manager.change_owner(request.path, request.owner, request.group)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
class UnlockRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/unlock")
|
||||
async def unlock_admin_mode(
|
||||
request: UnlockRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Unlock admin mode with password
|
||||
Returns a token that enables full filesystem access
|
||||
"""
|
||||
admin_password = os.environ.get("ZFS_ADMIN_PASSWORD", "admin")
|
||||
|
||||
if request.password != admin_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid admin password"
|
||||
)
|
||||
|
||||
# Generate a short-lived admin token (valid for 1 hour)
|
||||
secret_key = os.environ.get("ZFS_SECRET_KEY", "change-me-in-production")
|
||||
payload = {
|
||||
"sub": current_user,
|
||||
"admin": True,
|
||||
"exp": datetime.utcnow() + timedelta(hours=1)
|
||||
}
|
||||
token = jwt.encode(payload, secret_key, algorithm="HS256")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"admin_token": token,
|
||||
"message": "Admin mode unlocked"
|
||||
}
|
||||
|
||||
|
||||
class CopyRequest(BaseModel):
|
||||
src: str
|
||||
dst: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
@router.post("/copy")
|
||||
async def copy_file(
|
||||
request: CopyRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Copy file or directory
|
||||
"""
|
||||
result = file_manager.copy_file(request.src, request.dst, request.overwrite)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
src: str
|
||||
dst: str
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_file(
|
||||
request: MoveRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Move (rename) file or directory
|
||||
"""
|
||||
result = file_manager.move_file(request.src, request.dst, request.overwrite)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_files(
|
||||
q: str = Query(""),
|
||||
path: str = Query(""),
|
||||
limit: int = Query(50),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Search for files by name (case-insensitive)
|
||||
Query: ?q=term&path=/subdir&limit=50
|
||||
"""
|
||||
if not q:
|
||||
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
|
||||
|
||||
results = file_manager.search_files(q, path, limit)
|
||||
return {
|
||||
"query": q,
|
||||
"path": path or "/",
|
||||
"results": results,
|
||||
"count": len(results)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Pool management endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import List
|
||||
|
||||
from services.zfs_runner import zfs_runner
|
||||
from services.auth import auth_service
|
||||
from models import Pool, PoolStatus, PoolHealth
|
||||
|
||||
router = APIRouter(prefix="/api/pools", tags=["pools"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Pool])
|
||||
async def list_pools():
|
||||
"""
|
||||
Get list of all ZFS pools (public)
|
||||
"""
|
||||
try:
|
||||
pools = zfs_runner.list_pools()
|
||||
# Convert to Pydantic models
|
||||
return [
|
||||
Pool(
|
||||
name=p["name"],
|
||||
size=p["size"],
|
||||
alloc=p["alloc"],
|
||||
free=p["free"],
|
||||
fragmentation=p["fragmentation"],
|
||||
capacity=p["capacity"],
|
||||
health=PoolHealth(p["health"])
|
||||
)
|
||||
for p in pools
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{pool_name}", response_model=PoolStatus)
|
||||
async def get_pool_status(pool_name: str):
|
||||
"""
|
||||
Get detailed status of specific pool (public)
|
||||
"""
|
||||
try:
|
||||
status_data = zfs_runner.get_pool_status(pool_name)
|
||||
if not status_data:
|
||||
raise HTTPException(status_code=404, detail=f"Pool {pool_name} not found")
|
||||
|
||||
# Map state to health enum
|
||||
health = PoolHealth.ONLINE
|
||||
if "state" in status_data and status_data["state"]:
|
||||
try:
|
||||
health = PoolHealth(status_data["state"])
|
||||
except ValueError:
|
||||
health = PoolHealth.ONLINE
|
||||
|
||||
return PoolStatus(
|
||||
name=pool_name,
|
||||
state=status_data.get("state"),
|
||||
health=health,
|
||||
scan=status_data.get("scan"),
|
||||
errors=status_data.get("errors"),
|
||||
vdevs=status_data.get("vdevs", [])
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{pool_name}/scrub")
|
||||
async def scrub_pool(pool_name: str, current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
Start or resume scrub on pool
|
||||
"""
|
||||
try:
|
||||
result = zfs_runner.scrub_pool(pool_name)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/clear-cache")
|
||||
async def clear_cache(current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
Clear ZFS command cache (for testing/debugging)
|
||||
"""
|
||||
zfs_runner.clear_cache()
|
||||
return {"status": "success", "message": "Cache cleared"}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
File Sharing endpoints (Samba/NFS) – like cockpit-file-sharing
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from services.shares import share_manager
|
||||
from services.auth import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/shares", tags=["shares"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
class CreateSambaShareRequest(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class CreateNFSShareRequest(BaseModel):
|
||||
path: str
|
||||
clients: str
|
||||
options: Optional[str] = None
|
||||
|
||||
|
||||
class SambaConfigRequest(BaseModel):
|
||||
config: str
|
||||
|
||||
|
||||
class SambaImportRequest(BaseModel):
|
||||
config_file: str
|
||||
|
||||
|
||||
# ============== SAMBA ==============
|
||||
|
||||
@router.get("/samba")
|
||||
async def list_samba_shares(current_user: str = Depends(get_current_user)):
|
||||
"""List all Samba shares"""
|
||||
try:
|
||||
shares = share_manager.list_samba_shares()
|
||||
return {"shares": shares}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/samba")
|
||||
async def create_samba_share(
|
||||
request: CreateSambaShareRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Create new Samba share"""
|
||||
if not request.name.strip() or not request.path.strip():
|
||||
raise HTTPException(status_code=400, detail="Name and path are required")
|
||||
try:
|
||||
success = share_manager.create_samba_share(
|
||||
request.name,
|
||||
request.path,
|
||||
request.comment
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to create Samba share")
|
||||
return {"status": "created", "name": request.name}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/samba/{name}")
|
||||
async def delete_samba_share(
|
||||
name: str,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Delete Samba share"""
|
||||
try:
|
||||
success = share_manager.delete_samba_share(name)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Samba share '{name}' not found")
|
||||
return {"status": "deleted", "name": name}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/samba/config")
|
||||
async def get_samba_config(current_user: str = Depends(get_current_user)):
|
||||
"""Get Samba global configuration"""
|
||||
try:
|
||||
config = share_manager.get_samba_global_config()
|
||||
return config
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/samba/config")
|
||||
async def set_samba_config(
|
||||
request: SambaConfigRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Update Samba global configuration"""
|
||||
try:
|
||||
success = share_manager.set_samba_global_config(request.config)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to update Samba configuration")
|
||||
return {"status": "updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/samba/config/import")
|
||||
async def import_samba_config(
|
||||
request: SambaImportRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Import Samba configuration using net conf import"""
|
||||
try:
|
||||
success = share_manager.import_samba_config(request.config_file)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to import Samba configuration")
|
||||
return {"status": "imported", "config_file": request.config_file}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============== NFS ==============
|
||||
|
||||
@router.get("/nfs")
|
||||
async def list_nfs_shares(current_user: str = Depends(get_current_user)):
|
||||
"""List all NFS shares"""
|
||||
try:
|
||||
shares = share_manager.list_nfs_shares()
|
||||
return {"shares": shares}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/nfs")
|
||||
async def create_nfs_share(
|
||||
request: CreateNFSShareRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Create new NFS share"""
|
||||
if not request.path.strip() or not request.clients.strip():
|
||||
raise HTTPException(status_code=400, detail="Path and clients are required")
|
||||
try:
|
||||
success = share_manager.create_nfs_share(
|
||||
request.path,
|
||||
request.clients,
|
||||
request.options
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to create NFS share")
|
||||
return {"status": "created", "path": request.path}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/nfs")
|
||||
async def delete_nfs_share(
|
||||
path: str = None,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Delete NFS share"""
|
||||
try:
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="path parameter required")
|
||||
|
||||
success = share_manager.delete_nfs_share(path)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"NFS share '{path}' not found")
|
||||
return {"status": "deleted", "path": path}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/nfs/config")
|
||||
async def get_nfs_config(current_user: str = Depends(get_current_user)):
|
||||
"""Get NFS global configuration (/etc/exports)"""
|
||||
try:
|
||||
config = share_manager.get_nfs_config()
|
||||
return config
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/nfs/config")
|
||||
async def set_nfs_config(
|
||||
request: SambaConfigRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Update NFS global configuration (/etc/exports)"""
|
||||
try:
|
||||
success = share_manager.set_nfs_config(request.config)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to update NFS configuration")
|
||||
return {"status": "updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Snapshot management endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from services.zfs_runner import zfs_runner
|
||||
from services.auth import auth_service
|
||||
from models import Snapshot
|
||||
|
||||
router = APIRouter(prefix="/api/snapshots", tags=["snapshots"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
class CreateSnapshotRequest(BaseModel):
|
||||
dataset: str
|
||||
name: Optional[str] = None # Auto-generate if not provided
|
||||
|
||||
|
||||
class RollbackSnapshotRequest(BaseModel):
|
||||
snapshot: str
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Snapshot])
|
||||
async def list_snapshots(
|
||||
dataset: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List snapshots (optionally filtered by dataset)
|
||||
"""
|
||||
try:
|
||||
snapshots = zfs_runner.list_snapshots(dataset, limit=limit)
|
||||
return [
|
||||
Snapshot(
|
||||
name=s["name"],
|
||||
dataset=s["name"].split("@")[0], # Extract dataset part
|
||||
created=s["creation"],
|
||||
used=s["used"],
|
||||
referenced=s["referenced"],
|
||||
creation_datetime=datetime.fromtimestamp(s["creation"]).isoformat() + "Z"
|
||||
)
|
||||
for s in snapshots
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_snapshot(
|
||||
request: CreateSnapshotRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create new snapshot
|
||||
"""
|
||||
try:
|
||||
result = zfs_runner.create_snapshot(request.dataset, request.name)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{snapshot_name:path}")
|
||||
async def delete_snapshot(
|
||||
snapshot_name: str,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete snapshot
|
||||
"""
|
||||
try:
|
||||
result = zfs_runner.destroy_snapshot(snapshot_name)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/rollback")
|
||||
async def rollback_snapshot(
|
||||
request: RollbackSnapshotRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Rollback dataset to snapshot (WARNING: Destroys data after snapshot!)
|
||||
"""
|
||||
try:
|
||||
result = zfs_runner.rollback_snapshot(request.snapshot)
|
||||
if result.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
System Management endpoints – like cockpit-system
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.system_info import system_info
|
||||
from services.auth import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify JWT token and return username"""
|
||||
username = auth_service.verify_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
class SetHostnameRequest(BaseModel):
|
||||
hostname: str
|
||||
|
||||
|
||||
class SetTimeRequest(BaseModel):
|
||||
iso: str
|
||||
|
||||
|
||||
# ============== SYSTEM INFO ==============
|
||||
|
||||
@router.get("/info")
|
||||
async def get_info():
|
||||
"""Get general system information (public)"""
|
||||
return system_info.get_system_info()
|
||||
|
||||
|
||||
@router.get("/hostname")
|
||||
async def get_hostname():
|
||||
"""Get system hostname (public)"""
|
||||
result = system_info.get_hostname()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/hostname")
|
||||
async def set_hostname(
|
||||
request: SetHostnameRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Set system hostname"""
|
||||
result = system_info.set_hostname(request.hostname)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== UPTIME ==============
|
||||
|
||||
@router.get("/uptime")
|
||||
async def get_uptime():
|
||||
"""Get system uptime (public)"""
|
||||
result = system_info.get_uptime()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== MEMORY ==============
|
||||
|
||||
@router.get("/memory")
|
||||
async def get_memory():
|
||||
"""Get memory usage (public)"""
|
||||
result = system_info.get_memory()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== CPU ==============
|
||||
|
||||
@router.get("/cpu")
|
||||
async def get_cpu_info():
|
||||
"""Get CPU information (public)"""
|
||||
result = system_info.get_cpu_info()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== TIME ==============
|
||||
|
||||
@router.get("/time")
|
||||
async def get_time():
|
||||
"""Get system time (public)"""
|
||||
result = system_info.get_time()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/time")
|
||||
async def set_time(
|
||||
request: SetTimeRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Set system time (ISO format)"""
|
||||
result = system_info.set_time(request.iso)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== UPDATES ==============
|
||||
|
||||
@router.get("/updates")
|
||||
async def check_updates(current_user: str = Depends(get_current_user)):
|
||||
"""Check available updates"""
|
||||
result = system_info.get_updates()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== REBOOT/SHUTDOWN ==============
|
||||
|
||||
@router.post("/reboot")
|
||||
async def reboot(current_user: str = Depends(get_current_user)):
|
||||
"""Reboot system"""
|
||||
result = system_info.reboot()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/shutdown")
|
||||
async def shutdown(current_user: str = Depends(get_current_user)):
|
||||
"""Shutdown system"""
|
||||
result = system_info.shutdown()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== NETWORK ==============
|
||||
|
||||
@router.get("/network")
|
||||
async def get_network():
|
||||
"""Get network interface information (public)"""
|
||||
result = system_info.get_network_info()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/network/traffic")
|
||||
async def get_network_traffic():
|
||||
"""Get network interface traffic (RX/TX bytes) (public)"""
|
||||
result = system_info.get_network_traffic()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== DISK I/O ==============
|
||||
|
||||
@router.get("/diskio")
|
||||
async def get_diskio():
|
||||
"""Get disk I/O statistics (read/write operations and bytes) (public)"""
|
||||
result = system_info.get_disk_io()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== SERVICES ==============
|
||||
|
||||
@router.get("/services")
|
||||
async def get_services():
|
||||
"""Get running systemd services (public)"""
|
||||
result = system_info.get_services()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/units")
|
||||
async def get_units():
|
||||
"""Get all systemd units (services, targets, sockets, timers, paths) (public)"""
|
||||
result = system_info.get_all_units()
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ============== LOGS ==============
|
||||
|
||||
@router.get("/logs")
|
||||
async def get_logs(limit: int = 20):
|
||||
"""Get recent system logs (public)"""
|
||||
result = system_info.get_journal_logs(limit)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
Reference in New Issue
Block a user