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:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 6d74d874b6
104 changed files with 28836 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
# backend Dev Log
## 2026-04-14 22:06 22:11 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** cockpit_new
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-14 22:11 22:15 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-14 22:15 22:17 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-14 22:18 22:20 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-14 22:21 22:22 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-14 22:22 22:23 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-16 10:53 20:00 (57h 07m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-18 20:01 20:01 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-18 20:02 20:02 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-19 10:44 10:45 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-19 17:16 17:21 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-19 17:22 17:23 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-19 22:27 22:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-19 22:29 22:30 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
## 2026-04-19 22:34 22:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
Keine Änderungen ermittelbar.
---
+227
View File
@@ -0,0 +1,227 @@
# ZMB Webui Backend API
FastAPI backend for ZFS pool, dataset, and snapshot management.
## Quick Start (Local Development)
### Prerequisites
- Python 3.11+
- ZFS tools installed (`zpool`, `zfs`)
### Setup
```bash
cd backend
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Create default admin user (password: admin)
python3 -c "from services.auth import auth_service; auth_service.add_user('admin', 'admin')"
# Run development server
python3 main.py
```
Server runs on `http://localhost:8000`
## API Endpoints
### Authentication
```bash
# Login
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
# Returns: {"access_token":"...", "token_type":"bearer"}
```
### Pools
```bash
# List pools (requires auth)
curl http://localhost:8000/api/pools \
-H "Authorization: Bearer YOUR_TOKEN"
# Get pool status
curl http://localhost:8000/api/pools/tank \
-H "Authorization: Bearer YOUR_TOKEN"
# Start scrub
curl -X POST http://localhost:8000/api/pools/tank/scrub \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Datasets
```bash
# List datasets
curl http://localhost:8000/api/datasets \
-H "Authorization: Bearer YOUR_TOKEN"
# Create dataset
curl -X POST http://localhost:8000/api/datasets \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"tank/backup","properties":{"compression":"lz4"}}'
# Delete dataset
curl -X DELETE http://localhost:8000/api/datasets/tank/test \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Snapshots
```bash
# List snapshots
curl http://localhost:8000/api/snapshots \
-H "Authorization: Bearer YOUR_TOKEN"
# Create snapshot
curl -X POST http://localhost:8000/api/snapshots \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dataset":"tank/share"}'
# Delete snapshot
curl -X DELETE http://localhost:8000/api/snapshots/tank/share@2026-04-14-120000 \
-H "Authorization: Bearer YOUR_TOKEN"
# Rollback snapshot
curl -X POST http://localhost:8000/api/snapshots/rollback \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"snapshot":"tank/share@2026-04-14-120000"}'
```
### File Manager (Browse /tank/share)
```bash
# Browse directory
curl "http://localhost:8000/api/files/browse?path=/" \
-H "Authorization: Bearer YOUR_TOKEN"
# Get file info
curl "http://localhost:8000/api/files/info?path=/document.pdf" \
-H "Authorization: Bearer YOUR_TOKEN"
# Read text file (< 10MB)
curl "http://localhost:8000/api/files/read?path=/config.json&limit=1000" \
-H "Authorization: Bearer YOUR_TOKEN"
# Download file
curl "http://localhost:8000/api/files/download?path=/archive.tar.gz" \
-H "Authorization: Bearer YOUR_TOKEN" \
-O
# Upload file
curl -X POST "http://localhost:8000/api/files/upload?path=/" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@/local/path/file.txt"
# Create directory
curl -X POST http://localhost:8000/api/files/mkdir \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"path":"/new_folder"}'
# Create file
curl -X POST http://localhost:8000/api/files/create \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"path":"/notes.txt","content":"Hello World"}'
# Rename file
curl -X POST http://localhost:8000/api/files/rename \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"old_path":"/oldname.txt","new_name":"newname.txt"}'
# Delete file
curl -X DELETE "http://localhost:8000/api/files/delete?path=/file.txt" \
-H "Authorization: Bearer YOUR_TOKEN"
# Delete directory (recursive)
curl -X DELETE "http://localhost:8000/api/files/delete?path=/folder&recursive=true" \
-H "Authorization: Bearer YOUR_TOKEN"
# Get space usage
curl http://localhost:8000/api/files/space \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Architecture
### Services
- **zfs_runner.py**: Subprocess wrapper for ZFS commands with caching
- **auth.py**: JWT token generation and verification
### Routers
- **auth.py**: Login endpoint
- **pools.py**: Pool list, status, scrub operations
- **datasets.py**: Dataset CRUD operations
- **snapshots.py**: Snapshot CRUD, rollback
### Models
- **pool.py**: Pool, PoolStatus, PoolHealth
- **dataset.py**: Dataset, DatasetType
- **snapshot.py**: Snapshot
- **auth.py**: User, Token, TokenData
## Caching Strategy
- Pool status: 30s TTL
- Snapshots: 60s TTL
- Datasets: 60s TTL
Cache is cleared on mutations (create/delete operations).
## Performance Notes
### For 4GB RAM Raspberry Pi:
- gunicorn: 2 workers
- Memory limit: 512M soft / 768M hard
- Connection timeout: 30s
- Max requests per worker: 500 (recycle to prevent memory leaks)
### Optimizations:
- ZFS queries limited to max depth 2
- Snapshots limited to 50 by default (can be increased with `?limit=N`)
- Subprocess timeout: 5s
- In-memory TTL cache (no Redis required)
## Production Deployment
1. Copy backend to `/opt/zmb-webui/backend`
2. Install systemd service: `sudo cp deploy/zmb-webui-backend.service /etc/systemd/system/`
3. Update users: `python3 -c "from services.auth import auth_service; auth_service.add_user('yourusername', 'strongpassword')"`
4. Start service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable zmb-webui-backend
sudo systemctl start zmb-webui-backend
```
## Troubleshooting
### ZFS commands not working
- Check: `zpool list` runs without errors
- Check: Running as root or with proper sudo permissions
- Check: ZFS kernel module is loaded: `lsmod | grep zfs`
### Memory usage growing
- Check: `ps aux | grep uvicorn` for VSZ/RSS
- Restart service: `systemctl restart zmb-webui-backend`
- Increase frequency of restarts in crontab if needed
### Slow responses
- Check: `zpool status` output (large pool = slow scrub)
- Clear cache: `curl -X POST /api/pools/clear-cache` (with auth)
- Consider increasing cache TTLs in `zfs_runner.py`
## Development Tips
- Use `curl` or Postman for API testing
- Check logs: `journalctl -u zmb-webui-backend -f`
- Interactive API docs: `http://localhost:8000/docs` (after running)
+205
View File
@@ -0,0 +1,205 @@
#!/bin/bash
# System Compatibility Check for ZMB Webui Backend
# Run before installation to verify system meets requirements
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ ZMB Webui Backend System Compatibility Check ║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════════════════════════╝${NC}"
echo ""
ISSUES=0
WARNINGS=0
# ============== ARCHITECTURE ==============
echo -e "${YELLOW}Architecture${NC}"
ARCH=$(uname -m)
case $ARCH in
aarch64)
echo -e " ${GREEN}${NC} $ARCH (ARM64 - Raspberry Pi)"
;;
x86_64 | x86-64)
echo -e " ${GREEN}${NC} $ARCH (AMD64 - 64-bit x86)"
;;
i686 | i386)
echo -e " ${YELLOW}${NC} $ARCH (32-bit x86 - may be slow)"
((WARNINGS++))
;;
*)
echo -e " ${RED}${NC} $ARCH (unknown/unsupported)"
((ISSUES++))
;;
esac
echo ""
# ============== OS ==============
echo -e "${YELLOW}Operating System${NC}"
if [ -f /etc/os-release ]; then
. /etc/os-release
OS_NAME="${NAME:-Unknown}"
OS_VERSION="${VERSION_ID:-unknown}"
if [[ "$ID" == "debian" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then
echo -e " ${GREEN}${NC} $OS_NAME ($OS_VERSION) - Debian-based"
PKG_MANAGER="apt"
elif [[ "$ID" == "ubuntu" ]]; then
echo -e " ${GREEN}${NC} Ubuntu ($OS_VERSION) - Debian-based"
PKG_MANAGER="apt"
elif [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID" == "fedora" ]]; then
echo -e " ${YELLOW}${NC} $OS_NAME ($OS_VERSION) - RHEL-based (use install-rhel.sh)"
((WARNINGS++))
PKG_MANAGER="dnf"
else
echo -e " ${RED}${NC} $OS_NAME - not tested"
((ISSUES++))
fi
else
echo -e " ${RED}${NC} Could not detect OS"
((ISSUES++))
fi
echo ""
# ============== PYTHON ==============
echo -e "${YELLOW}Python${NC}"
if command -v python3 &> /dev/null; then
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
PYTHON_MAJOR=$(echo $PYTHON_VERSION | cut -d. -f1)
PYTHON_MINOR=$(echo $PYTHON_VERSION | cut -d. -f2)
if [ "$PYTHON_MAJOR" -ge 3 ] && [ "$PYTHON_MINOR" -ge 8 ]; then
echo -e " ${GREEN}${NC} Python $PYTHON_VERSION"
else
echo -e " ${RED}${NC} Python $PYTHON_VERSION (need 3.8+)"
((ISSUES++))
fi
else
echo -e " ${RED}${NC} Python 3 not found"
((ISSUES++))
fi
echo ""
# ============== PIP ==============
echo -e "${YELLOW}pip${NC}"
if python3 -m pip --version &> /dev/null; then
echo -e " ${GREEN}${NC} pip available"
else
echo -e " ${RED}${NC} pip not found"
((ISSUES++))
fi
echo ""
# ============== ZFS TOOLS ==============
echo -e "${YELLOW}ZFS Tools${NC}"
ZFS_OK=true
if command -v zpool &> /dev/null; then
ZPOOL_VERSION=$(zpool --version 2>/dev/null | head -1)
echo -e " ${GREEN}${NC} zpool - $ZPOOL_VERSION"
else
echo -e " ${RED}${NC} zpool not found"
ZFS_OK=false
((ISSUES++))
fi
if command -v zfs &> /dev/null; then
ZFS_VERSION=$(zfs --version 2>/dev/null | head -1)
echo -e " ${GREEN}${NC} zfs - $ZFS_VERSION"
else
echo -e " ${RED}${NC} zfs not found"
ZFS_OK=false
((ISSUES++))
fi
if [ "$ZFS_OK" = true ]; then
# Try to list pools to verify ZFS is working
if zpool list &> /dev/null; then
echo -e " ${GREEN}${NC} ZFS is functional (can list pools)"
fi
else
echo -e " ${YELLOW}${NC} ZFS not installed - install with: $PKG_MANAGER install zfsutils-linux"
((WARNINGS++))
fi
echo ""
# ============== SYSTEMD ==============
echo -e "${YELLOW}systemd${NC}"
if command -v systemctl &> /dev/null; then
echo -e " ${GREEN}${NC} systemd available"
else
echo -e " ${YELLOW}${NC} systemd not found (required for service installation)"
((WARNINGS++))
fi
echo ""
# ============== DISK SPACE ==============
echo -e "${YELLOW}Disk Space${NC}"
AVAILABLE=$(df /opt 2>/dev/null | tail -1 | awk '{print $4}')
if [ -z "$AVAILABLE" ]; then
AVAILABLE=$(df / 2>/dev/null | tail -1 | awk '{print $4}')
fi
if [ ! -z "$AVAILABLE" ] && [ "$AVAILABLE" -gt 500000 ]; then
AVAILABLE_MB=$((AVAILABLE / 1024))
echo -e " ${GREEN}${NC} ~${AVAILABLE_MB}MB available"
else
echo -e " ${RED}${NC} Less than 500MB available"
((ISSUES++))
fi
echo ""
# ============== MEMORY ==============
echo -e "${YELLOW}Memory${NC}"
if [ -f /proc/meminfo ]; then
TOTAL_MEM=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_MB=$((TOTAL_MEM / 1024))
if [ "$TOTAL_MB" -ge 512 ]; then
echo -e " ${GREEN}${NC} ${TOTAL_MB}MB available"
if [ "$TOTAL_MB" -lt 1024 ]; then
echo -e " ${YELLOW}${NC} Less than 1GB - performance may be limited"
((WARNINGS++))
fi
else
echo -e " ${RED}${NC} Only ${TOTAL_MB}MB - too little memory"
((ISSUES++))
fi
else
echo -e " ${YELLOW}${NC} Could not determine memory"
((WARNINGS++))
fi
echo ""
# ============== NETWORK ==============
echo -e "${YELLOW}Network${NC}"
if ping -c 1 8.8.8.8 &> /dev/null; then
echo -e " ${GREEN}${NC} Internet connectivity OK"
else
echo -e " ${YELLOW}${NC} No internet connectivity (needed for apt)"
((WARNINGS++))
fi
echo ""
# ============== SUMMARY ==============
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
if [ $ISSUES -eq 0 ]; then
echo -e "${GREEN}✓ System is compatible - ready for installation${NC}"
if [ $WARNINGS -gt 0 ]; then
echo -e "${YELLOW}$WARNINGS warning(s) - review above${NC}"
fi
echo ""
echo -e "Next step: ${BLUE}sudo bash install.sh${NC}"
exit 0
else
echo -e "${RED}$ISSUES issue(s) found - cannot proceed${NC}"
echo ""
echo "Fix the issues above and try again."
exit 1
fi
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# Installation script for ZMB Webui Backend on Raspberry Pi
# Run as root: sudo bash install.sh
set -e
echo "=== ZMB Webui Backend Installation ==="
echo ""
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "ERROR: This script must be run as root (use: sudo bash install.sh)"
exit 1
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Configuration
INSTALL_PATH="/opt/zmb-webui"
VENV_PATH="${INSTALL_PATH}/venv"
SYSTEMD_USER="root"
echo -e "${YELLOW}Step 1: Checking prerequisites & architecture${NC}"
# Detect architecture
ARCH=$(uname -m)
echo " - Architecture: $ARCH"
case $ARCH in
aarch64) ARCH_NAME="ARM64 (Raspberry Pi)" ;;
x86_64) ARCH_NAME="AMD64 (64-bit)" ;;
i686) ARCH_NAME="x86 (32-bit)" ;;
*) ARCH_NAME="$ARCH (unknown)" ;;
esac
echo "$ARCH_NAME"
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
OS_NAME="${NAME:-Unknown}"
echo " - OS: $OS_NAME"
else
echo -e "${YELLOW}WARNING: Could not detect OS${NC}"
fi
echo " - Python 3.11+"
python3 --version || { echo -e "${RED}ERROR: Python 3.11+ required${NC}"; exit 1; }
echo " - ZFS tools"
which zpool > /dev/null || { echo -e "${RED}ERROR: zpool not found${NC}"; exit 1; }
which zfs > /dev/null || { echo -e "${RED}ERROR: zfs not found${NC}"; exit 1; }
echo " - pip"
python3 -m pip --version > /dev/null || { echo -e "${RED}ERROR: pip not found${NC}"; exit 1; }
echo -e "${GREEN}✓ Prerequisites OK${NC}"
echo ""
echo -e "${YELLOW}Step 2: Creating installation directory${NC}"
mkdir -p "${INSTALL_PATH}"
cp -r . "${INSTALL_PATH}/backend"
echo -e "${GREEN}✓ Backend copied to ${INSTALL_PATH}/backend${NC}"
echo ""
echo -e "${YELLOW}Step 3: Creating Python virtual environment${NC}"
python3 -m venv "${VENV_PATH}"
source "${VENV_PATH}/bin/activate"
echo -e "${GREEN}✓ Virtual environment created${NC}"
echo ""
echo -e "${YELLOW}Step 4: Installing dependencies${NC}"
pip install --upgrade pip setuptools wheel > /dev/null
pip install -r "${INSTALL_PATH}/backend/requirements.txt"
echo -e "${GREEN}✓ Dependencies installed${NC}"
echo ""
echo -e "${YELLOW}Step 5: Setting up default admin user${NC}"
cd "${INSTALL_PATH}/backend"
python3 << EOF
import sys
sys.path.insert(0, '.')
from services.auth import auth_service
# Check if admin exists
if 'admin' not in auth_service.users:
print("Creating default admin user...")
auth_service.add_user('admin', 'admin123')
print("✓ Admin user created (username: admin, password: admin123)")
print("⚠️ CHANGE PASSWORD IMMEDIATELY!")
else:
print("✓ Admin user already exists")
EOF
echo ""
echo -e "${YELLOW}Step 6: Installing systemd service${NC}"
cp deploy/zmb-webui-backend.service /etc/systemd/system/
systemctl daemon-reload
echo -e "${GREEN}✓ Systemd service installed${NC}"
echo ""
echo -e "${YELLOW}Step 7: Setting permissions${NC}"
chown -R root:root "${INSTALL_PATH}"
chmod 750 "${INSTALL_PATH}/backend"
chmod 640 "${INSTALL_PATH}/backend/config/users.json"
echo -e "${GREEN}✓ Permissions set${NC}"
echo ""
echo -e "${GREEN}=== Installation Complete ===${NC}"
echo ""
echo "Next steps:"
echo " 1. Review and update admin password:"
echo " systemctl start zmb-webui-backend"
echo " curl http://localhost:8000/health"
echo ""
echo " 2. Enable service to start on boot:"
echo " systemctl enable zmb-webui-backend"
echo ""
echo " 3. Check logs:"
echo " journalctl -u zmb-webui-backend -f"
echo ""
echo " 4. Test API:"
echo " curl -X POST http://localhost:8000/api/auth/login \\"
echo " -H 'Content-Type: application/json' \\"
echo " -d '{\"username\":\"admin\",\"password\":\"admin123\"}'"
echo ""
+201
View File
@@ -0,0 +1,201 @@
"""
ZMB Webui API
FastAPI backend for ZFS pool management
"""
import asyncio
import json
import logging
import sys
from pathlib import Path
from typing import Set
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
# Add backend to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from routers import auth, pools, datasets, snapshots, navigator, identities, shares, system
from services.zfs_runner import zfs_runner
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="ZMB Webui API",
description="API for managing ZFS pools, datasets, and snapshots",
version="1.0.0"
)
# CORS middleware (adjust origins for production!)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Change to specific origins in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Connected WebSocket clients
ws_clients: Set[WebSocket] = set()
async def ws_broadcast(message: dict):
"""Broadcast JSON message to all connected WebSocket clients"""
if not ws_clients:
return
dead = set()
data = json.dumps(message)
for ws in ws_clients:
try:
await ws.send_text(data)
except Exception:
dead.add(ws)
ws_clients.difference_update(dead)
async def pool_status_broadcaster():
"""Background task: broadcast pool status every 30s"""
while True:
await asyncio.sleep(30)
try:
pools_data = zfs_runner.list_pools()
if pools_data:
await ws_broadcast({"type": "pool_status", "data": pools_data})
except Exception as e:
logger.warning(f"WS broadcaster error: {e}")
@app.on_event("startup")
async def startup_event():
asyncio.create_task(pool_status_broadcaster())
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
ws_clients.add(websocket)
try:
# Send initial pool status immediately on connect
try:
pools_data = zfs_runner.list_pools()
await websocket.send_text(json.dumps({"type": "pool_status", "data": pools_data}))
except Exception:
pass
# Keep alive
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
ws_clients.discard(websocket)
# Include routers (must be before static files mounting)
app.include_router(auth.router)
app.include_router(pools.router)
app.include_router(datasets.router)
app.include_router(snapshots.router)
app.include_router(navigator.router)
app.include_router(identities.router)
app.include_router(shares.router)
app.include_router(system.router)
# Health check endpoint (no auth required)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "version": "1.0.0"}
# Status endpoint - check if ZFS is available (no auth required)
@app.get("/api/status")
async def status_check():
"""Check system status (ZFS availability)"""
# Try to list pools to see if ZFS is available
pools = zfs_runner.list_pools()
zfs_available = len(pools) >= 0 # list_pools returns [] if ZFS unavailable
# More accurate: check if zpool command works
import subprocess
try:
result = subprocess.run(["zpool", "list"], capture_output=True, timeout=5)
zfs_available = result.returncode == 0
except Exception:
zfs_available = False
return {
"status": "healthy",
"zfs_available": zfs_available,
"version": "1.0.0"
}
# Root endpoint - API info only
@app.get("/")
async def root():
"""API info"""
return {
"name": "ZMB Webui API",
"version": "1.0.0",
"docs": "/docs",
"frontend": "http://192.168.1.179:3000"
}
# Error handler
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Global exception handler"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"}
)
if __name__ == "__main__":
import uvicorn
logger.info("Starting ZMB Webui API")
logger.info("Available endpoints:")
logger.info(" POST /api/auth/login - Login with username/password")
logger.info(" GET /api/pools - List all pools")
logger.info(" GET /api/pools/{name} - Get pool details")
logger.info(" POST /api/pools/{name}/scrub - Start scrub")
logger.info(" GET /api/datasets - List datasets")
logger.info(" POST /api/datasets - Create dataset")
logger.info(" DELETE /api/datasets/{name} - Delete dataset")
logger.info(" GET /api/snapshots - List snapshots")
logger.info(" POST /api/snapshots - Create snapshot")
logger.info(" DELETE /api/snapshots/{name} - Delete snapshot")
logger.info(" POST /api/snapshots/rollback - Rollback to snapshot")
logger.info(" GET /api/navigator/browse - Browse directory")
logger.info(" GET /api/navigator/read - Read file")
logger.info(" GET /api/navigator/download - Download file")
logger.info(" POST /api/navigator/upload - Upload file")
logger.info(" POST /api/navigator/create - Create file")
logger.info(" POST /api/navigator/mkdir - Create directory")
logger.info(" POST /api/navigator/rename - Rename file")
logger.info(" DELETE /api/navigator/delete - Delete file/directory")
logger.info(" GET /api/navigator/space - Get space usage")
logger.info("")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=False,
workers=1
)
+240
View File
@@ -0,0 +1,240 @@
"""
ZMB Webui API - aiohttp version (Low-RAM)
Single service: API + Static Files + WebSocket
"""
import asyncio
import json
import logging
import sys
from pathlib import Path
from typing import Set
from aiohttp import web
from aiohttp.web_runner import AppRunner, TCPSite
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent))
from services.zfs_runner import zfs_runner
from services.auth import auth_service
from routers_aiohttp import setup_all_routes
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# WebSocket clients for broadcasting
ws_clients: Set[web.WebSocketResponse] = set()
async def ws_broadcast(data: dict):
"""Broadcast message to all connected WebSocket clients"""
message = json.dumps(data)
dead_clients = set()
for ws in ws_clients:
try:
if not ws.is_closed():
await ws.send_str(message)
else:
dead_clients.add(ws)
except Exception as e:
logger.error(f"WebSocket broadcast error: {e}")
dead_clients.add(ws)
# Clean up dead connections
for ws in dead_clients:
ws_clients.discard(ws)
async def pool_status_broadcaster():
"""Background task: broadcast pool status every 30 seconds"""
while True:
try:
await asyncio.sleep(30)
pools = zfs_runner.list_pools()
if pools:
await ws_broadcast({
"type": "pool_status",
"pools": pools
})
except Exception as e:
logger.error(f"Pool status broadcast error: {e}")
async def startup(app):
"""Startup handler"""
logger.info("Starting ZMB Webui API (aiohttp)")
app['broadcaster_task'] = asyncio.create_task(pool_status_broadcaster())
async def shutdown(app):
"""Shutdown handler"""
logger.info("Shutting down ZMB Webui API")
if 'broadcaster_task' in app:
app['broadcaster_task'].cancel()
# Close all WebSocket connections
for ws in ws_clients:
await ws.close()
async def handle_websocket(request):
"""WebSocket endpoint for live pool updates"""
ws = web.WebSocketResponse()
await ws.prepare(request)
ws_clients.add(ws)
try:
# Send initial pool status
pools = zfs_runner.list_pools()
await ws.send_json({
"type": "initial",
"pools": pools
})
# Keep connection open
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
# Echo back (optional)
pass
elif msg.type == web.WSMsgType.ERROR:
logger.error(f'WebSocket error: {ws.exception()}')
finally:
ws_clients.discard(ws)
await ws.close()
return ws
async def handle_status(request):
"""System status endpoint (no auth required)"""
# Check if ZFS is available
import subprocess
try:
result = subprocess.run(["zpool", "list"], capture_output=True, timeout=5)
zfs_available = result.returncode == 0
except Exception:
zfs_available = False
return web.json_response({
"status": "healthy",
"zfs_available": zfs_available,
"version": "1.0.0"
})
async def handle_health(request):
"""Health check endpoint"""
return web.json_response({
"status": "healthy",
"version": "1.0.0"
})
async def handle_static(request):
"""Serve static files (HTML, JS, CSS)"""
path = request.match_info['path']
# Security: prevent directory traversal
if '..' in path or path.startswith('/'):
return web.Response(status=400, text="Invalid path")
# Try to find file in frontend/out directory
static_dir = Path(__file__).parent.parent / "frontend" / "out"
file_path = static_dir / path
# Ensure file is within static_dir
try:
file_path.resolve().relative_to(static_dir.resolve())
except ValueError:
return web.Response(status=403, text="Forbidden")
if file_path.exists() and file_path.is_file():
return web.FileResponse(file_path)
# If requesting a directory or file not found, try index.html (SPA routing)
index_path = static_dir / "index.html"
if index_path.exists():
return web.FileResponse(index_path)
return web.Response(status=404, text="Not found")
async def handle_root(request):
"""Root endpoint - serve index.html"""
static_dir = Path(__file__).parent.parent / "frontend" / "out"
index_path = static_dir / "index.html"
if index_path.exists():
return web.FileResponse(index_path)
return web.json_response({
"name": "ZMB Webui API",
"version": "1.0.0",
"docs": "/api/docs",
"login": "/login"
})
def create_app():
"""Create and configure the aiohttp application"""
app = web.Application()
# Store WebSocket clients and broadcaster task
app['ws_clients'] = ws_clients
# Startup/Shutdown handlers
app.on_startup.append(startup)
app.on_shutdown.append(shutdown)
# Routes
app.router.add_get('/health', handle_health)
app.router.add_get('/api/status', handle_status)
app.router.add_get('/ws', handle_websocket)
# API Routes (all routers)
setup_all_routes(app)
# Static file serving (must be last - catch-all)
app.router.add_get('/{path_info:.*}', handle_static)
app.router.add_get('/', handle_root)
return app
async def main():
"""Main entry point"""
app = create_app()
runner = AppRunner(app)
await runner.setup()
# Start server on port 8000
site = TCPSite(runner, '0.0.0.0', 8000)
await site.start()
logger.info("ZMB Webui API listening on http://0.0.0.0:8000")
logger.info("Available endpoints:")
logger.info(" GET /health - Health check")
logger.info(" GET /api/status - System status (ZFS available)")
logger.info(" POST /api/auth/login - Login with username/password")
logger.info(" GET /api/pools - List all pools")
logger.info(" GET /api/datasets - List datasets")
logger.info(" GET /api/snapshots - List snapshots")
logger.info(" GET /ws - WebSocket (live updates)")
logger.info(" GET /* (except /api) - Static files (HTML/CSS/JS)")
logger.info("")
# Keep running
try:
await asyncio.Event().wait()
except KeyboardInterrupt:
logger.info("Shutting down...")
await runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
User management CLI tool for ZMB Webui
Usage:
python manage_users.py add <username> [password]
python manage_users.py list
python manage_users.py delete <username>
python manage_users.py change-password <username> [password]
"""
import sys
import getpass
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from services.auth import auth_service
def add_user(username: str, password: str = None):
"""Add new user"""
if not password:
password = getpass.getpass(f"Enter password for {username}: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
print("ERROR: Passwords don't match")
return False
try:
auth_service.add_user(username, password)
print(f"✓ User '{username}' created successfully")
return True
except Exception as e:
print(f"ERROR: {e}")
return False
def list_users():
"""List all users"""
if not auth_service.users:
print("No users found")
return True
print("Users:")
print("-" * 40)
for username, user_data in auth_service.users.items():
disabled = " (disabled)" if user_data.get("disabled") else ""
print(f" {username}{disabled}")
print("-" * 40)
return True
def delete_user(username: str):
"""Delete user"""
if username not in auth_service.users:
print(f"ERROR: User '{username}' not found")
return False
confirm = input(f"Are you sure you want to delete '{username}'? (y/N): ")
if confirm.lower() != 'y':
print("Cancelled")
return False
del auth_service.users[username]
auth_service._save_users()
print(f"✓ User '{username}' deleted")
return True
def change_password(username: str, password: str = None):
"""Change user password"""
if username not in auth_service.users:
print(f"ERROR: User '{username}' not found")
return False
if not password:
password = getpass.getpass(f"Enter new password for {username}: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
print("ERROR: Passwords don't match")
return False
auth_service.users[username]["hashed_password"] = auth_service.get_password_hash(password)
auth_service._save_users()
print(f"✓ Password changed for '{username}'")
return True
def main():
if len(sys.argv) < 2:
print(__doc__)
return 1
command = sys.argv[1]
if command == "add":
if len(sys.argv) < 3:
print("Usage: manage_users.py add <username> [password]")
return 1
username = sys.argv[2]
password = sys.argv[3] if len(sys.argv) > 3 else None
return 0 if add_user(username, password) else 1
elif command == "list":
return 0 if list_users() else 1
elif command == "delete":
if len(sys.argv) < 3:
print("Usage: manage_users.py delete <username>")
return 1
username = sys.argv[2]
return 0 if delete_user(username) else 1
elif command == "change-password":
if len(sys.argv) < 3:
print("Usage: manage_users.py change-password <username> [password]")
return 1
username = sys.argv[2]
password = sys.argv[3] if len(sys.argv) > 3 else None
return 0 if change_password(username, password) else 1
else:
print(f"ERROR: Unknown command '{command}'")
print(__doc__)
return 1
if __name__ == "__main__":
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
from .pool import Pool, PoolStatus, PoolHealth, Vdev
from .dataset import Dataset, DatasetType
from .snapshot import Snapshot
from .auth import Token, TokenData, User
__all__ = ["Pool", "PoolStatus", "PoolHealth", "Vdev", "Dataset", "DatasetType", "Snapshot", "Token", "TokenData", "User"]
+16
View File
@@ -0,0 +1,16 @@
from pydantic import BaseModel
from typing import Optional
class User(BaseModel):
username: str
disabled: Optional[bool] = None
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
from enum import Enum
class DatasetType(str, Enum):
FILESYSTEM = "filesystem"
VOLUME = "volume"
SNAPSHOT = "snapshot"
class Dataset(BaseModel):
name: str
type: DatasetType
used: int # bytes
avail: int # bytes
refer: int # bytes (how much data is actually in dataset)
mountpoint: Optional[str] = None
compression: Optional[str] = None
quota: Optional[int] = None
reservation: Optional[int] = None
class Config:
json_schema_extra = {
"example": {
"name": "tank/share",
"type": "filesystem",
"used": 2040109465,
"avail": 1825361511,
"refer": 1900000000,
"mountpoint": "/tank/share",
"compression": "lz4",
"quota": None,
"reservation": None
}
}
+96
View File
@@ -0,0 +1,96 @@
from pydantic import BaseModel
from typing import Optional, List
from enum import Enum
class PoolHealth(str, Enum):
ONLINE = "ONLINE"
DEGRADED = "DEGRADED"
FAULTED = "FAULTED"
OFFLINE = "OFFLINE"
UNAVAIL = "UNAVAIL"
class Vdev(BaseModel):
name: str
state: str
read: int = 0 # Read error count
write: int = 0 # Write error count
cksum: int = 0 # Checksum error count
children: List["Vdev"] = []
class Config:
json_schema_extra = {
"example": {
"name": "mirror-0",
"state": "ONLINE",
"read": 0,
"write": 0,
"cksum": 0,
"children": [
{"name": "sda", "state": "ONLINE", "read": 0, "write": 0, "cksum": 0},
{"name": "sdb", "state": "ONLINE", "read": 0, "write": 0, "cksum": 0}
]
}
}
# Update forward reference for recursive type
Vdev.model_rebuild()
class Pool(BaseModel):
name: str
size: int # bytes
alloc: int # bytes
free: int # bytes
fragmentation: str # percentage
capacity: str # percentage
health: PoolHealth
class Config:
json_schema_extra = {
"example": {
"name": "tank",
"size": 3865470976,
"alloc": 2040109465,
"free": 1825361511,
"fragmentation": "0%",
"capacity": "52%",
"health": "ONLINE"
}
}
class PoolStatus(BaseModel):
name: str
state: Optional[str] = None
health: PoolHealth
scan: Optional[str] = None
errors: Optional[str] = None
last_scrub: Optional[str] = None
vdevs: List[Vdev] = []
class Config:
json_schema_extra = {
"example": {
"name": "tank",
"state": "ONLINE",
"health": "ONLINE",
"scan": "scrub in progress since Sat Apr 14 10:30:00 2026",
"errors": "No known data errors",
"vdevs": [
{
"name": "mirror-0",
"state": "ONLINE",
"read": 0,
"write": 0,
"cksum": 0,
"children": [
{"name": "sda", "state": "ONLINE", "read": 0, "write": 0, "cksum": 0},
{"name": "sdb", "state": "ONLINE", "read": 0, "write": 0, "cksum": 0}
]
}
]
}
}
+24
View File
@@ -0,0 +1,24 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class Snapshot(BaseModel):
name: str
dataset: str # parent dataset
created: int # Unix timestamp
used: int # bytes
referenced: int # bytes
creation_datetime: Optional[str] = None # ISO format for API
class Config:
json_schema_extra = {
"example": {
"name": "tank/share@2026-04-14-120000",
"dataset": "tank/share",
"created": 1713089400,
"used": 0,
"referenced": 1900000000,
"creation_datetime": "2026-04-14T12:00:00Z"
}
}
+4
View File
@@ -0,0 +1,4 @@
aiohttp==3.9.1
python-pam==1.8.5
python-jose==3.3.0
cryptography==41.0.7
+13
View File
@@ -0,0 +1,13 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
pydantic>=2.6.0
pydantic-settings>=2.2.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
aiofiles>=23.2.0
websockets>=12.0
httpx>=0.26.0
pyyaml>=6.0.0
python-pam>=2.0.0
psutil>=5.9.0
View File
+53
View File
@@ -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}
+126
View File
@@ -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))
+280
View File
@@ -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))
+408
View File
@@ -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)
}
+102
View File
@@ -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"}
+209
View File
@@ -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))
+113
View File
@@ -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))
+210
View File
@@ -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
+382
View File
@@ -0,0 +1,382 @@
"""
aiohttp routers for ZMB Webui API
Simplified compared to FastAPI
"""
import json
import logging
from aiohttp import web
from datetime import datetime, timedelta
from services.auth import auth_service
from services.zfs_runner import zfs_runner
logger = logging.getLogger(__name__)
def verify_token(request):
"""Extract and verify JWT token from Authorization header"""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return None
token = auth_header[7:] # Remove 'Bearer ' prefix
username = auth_service.verify_token(token)
return username
def require_auth(func):
"""Decorator to require authentication"""
async def wrapper(request):
username = verify_token(request)
if not username:
return web.json_response(
{"detail": "Invalid token"},
status=401
)
request['username'] = username
return await func(request)
wrapper.__name__ = func.__name__ # Preserve function name
return wrapper
# ============ AUTH ROUTER ============
class AuthRouter:
@staticmethod
async def login(request):
"""POST /api/auth/login - Login with username/password"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
username = data.get('username')
password = data.get('password')
if not username or not password:
return web.json_response(
{"detail": "Username and password required"},
status=400
)
user = auth_service.authenticate_user(username, password)
if not user:
return web.json_response(
{"detail": "Invalid credentials"},
status=401
)
token = auth_service.create_access_token(username)
return web.json_response({
"access_token": token,
"token_type": "bearer"
})
@staticmethod
async def verify(request):
"""POST /api/auth/verify - Verify token"""
username = verify_token(request)
if not username:
return web.json_response(
{"detail": "Invalid token"},
status=401
)
return web.json_response({
"valid": True,
"username": username
})
auth = AuthRouter()
# ============ POOLS ROUTER ============
class PoolsRouter:
@staticmethod
@require_auth
async def list_pools(request):
"""GET /api/pools - List all pools"""
try:
pools = zfs_runner.list_pools()
return web.json_response(pools)
except Exception as e:
logger.error(f"Error listing pools: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def get_pool_status(request):
"""GET /api/pools/{name} - Get pool status"""
name = request.match_info['name']
try:
status = zfs_runner.get_pool_status(name)
return web.json_response(status)
except Exception as e:
logger.error(f"Error getting pool status: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def start_scrub(request):
"""POST /api/pools/{name}/scrub - Start scrub"""
name = request.match_info['name']
try:
zfs_runner.start_scrub(name)
return web.json_response({"status": "scrub started"})
except Exception as e:
logger.error(f"Error starting scrub: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
pools = PoolsRouter()
# ============ DATASETS ROUTER ============
class DatasetsRouter:
@staticmethod
@require_auth
async def list_datasets(request):
"""GET /api/datasets - List datasets"""
pool = request.query.get('pool', 'tank')
try:
datasets = zfs_runner.list_datasets(pool)
return web.json_response(datasets)
except Exception as e:
logger.error(f"Error listing datasets: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def update_dataset(request):
"""PATCH /api/datasets/{name} - Update dataset properties"""
name = request.match_info['name']
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
try:
zfs_runner.set_dataset_properties(name, data)
return web.json_response({"status": "updated"})
except Exception as e:
logger.error(f"Error updating dataset: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
datasets = DatasetsRouter()
# ============ SNAPSHOTS ROUTER ============
class SnapshotsRouter:
@staticmethod
@require_auth
async def list_snapshots(request):
"""GET /api/snapshots - List snapshots"""
dataset = request.query.get('dataset')
limit = int(request.query.get('limit', 50))
try:
snapshots = zfs_runner.list_snapshots(dataset, limit)
return web.json_response(snapshots)
except Exception as e:
logger.error(f"Error listing snapshots: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def create_snapshot(request):
"""POST /api/snapshots - Create snapshot"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
dataset = data.get('dataset')
snapshot_name = data.get('snapshot_name')
if not dataset or not snapshot_name:
return web.json_response(
{"detail": "dataset and snapshot_name required"},
status=400
)
try:
zfs_runner.create_snapshot(dataset, snapshot_name)
return web.json_response({"status": "created"})
except Exception as e:
logger.error(f"Error creating snapshot: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def rollback_snapshot(request):
"""POST /api/snapshots/rollback - Rollback to snapshot"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
snapshot = data.get('snapshot')
if not snapshot:
return web.json_response(
{"detail": "snapshot required"},
status=400
)
try:
zfs_runner.rollback_snapshot(snapshot)
return web.json_response({"status": "rolled back"})
except Exception as e:
logger.error(f"Error rolling back snapshot: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def delete_snapshot(request):
"""DELETE /api/snapshots/{name} - Delete snapshot"""
name = request.match_info['name']
try:
zfs_runner.delete_snapshot(name)
return web.json_response({"status": "deleted"})
except Exception as e:
logger.error(f"Error deleting snapshot: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
snapshots = SnapshotsRouter()
# ============ FILES ROUTER ============
class FilesRouter:
@staticmethod
@require_auth
async def browse(request):
"""GET /api/files/browse - Browse directory"""
path = request.query.get('path', '/')
try:
items = zfs_runner.browse_directory(path)
return web.json_response(items)
except Exception as e:
logger.error(f"Error browsing directory: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def read(request):
"""GET /api/files/read - Read file"""
path = request.query.get('path')
if not path:
return web.json_response(
{"detail": "path required"},
status=400
)
try:
content = zfs_runner.read_file(path)
return web.Response(text=content, content_type='text/plain')
except Exception as e:
logger.error(f"Error reading file: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
files = FilesRouter()
# ============ SHARES ROUTER ============
class SharesRouter:
@staticmethod
@require_auth
async def list_samba_shares(request):
"""GET /api/shares/samba - List Samba shares"""
# TODO: Implement
return web.json_response([])
@staticmethod
@require_auth
async def list_nfs_shares(request):
"""GET /api/shares/nfs - List NFS shares"""
# TODO: Implement
return web.json_response([])
shares = SharesRouter()
def setup_all_routes(app):
"""Setup all routes"""
# Auth routes
app.router.add_post('/api/auth/login', auth.login)
app.router.add_post('/api/auth/verify', auth.verify)
# Pool routes
app.router.add_get('/api/pools', pools.list_pools)
app.router.add_get('/api/pools/{name}', pools.get_pool_status)
app.router.add_post('/api/pools/{name}/scrub', pools.start_scrub)
# Dataset routes
app.router.add_get('/api/datasets', datasets.list_datasets)
app.router.add_patch('/api/datasets/{name}', datasets.update_dataset)
# Snapshot routes
app.router.add_get('/api/snapshots', snapshots.list_snapshots)
app.router.add_post('/api/snapshots', snapshots.create_snapshot)
app.router.add_post('/api/snapshots/rollback', snapshots.rollback_snapshot)
app.router.add_delete('/api/snapshots/{name}', snapshots.delete_snapshot)
# File routes
app.router.add_get('/api/files/browse', files.browse)
app.router.add_get('/api/files/read', files.read)
# Share routes
app.router.add_get('/api/shares/samba', shares.list_samba_shares)
app.router.add_get('/api/shares/nfs', shares.list_nfs_shares)
View File
+89
View File
@@ -0,0 +1,89 @@
"""
JWT Authentication Service
Handles user login via PAM (Linux system users), token generation, and verification
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("ZFS_SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 8
# Try to import PAM for system authentication
try:
import pam
PAM_AVAILABLE = True
except ImportError:
PAM_AVAILABLE = False
logger.warning("python-pam not installed, PAM authentication unavailable")
class AuthService:
def __init__(self):
"""Initialize auth service with PAM (Linux system users)"""
if PAM_AVAILABLE:
logger.info("Using PAM authentication (Linux system users)")
else:
logger.error("PAM not available - install python-pam for authentication")
def authenticate_user(self, username: str, password: str) -> Optional[dict]:
"""
Authenticate user via PAM (Linux system users like 'pi', 'root')
Returns user data if valid, None otherwise
"""
if not PAM_AVAILABLE:
logger.error("PAM not available")
return None
try:
p = pam.pam()
if p.authenticate(username, password):
logger.info(f"User {username} authenticated via PAM")
return {
"username": username,
"source": "pam"
}
else:
logger.warning(f"PAM authentication failed for user {username}: {p.reason}")
return None
except Exception as e:
logger.error(f"PAM authentication error: {e}")
return None
def create_access_token(self, username: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
if expires_delta is None:
expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
expire = datetime.utcnow() + expires_delta
to_encode = {"sub": username, "exp": expire}
try:
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
except Exception as e:
logger.error(f"Failed to create token: {e}")
raise
def verify_token(self, token: str) -> Optional[str]:
"""Verify JWT token and return username"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
# Global instance
auth_service = AuthService()
+89
View File
@@ -0,0 +1,89 @@
"""
JWT Authentication Service
Handles user login via PAM (Linux system users), token generation, and verification
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("ZFS_SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 8
# Try to import PAM for system authentication
try:
import pam
PAM_AVAILABLE = True
except ImportError:
PAM_AVAILABLE = False
logger.warning("python-pam not installed, PAM authentication unavailable")
class AuthService:
def __init__(self):
"""Initialize auth service with PAM (Linux system users)"""
if PAM_AVAILABLE:
logger.info("Using PAM authentication (Linux system users)")
else:
logger.error("PAM not available - install python-pam for authentication")
def authenticate_user(self, username: str, password: str) -> Optional[dict]:
"""
Authenticate user via PAM (Linux system users like 'pi', 'root')
Returns user data if valid, None otherwise
"""
if not PAM_AVAILABLE:
logger.error("PAM not available")
return None
try:
p = pam.pam()
if p.authenticate(username, password):
logger.info(f"User {username} authenticated via PAM")
return {
"username": username,
"source": "pam"
}
else:
logger.warning(f"PAM authentication failed for user {username}: {p.reason}")
return None
except Exception as e:
logger.error(f"PAM authentication error: {e}")
return None
def create_access_token(self, username: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
if expires_delta is None:
expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
expire = datetime.utcnow() + expires_delta
to_encode = {"sub": username, "exp": expire}
try:
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
except Exception as e:
logger.error(f"Failed to create token: {e}")
raise
def verify_token(self, token: str) -> Optional[str]:
"""Verify JWT token and return username"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
# Global instance
auth_service = AuthService()
+644
View File
@@ -0,0 +1,644 @@
"""
File Manager Service Browse, upload, download files in /tank/share
Similar to cockpit-files but optimized for ZFS shares
"""
import os
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
from stat import filemode
from datetime import datetime
import stat
logger = logging.getLogger(__name__)
# Root directory for file operations (ZFS share)
BASE_PATH = Path("/tank/share")
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB max upload
class FileInfo:
"""File/Directory information"""
def __init__(self, path: Path, base_path: Optional[Path] = None):
self.path = path
self.base_path = base_path or BASE_PATH
self.name = path.name
self.is_link = path.is_symlink()
self.link_target = None
try:
# For symlinks, read the target
if self.is_link:
self.link_target = os.readlink(path)
# Use lstat for symlink itself, not the target
self.stat = path.lstat()
else:
self.stat = path.stat()
self.is_dir = path.is_dir()
self.size = self.stat.st_size
self.modified = self.stat.st_mtime
self.mode = filemode(self.stat.st_mode)
self.uid = self.stat.st_uid
self.gid = self.stat.st_gid
self.error = None
except Exception as e:
self.is_dir = False
self.size = 0
self.modified = 0
self.mode = "---------"
self.uid = 0
self.gid = 0
self.error = str(e)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dict for JSON response"""
result = {
"name": self.name,
"path": str(self.path.relative_to(self.base_path)),
"is_dir": self.is_dir,
"is_link": self.is_link,
"size": self.size,
"modified": self.modified,
"modified_iso": datetime.fromtimestamp(self.modified).isoformat(),
"permissions": self.mode,
"uid": self.uid,
"gid": self.gid,
"error": self.error
}
if self.link_target:
result["link_target"] = self.link_target
return result
class FileManager:
def __init__(self, base_path: Optional[Path] = None):
self.base_path = base_path or BASE_PATH
# Ensure base path exists
if not self.base_path.exists():
logger.warning(f"Base path does not exist: {self.base_path}")
self.base_path.mkdir(parents=True, exist_ok=True)
def _resolve_path(self, rel_path: str) -> Optional[Path]:
"""
Resolve and validate path (prevent directory traversal attacks)
Returns absolute path if safe, None otherwise
"""
try:
if not rel_path:
return self.base_path
# Remove leading slash and resolve
rel_path = rel_path.lstrip("/")
target = (self.base_path / rel_path).resolve()
# Ensure target is within base_path
if not str(target).startswith(str(self.base_path.resolve())):
logger.warning(f"Path traversal attempt: {rel_path}")
return None
return target
except Exception as e:
logger.error(f"Path resolution error: {e}")
return None
def list_directory(self, rel_path: str = "") -> Dict[str, Any]:
"""
List directory contents
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
if not target.is_dir():
return {"error": "Not a directory"}
try:
entries = []
for item in sorted(target.iterdir()):
entries.append(FileInfo(item, self.base_path).to_dict())
return {
"path": rel_path or "/",
"entries": entries,
"total": len(entries)
}
except PermissionError:
return {"error": "Permission denied"}
except Exception as e:
logger.error(f"Error listing directory: {e}")
return {"error": str(e)}
def list_subdirectories(self, rel_path: str = "") -> Dict[str, Any]:
"""
List only subdirectories of a path (for tree sidebar navigation).
Returns: { dirs: [{name, path, has_children}] }
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
if not target.is_dir():
return {"error": "Not a directory"}
try:
dirs = []
for item in sorted(target.iterdir()):
try:
if not item.is_dir():
continue
# Check if has any subdirectory children
has_children = any(
c.is_dir() for c in item.iterdir()
)
except PermissionError:
has_children = False
dirs.append({
"name": item.name,
"path": str(item.relative_to(self.base_path)),
"has_children": has_children
})
return {"path": rel_path or "/", "dirs": dirs}
except PermissionError:
return {"error": "Permission denied"}
except Exception as e:
logger.error(f"Error listing subdirectories: {e}")
return {"error": str(e)}
def get_file_info(self, rel_path: str) -> Dict[str, Any]:
"""
Get detailed info about file/directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
file_info = FileInfo(target, self.base_path).to_dict()
# Add directory listing if it's a directory
if target.is_dir():
try:
children = sorted(target.iterdir())
file_info["children_count"] = len(children)
except PermissionError:
file_info["children_count"] = -1
# Add file preview for text files
if target.is_file() and target.stat().st_size < 1024 * 100: # < 100KB
try:
if target.suffix in [".txt", ".log", ".md", ".json", ".yaml", ".yml"]:
with open(target, "r", encoding="utf-8", errors="replace") as f:
preview = f.read(1000)
file_info["preview"] = preview
except Exception as e:
logger.debug(f"Could not read preview: {e}")
return file_info
except Exception as e:
logger.error(f"Error getting file info: {e}")
return {"error": str(e)}
def read_file(self, rel_path: str, limit: Optional[int] = None) -> Dict[str, Any]:
"""
Read file content (with optional limit)
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "File not found"}
if not target.is_file():
return {"error": "Not a file"}
# Prevent reading huge files at once
if target.stat().st_size > 10 * 1024 * 1024: # > 10MB
return {
"error": "File too large",
"size": target.stat().st_size,
"message": "Use download endpoint for large files"
}
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
if limit:
content = f.read(limit)
else:
content = f.read()
return {
"path": rel_path,
"content": content,
"size": len(content),
"encoding": "utf-8"
}
except Exception as e:
logger.error(f"Error reading file: {e}")
return {"error": str(e)}
def create_file(self, rel_path: str, content: str = "") -> Dict[str, str]:
"""
Create new file or empty file
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
try:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
return {"error": "File already exists"}
with open(target, "w") as f:
f.write(content)
logger.info(f"Created file: {target}")
return {"status": "success", "path": rel_path}
except Exception as e:
logger.error(f"Error creating file: {e}")
return {"error": str(e)}
def delete_file_or_dir(self, rel_path: str, recursive: bool = False) -> Dict[str, str]:
"""
Delete file or directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
if target.is_file():
target.unlink()
logger.info(f"Deleted file: {target}")
return {"status": "success", "message": f"File deleted: {rel_path}"}
elif target.is_dir():
if not recursive and list(target.iterdir()):
return {"error": "Directory not empty"}
import shutil
shutil.rmtree(target)
logger.info(f"Deleted directory: {target}")
return {"status": "success", "message": f"Directory deleted: {rel_path}"}
return {"error": "Unknown error"}
except PermissionError:
return {"error": "Permission denied"}
except Exception as e:
logger.error(f"Error deleting: {e}")
return {"error": str(e)}
def rename_file(self, rel_path: str, new_name: str) -> Dict[str, str]:
"""
Rename file or directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
# Validate new name (no path separators)
if "/" in new_name or "\\" in new_name:
return {"error": "Invalid filename"}
try:
new_path = target.parent / new_name
if new_path.exists():
return {"error": "Target already exists"}
target.rename(new_path)
logger.info(f"Renamed {target} to {new_path}")
return {
"status": "success",
"old_path": rel_path,
"new_path": str(new_path.relative_to(self.base_path))
}
except Exception as e:
logger.error(f"Error renaming: {e}")
return {"error": str(e)}
def mkdir(self, rel_path: str) -> Dict[str, str]:
"""
Create directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if target.exists():
return {"error": "Directory already exists"}
try:
target.mkdir(parents=True, exist_ok=False)
logger.info(f"Created directory: {target}")
return {"status": "success", "path": rel_path}
except Exception as e:
logger.error(f"Error creating directory: {e}")
return {"error": str(e)}
def get_space_info(self) -> Dict[str, Any]:
"""
Get space usage of base path via ZFS or fallback to df
"""
try:
import subprocess
# Try to get space from ZFS first
result = subprocess.run(
["zfs", "list", "-H", "-p", "-o", "used,avail,refer", "tank/share"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
parts = result.stdout.strip().split()
if len(parts) >= 3:
used = int(parts[0])
available = int(parts[1])
return {
"used": used,
"available": available,
"actual": int(parts[2]),
"total": used + available
}
# Fallback to df if ZFS is not available
result = subprocess.run(
["df", "-B1", str(self.base_path)],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) >= 2:
# Parse df output: Filesystem 1B-blocks Used Available Use% Mounted on
parts = lines[1].split()
if len(parts) >= 4:
total = int(parts[1])
used = int(parts[2])
available = int(parts[3])
return {
"used": used,
"available": available,
"total": total
}
return {"error": "Could not get space info"}
except Exception as e:
logger.error(f"Error getting space info: {e}")
return {"error": str(e)}
def change_permissions(self, rel_path: str, mode: str) -> Dict[str, Any]:
"""
Change file/directory permissions (chmod)
mode: octal string like "755" or "644"
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
import subprocess
# Convert octal mode string to int
mode_int = int(mode, 8)
result = subprocess.run(
["chmod", mode, str(target)],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Changed permissions for {target} to {mode}")
return {
"status": "success",
"path": rel_path,
"permissions": mode
}
else:
logger.error(f"Failed to change permissions: {result.stderr.decode()}")
return {"error": f"Failed to change permissions: {result.stderr.decode()}"}
except Exception as e:
logger.error(f"Error changing permissions: {e}")
return {"error": str(e)}
def change_owner(self, rel_path: str, owner: str, group: Optional[str] = None) -> Dict[str, Any]:
"""
Change file/directory owner (chown)
owner: username or uid
group: groupname or gid (optional)
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
import subprocess
if group:
chown_spec = f"{owner}:{group}"
else:
chown_spec = owner
result = subprocess.run(
["chown", chown_spec, str(target)],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Changed owner for {target} to {chown_spec}")
return {
"status": "success",
"path": rel_path,
"owner": owner,
"group": group
}
else:
logger.error(f"Failed to change owner: {result.stderr.decode()}")
return {"error": f"Failed to change owner: {result.stderr.decode()}"}
except Exception as e:
logger.error(f"Error changing owner: {e}")
return {"error": str(e)}
def change_permissions_recursive(self, rel_path: str, mode: str) -> Dict[str, Any]:
"""
Change permissions recursively for directory and contents (chmod -R)
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
if not target.is_dir():
return {"error": "Path is not a directory"}
try:
import subprocess
result = subprocess.run(
["chmod", "-R", mode, str(target)],
capture_output=True,
timeout=30
)
if result.returncode == 0:
logger.info(f"Changed permissions recursively for {target} to {mode}")
return {
"status": "success",
"path": rel_path,
"permissions": mode,
"recursive": True
}
else:
logger.error(f"Failed to change permissions: {result.stderr.decode()}")
return {"error": f"Failed to change permissions: {result.stderr.decode()}"}
except Exception as e:
logger.error(f"Error changing permissions: {e}")
return {"error": str(e)}
def copy_file(self, src_rel: str, dst_rel: str, overwrite: bool = False) -> Dict[str, Any]:
"""
Copy file or directory
"""
src = self._resolve_path(src_rel)
dst = self._resolve_path(dst_rel)
if not src or not dst:
return {"error": "Invalid path"}
if not src.exists():
return {"error": "Source path not found"}
if dst.exists() and not overwrite:
return {"error": "Destination already exists"}
try:
import shutil
if src.is_file():
shutil.copy2(src, dst)
else:
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
logger.info(f"Copied {src} to {dst}")
return {
"status": "success",
"src": src_rel,
"dst": dst_rel
}
except Exception as e:
logger.error(f"Error copying: {e}")
return {"error": str(e)}
def move_file(self, src_rel: str, dst_rel: str, overwrite: bool = False) -> Dict[str, Any]:
"""
Move (rename) file or directory
"""
src = self._resolve_path(src_rel)
dst = self._resolve_path(dst_rel)
if not src or not dst:
return {"error": "Invalid path"}
if not src.exists():
return {"error": "Source path not found"}
if dst.exists() and not overwrite:
return {"error": "Destination already exists"}
try:
import shutil
if dst.exists() and overwrite:
if dst.is_dir():
shutil.rmtree(dst)
else:
dst.unlink()
shutil.move(str(src), str(dst))
logger.info(f"Moved {src} to {dst}")
return {
"status": "success",
"src": src_rel,
"dst": dst_rel
}
except Exception as e:
logger.error(f"Error moving: {e}")
return {"error": str(e)}
def search_files(self, query: str, search_path: str = "", max_results: int = 50) -> List[Dict[str, Any]]:
"""
Search for files by name (case-insensitive)
"""
target = self._resolve_path(search_path) if search_path else self.base_path
if not target or not target.exists():
return []
results = []
query_lower = query.lower()
try:
for root, dirs, files in target.walk():
# Search in directories
for d in sorted(dirs):
if query_lower in d.lower():
dir_path = Path(root) / d
results.append(FileInfo(dir_path, self.base_path).to_dict())
if len(results) >= max_results:
return results
# Search in files
for f in sorted(files):
if query_lower in f.lower():
file_path = Path(root) / f
results.append(FileInfo(file_path, self.base_path).to_dict())
if len(results) >= max_results:
return results
return results
except Exception as e:
logger.error(f"Error searching files: {e}")
return []
# Global instance
file_manager = FileManager()
+572
View File
@@ -0,0 +1,572 @@
"""
User and Group Management Service
Handle system users, groups, and PAM operations
"""
import subprocess
import logging
import pwd
import grp
from typing import List, Dict, Any, Optional
from pathlib import Path
try:
import spwd
except ImportError:
spwd = None
logger = logging.getLogger(__name__)
class IdentitiesManager:
"""Manage system users and groups"""
def list_users(self) -> List[Dict[str, Any]]:
"""List all system users"""
users = []
try:
# Use getpwall() which returns an iterator
import getpass
# Fallback: read /etc/passwd directly
result = subprocess.run(
["/usr/bin/getent", "passwd"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) < 7:
continue
try:
username = parts[0]
uid = int(parts[2])
gid = int(parts[3])
gecos = parts[4]
home = parts[5]
shell = parts[6]
users.append({
'username': username,
'uid': uid,
'gid': gid,
'gecos': gecos,
'home': home,
'shell': shell,
'locked': self._is_user_locked(username)
})
except Exception as e:
logger.warning(f"Error parsing user line: {e}")
return sorted(users, key=lambda x: x['uid'])
except Exception as e:
logger.error(f"Error listing users: {e}")
return []
def list_groups(self) -> List[Dict[str, Any]]:
"""List all system groups"""
groups = []
try:
# Use getent to read groups
result = subprocess.run(
["/usr/bin/getent", "group"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) < 4:
continue
try:
groupname = parts[0]
gid = int(parts[2])
members = [m.strip() for m in parts[3].split(',') if m.strip()]
groups.append({
'groupname': groupname,
'gid': gid,
'members': members
})
except Exception as e:
logger.warning(f"Error parsing group line: {e}")
return sorted(groups, key=lambda x: x['gid'])
except Exception as e:
logger.error(f"Error listing groups: {e}")
return []
def get_user_groups(self, username: str) -> List[str]:
"""Get all groups a user belongs to"""
try:
groups = []
for entry in grp.getall():
if username in entry.gr_mem:
groups.append(entry.gr_name)
# Also add primary group
try:
user_entry = pwd.getpwnam(username)
primary_group = grp.getgrgid(user_entry.pw_gid)
if primary_group.gr_name not in groups:
groups.append(primary_group.gr_name)
except KeyError:
pass
return sorted(groups)
except Exception as e:
logger.error(f"Error getting groups for {username}: {e}")
return []
def create_user(self, username: str, home_dir: Optional[str] = None,
shell: str = "/bin/bash", gecos: str = "") -> bool:
"""Create new system user"""
try:
# Build useradd command
cmd = ["/usr/sbin/useradd"]
if home_dir:
cmd.extend(["-d", home_dir])
else:
cmd.extend(["-d", f"/home/{username}"])
cmd.extend(["-s", shell])
if gecos:
cmd.extend(["-c", gecos])
cmd.extend(["-m", username]) # -m to create home directory
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0:
logger.info(f"User created: {username}")
return True
else:
logger.error(f"Failed to create user {username}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error creating user: {e}")
return False
def delete_user(self, username: str, remove_home: bool = True) -> bool:
"""Delete system user and Samba user"""
try:
# First delete Samba user if exists (using pdbedit for better handling)
try:
subprocess.run(
["/usr/bin/pdbedit", "-x", "-u", username],
capture_output=True,
timeout=10
)
except Exception:
pass # Samba not installed or user doesn't exist
# Delete system user
cmd = ["/usr/sbin/userdel"]
if remove_home:
cmd.append("-r")
cmd.append(username)
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0:
logger.info(f"User deleted: {username}")
return True
else:
logger.error(f"Failed to delete user {username}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error deleting user: {e}")
return False
def create_group(self, groupname: str) -> bool:
"""Create new system group"""
try:
result = subprocess.run(
["/usr/sbin/groupadd", groupname],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Group created: {groupname}")
return True
else:
logger.error(f"Failed to create group {groupname}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error creating group: {e}")
return False
def delete_group(self, groupname: str) -> bool:
"""Delete system group"""
try:
result = subprocess.run(
["/usr/sbin/groupdel", groupname],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Group deleted: {groupname}")
return True
else:
logger.error(f"Failed to delete group {groupname}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error deleting group: {e}")
return False
def add_user_to_group(self, username: str, groupname: str) -> bool:
"""Add user to group"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-aG", groupname, username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User {username} added to group {groupname}")
return True
else:
logger.error(f"Failed to add user to group: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error adding user to group: {e}")
return False
def remove_user_from_group(self, username: str, groupname: str) -> bool:
"""Remove user from group"""
try:
result = subprocess.run(
["/usr/sbin/gpasswd", "-d", username, groupname],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User {username} removed from group {groupname}")
return True
else:
logger.error(f"Failed to remove user from group: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error removing user from group: {e}")
return False
def change_password(self, username: str, password: str) -> bool:
"""Change user password via chpasswd"""
try:
# Use chpasswd for password changes
result = subprocess.run(
["/usr/sbin/chpasswd"],
input=f"{username}:{password}\n",
text=True,
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Password changed for {username}")
return True
else:
logger.error(f"Failed to change password: {result.stderr}")
return False
except Exception as e:
logger.error(f"Error changing password: {e}")
return False
def change_shell(self, username: str, shell: str) -> bool:
"""Change user shell"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-s", shell, username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Shell changed for {username} to {shell}")
return True
else:
logger.error(f"Failed to change shell: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error changing shell: {e}")
return False
def _is_user_locked(self, username: str) -> bool:
"""Check if user account is locked"""
if not spwd:
return False
try:
entry = spwd.getspnam(username)
return entry.sp_lstchg == 0 or entry.sp_max == 0
except (KeyError, PermissionError):
return False
except Exception as e:
logger.warning(f"Error checking lock status: {e}")
return False
def lock_user(self, username: str) -> bool:
"""Lock user account"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-L", username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User locked: {username}")
return True
else:
logger.error(f"Failed to lock user: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error locking user: {e}")
return False
def unlock_user(self, username: str) -> bool:
"""Unlock user account"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-U", username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User unlocked: {username}")
return True
else:
logger.error(f"Failed to unlock user: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error unlocking user: {e}")
return False
def set_samba_password(self, username: str, password: str) -> bool:
"""Set Samba password for user"""
try:
# Use smbpasswd to set Samba password
# -a flag: add/update user
# -s flag: read password from stdin
result = subprocess.run(
["/usr/bin/smbpasswd", "-a", "-s", username],
input=f"{password}\n{password}\n",
text=True,
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Samba password set for {username}")
return True
else:
logger.error(f"Failed to set Samba password: {result.stderr}")
return False
except FileNotFoundError:
logger.error("smbpasswd command not found - Samba not installed?")
return False
except Exception as e:
logger.error(f"Error setting Samba password: {e}")
return False
def get_login_history(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get recent login history using last command"""
import re
from datetime import datetime
logins = []
days_of_week = {'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'}
months = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12}
current_year = datetime.now().year
try:
result = subprocess.run(
["last", "-n", str(limit)],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
# Skip header, footer, and system entries
if 'wtmp' in line or 'begins' in line:
continue
try:
# Find the day-of-week anchor (reliable marker for date section)
tokens = line.split()
day_idx = -1
for i, token in enumerate(tokens):
if token in days_of_week:
day_idx = i
break
if day_idx < 1: # Need at least username before day
continue
# Extract components from token positions
username = tokens[0]
# Skip system entries
if username in ['wtmp', 'reboot', 'kernel']:
continue
# Try to find full username from /etc/passwd (wtmp truncates to 8 chars)
full_username = self._get_full_username(username)
if full_username:
username = full_username
# Everything between username and day-of-week is TTY/host
device_host_tokens = tokens[1:day_idx]
# TTY is typically first token if it contains '/' or starts with 'pts'/'tty'
tty = '-'
host = '-'
if device_host_tokens:
first = device_host_tokens[0]
if '/' in first or first.startswith('pts') or first.startswith('tty'):
tty = first
# Remaining tokens are host
if len(device_host_tokens) > 1:
host = ' '.join(device_host_tokens[1:])
else:
# No TTY, all tokens are host
host = ' '.join(device_host_tokens)
# Extract date components (should be: day month date time)
# Note: year is NOT in standard last output, we need to infer it
if day_idx + 3 < len(tokens):
day = tokens[day_idx]
month = tokens[day_idx + 1]
date = tokens[day_idx + 2]
time_str = tokens[day_idx + 3]
# Validate month
if month not in months:
logger.debug(f"Invalid month '{month}' in line: {line}")
continue
# Use current year (last entries are usually recent)
year = current_year
else:
logger.debug(f"Not enough date tokens in line: {line}")
continue
# Extract duration from end of line (in parentheses)
duration_match = re.search(r'\(([^)]+)\)\s*$', line)
duration = duration_match.group(1) if duration_match else 'still logged in'
logins.append({
'username': username,
'tty': tty,
'host': host,
'date': f"{year}-{months[month]:02d}-{date.zfill(2)}",
'time': time_str,
'duration': duration,
'login_str': f"{day} {month} {date} {time_str} {year}"
})
except Exception as e:
logger.debug(f"Error parsing login line '{line}': {e}")
continue
return logins
except Exception as e:
logger.error(f"Error getting login history: {e}")
return []
def _get_full_username(self, truncated: str) -> Optional[str]:
"""Find full username from /etc/passwd when wtmp has truncated it (8 char limit)
wtmp truncates usernames to 8 characters, so we need to look up the full name
in /etc/passwd by matching usernames that start with the truncated name.
"""
try:
result = subprocess.run(
["/usr/bin/getent", "passwd"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if parts and parts[0].startswith(truncated):
# Found a username that starts with the truncated name
if len(parts[0]) > len(truncated):
# It's longer than the truncated version
return parts[0]
return None
except Exception as e:
logger.debug(f"Error finding full username for '{truncated}': {e}")
return None
def list_samba_users(self) -> List[Dict[str, Any]]:
"""List all Samba users using pdbedit"""
users = []
try:
result = subprocess.run(
["/usr/bin/pdbedit", "-L"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
# pdbedit output format: username:uid:comment
parts = line.split(':')
if len(parts) >= 2:
try:
username = parts[0]
uid = int(parts[1])
comment = parts[2] if len(parts) > 2 else ""
users.append({
'username': username,
'uid': uid,
'comment': comment,
'type': 'samba'
})
except (ValueError, IndexError) as e:
logger.warning(f"Error parsing Samba user line: {e}")
return sorted(users, key=lambda x: x['username'])
except FileNotFoundError:
logger.warning("pdbedit not found - Samba may not be installed")
return []
except Exception as e:
logger.error(f"Error listing Samba users: {e}")
return []
identities_manager = IdentitiesManager()
+299
View File
@@ -0,0 +1,299 @@
"""
Samba and NFS Shares Management
Handles /etc/samba/smb.conf and /etc/exports
"""
import re
import subprocess
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
SAMBA_CONFIG = Path("/etc/samba/smb.conf")
NFS_EXPORTS = Path("/etc/exports")
class SharesManager:
"""Manage Samba and NFS shares"""
def list_samba_shares(self) -> List[Dict[str, Any]]:
"""Parse /etc/samba/smb.conf and return shares"""
if not SAMBA_CONFIG.exists():
return []
shares = []
try:
with open(SAMBA_CONFIG, 'r') as f:
content = f.read()
current_share = None
for line in content.split('\n'):
line = line.strip()
if not line or line.startswith('#') or line.startswith(';'):
continue
if line.startswith('[') and line.endswith(']'):
current_share = line[1:-1]
if current_share.lower() != 'global':
shares.append({'name': current_share, 'path': None, 'comment': None})
else:
current_share = None
continue
if '=' in line and current_share:
key, value = line.split('=', 1)
key = key.strip().lower()
value = value.strip()
if key == 'path':
shares[-1]['path'] = value
elif key == 'comment':
shares[-1]['comment'] = value
return [s for s in shares if s['path']]
except Exception as e:
logger.error(f"Error parsing Samba config: {e}")
return []
def create_samba_share(self, name: str, path: str, comment: Optional[str] = None) -> bool:
"""Add Samba share to /etc/samba/smb.conf"""
if not SAMBA_CONFIG.exists() or not name.strip() or not path.strip():
return False
try:
name = name.strip()
path = path.strip()
section = f"\n[{name}]\n path = {path}\n"
if comment:
section += f" comment = {comment}\n"
section += f" browseable = yes\n read only = no\n"
with open(SAMBA_CONFIG, 'a') as f:
f.write(section)
subprocess.run(['smbcontrol', 'smbd', 'reload-config'], capture_output=True, timeout=10)
logger.info(f"Samba share created: {name}")
return True
except Exception as e:
logger.error(f"Error creating Samba share: {e}")
return False
def delete_samba_share(self, name: str) -> bool:
"""Remove Samba share from /etc/samba/smb.conf"""
if not SAMBA_CONFIG.exists():
return False
try:
with open(SAMBA_CONFIG, 'r') as f:
content = f.read()
pattern = rf"\n\[{re.escape(name)}\].*?(?=\n\[|\Z)"
new_content = re.sub(pattern, '', content, flags=re.DOTALL)
if new_content == content:
return False
with open(SAMBA_CONFIG, 'w') as f:
f.write(new_content)
subprocess.run(['smbcontrol', 'smbd', 'reload-config'], capture_output=True, timeout=10)
logger.info(f"Samba share deleted: {name}")
return True
except Exception as e:
logger.error(f"Error deleting Samba share: {e}")
return False
def list_nfs_shares(self) -> List[Dict[str, Any]]:
"""Parse /etc/exports and return NFS shares"""
if not NFS_EXPORTS.exists():
return []
shares = []
try:
with open(NFS_EXPORTS, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split()
if len(parts) >= 2:
path = parts[0]
rest = ' '.join(parts[1:])
clients = rest[:rest.index('(')].strip() if '(' in rest else rest
options = rest[rest.index('(') + 1:rest.index(')')] if '(' in rest else None
shares.append({'path': path, 'clients': clients, 'options': options})
return shares
except Exception as e:
logger.error(f"Error parsing NFS exports: {e}")
return []
def create_nfs_share(self, path: str, clients: str, options: Optional[str] = None) -> bool:
"""Add NFS share to /etc/exports"""
if not NFS_EXPORTS.exists() or not path.strip() or not clients.strip():
return False
try:
path = path.strip()
clients = clients.strip()
if not options:
options = "rw,sync,no_subtree_check"
export_line = f"{path} {clients}({options})\n"
with open(NFS_EXPORTS, 'a') as f:
f.write(export_line)
subprocess.run(['exportfs', '-r'], capture_output=True, timeout=10)
logger.info(f"NFS share created: {path}")
return True
except Exception as e:
logger.error(f"Error creating NFS share: {e}")
return False
def delete_nfs_share(self, path: str) -> bool:
"""Remove NFS share from /etc/exports"""
if not NFS_EXPORTS.exists():
return False
try:
with open(NFS_EXPORTS, 'r') as f:
lines = f.readlines()
new_lines = [l for l in lines if not l.strip().startswith(path)]
if len(new_lines) == len(lines):
return False
with open(NFS_EXPORTS, 'w') as f:
f.writelines(new_lines)
subprocess.run(['exportfs', '-r'], capture_output=True, timeout=10)
logger.info(f"NFS share deleted: {path}")
return True
except Exception as e:
logger.error(f"Error deleting NFS share: {e}")
return False
def get_samba_global_config(self) -> Dict[str, Any]:
"""Read Samba global configuration section"""
if not SAMBA_CONFIG.exists():
return {"raw": ""}
try:
with open(SAMBA_CONFIG, 'r') as f:
content = f.read()
# Extract global section
global_section = ""
lines = content.split('\n')
in_global = False
for line in lines:
if line.strip().startswith('[global]'):
in_global = True
continue
if in_global:
if line.strip().startswith('['):
break
global_section += line + '\n'
return {"raw": global_section.strip()}
except Exception as e:
logger.error(f"Error reading Samba global config: {e}")
return {"raw": ""}
def set_samba_global_config(self, config_text: str) -> bool:
"""Write Samba global configuration section"""
if not SAMBA_CONFIG.exists():
return False
try:
with open(SAMBA_CONFIG, 'r') as f:
lines = f.readlines()
# Find global section and shares
output_lines = []
skip_global = False
for i, line in enumerate(lines):
if line.strip().startswith('[global]'):
skip_global = True
output_lines.append('[global]\n')
# Add config lines
for config_line in config_text.split('\n'):
if config_line.strip():
output_lines.append(' ' + config_line + '\n')
output_lines.append('\n')
continue
if skip_global:
if line.strip().startswith('['):
skip_global = False
output_lines.append(line)
continue
output_lines.append(line)
with open(SAMBA_CONFIG, 'w') as f:
f.writelines(output_lines)
subprocess.run(['smbcontrol', 'smbd', 'reload-config'], capture_output=True, timeout=10)
logger.info("Samba global config updated")
return True
except Exception as e:
logger.error(f"Error writing Samba global config: {e}")
return False
def import_samba_config(self, config_file: str) -> bool:
"""Import Samba configuration using net conf import"""
try:
# Use net conf import to load configuration from file
result = subprocess.run(
['net', 'conf', 'import', config_file],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Samba config imported from {config_file}")
return True
else:
logger.error(f"Failed to import Samba config: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error importing Samba config: {e}")
return False
def get_nfs_config(self) -> Dict[str, Any]:
"""Read /etc/exports and return as config object"""
if not NFS_EXPORTS.exists():
return {"exports": "", "note": "NFS not configured"}
try:
with open(NFS_EXPORTS, 'r') as f:
content = f.read()
return {"exports": content, "path": str(NFS_EXPORTS)}
except Exception as e:
logger.error(f"Error reading NFS config: {e}")
return {"error": str(e), "path": str(NFS_EXPORTS)}
def set_nfs_config(self, content: str) -> bool:
"""Write to /etc/exports and reload NFS"""
if not NFS_EXPORTS.exists():
return False
try:
with open(NFS_EXPORTS, 'w') as f:
f.write(content)
subprocess.run(['exportfs', '-r'], capture_output=True, timeout=10)
logger.info("NFS config updated")
return True
except Exception as e:
logger.error(f"Error writing NFS config: {e}")
return False
share_manager = SharesManager()
+588
View File
@@ -0,0 +1,588 @@
"""
System Information Service
Hostname, time, updates, CPU, memory, etc.
"""
import subprocess
import logging
import socket
import platform
from typing import Dict, Any, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
class SystemInfo:
"""Get system information"""
@staticmethod
def get_hostname() -> Dict[str, str]:
"""Get system hostname"""
try:
with open("/etc/hostname", "r") as f:
hostname = f.read().strip()
return {"hostname": hostname}
except Exception as e:
logger.error(f"Error getting hostname: {e}")
return {"error": str(e)}
@staticmethod
def set_hostname(hostname: str) -> Dict[str, str]:
"""Set system hostname"""
try:
with open("/etc/hostname", "w") as f:
f.write(hostname)
# Also update hostnamectl if available
subprocess.run(
["hostnamectl", "set-hostname", hostname],
capture_output=True,
check=False
)
logger.info(f"Set hostname to {hostname}")
return {"status": "success", "hostname": hostname}
except Exception as e:
logger.error(f"Error setting hostname: {e}")
return {"error": str(e)}
@staticmethod
def get_system_info() -> Dict[str, Any]:
"""Get general system information"""
try:
uname = platform.uname()
info = {
"hostname": socket.gethostname(),
"system": uname.system,
"kernel": uname.release,
"machine": uname.machine,
"processor": platform.processor(),
"python": platform.python_version()
}
# Get machine ID
try:
with open("/etc/machine-id", "r") as f:
info["machine_id"] = f.read().strip()
except:
pass
# Get hardware model
try:
result = subprocess.run(
["dmidecode", "-s", "system-product-name"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
info["model"] = result.stdout.strip()
except:
pass
# Get domain name
try:
result = subprocess.run(
["domainname"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
domain = result.stdout.strip()
if domain and domain != "(none)":
info["domain"] = domain
except:
pass
return info
except Exception as e:
logger.error(f"Error getting system info: {e}")
return {"error": str(e)}
@staticmethod
def get_uptime() -> Dict[str, Any]:
"""Get system uptime and boot time"""
try:
import time
with open("/proc/uptime", "r") as f:
uptime_seconds = int(float(f.read().split()[0]))
days = uptime_seconds // 86400
hours = (uptime_seconds % 86400) // 3600
minutes = (uptime_seconds % 3600) // 60
# Calculate boot timestamp
current_time = time.time()
boot_timestamp = current_time - uptime_seconds
return {
"uptime_seconds": uptime_seconds,
"uptime_string": f"{days}d {hours}h {minutes}m",
"uptime_formatted": {
"days": days,
"hours": hours,
"minutes": minutes
},
"boot_time": int(boot_timestamp)
}
except Exception as e:
logger.error(f"Error getting uptime: {e}")
return {"error": str(e)}
@staticmethod
def get_memory() -> Dict[str, Any]:
"""Get memory usage"""
try:
with open("/proc/meminfo", "r") as f:
lines = f.readlines()
meminfo = {}
for line in lines:
key, value = line.split(":")
meminfo[key.strip()] = int(value.split()[0]) * 1024 # Convert to bytes
return {
"total": meminfo.get("MemTotal", 0),
"available": meminfo.get("MemAvailable", 0),
"used": meminfo.get("MemTotal", 0) - meminfo.get("MemAvailable", 0),
"free": meminfo.get("MemFree", 0),
"swap_total": meminfo.get("SwapTotal", 0),
"swap_free": meminfo.get("SwapFree", 0),
"swap_used": meminfo.get("SwapTotal", 0) - meminfo.get("SwapFree", 0)
}
except Exception as e:
logger.error(f"Error getting memory info: {e}")
return {"error": str(e)}
@staticmethod
def get_cpu_info() -> Dict[str, Any]:
"""Get CPU information"""
try:
import psutil
except ImportError:
logger.debug("psutil not installed, using fallback")
try:
with open("/proc/cpuinfo", "r") as f:
cpuinfo_text = f.read()
cpu_count = cpuinfo_text.count("processor")
return {
"count": cpu_count,
"load_average": open("/proc/loadavg").read().split()[:3]
}
except Exception as e:
logger.error(f"Error getting CPU info: {e}")
return {"error": str(e)}
try:
return {
"count": psutil.cpu_count(),
"percent": psutil.cpu_percent(interval=1),
"load_average": [round(x, 2) for x in __import__("os").getloadavg()]
}
except Exception as e:
logger.error(f"Error getting CPU info with psutil: {e}")
return {"error": str(e)}
@staticmethod
def get_time() -> Dict[str, str]:
"""Get system time"""
try:
now = datetime.now()
return {
"iso": now.isoformat(),
"timestamp": int(now.timestamp()),
"timezone": datetime.now().astimezone().tzinfo.__str__()
}
except Exception as e:
logger.error(f"Error getting time: {e}")
return {"error": str(e)}
@staticmethod
def set_time(iso_string: str) -> Dict[str, str]:
"""Set system time (requires root)"""
try:
dt = datetime.fromisoformat(iso_string)
result = subprocess.run(
["date", "-s", dt.strftime("%Y-%m-%d %H:%M:%S")],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
return {"error": result.stderr}
# Sync hardware clock
subprocess.run(["hwclock", "--systohc"], check=False)
logger.info(f"Set time to {iso_string}")
return {"status": "success", "time": iso_string}
except Exception as e:
logger.error(f"Error setting time: {e}")
return {"error": str(e)}
@staticmethod
def get_updates() -> Dict[str, Any]:
"""Check available updates"""
try:
result = subprocess.run(
["apt", "list", "--upgradable"],
capture_output=True,
text=True,
timeout=10,
check=False
)
if result.returncode != 0:
return {"error": result.stderr}
packages = []
for line in result.stdout.split("\n")[1:]:
if line.strip():
parts = line.split("/")
if len(parts) >= 2:
packages.append({
"package": parts[0].strip(),
"current": parts[1].split("[")[0].strip() if "[" in line else ""
})
return {
"available": len(packages),
"packages": packages
}
except Exception as e:
logger.error(f"Error checking updates: {e}")
return {"error": str(e)}
@staticmethod
def reboot() -> Dict[str, str]:
"""Reboot system (requires root)"""
try:
subprocess.Popen(["shutdown", "-r", "now"])
return {"status": "success", "message": "System rebooting..."}
except Exception as e:
logger.error(f"Error rebooting: {e}")
return {"error": str(e)}
@staticmethod
def shutdown() -> Dict[str, str]:
"""Shutdown system (requires root)"""
try:
subprocess.Popen(["shutdown", "-h", "now"])
return {"status": "success", "message": "System shutting down..."}
except Exception as e:
logger.error(f"Error shutting down: {e}")
return {"error": str(e)}
@staticmethod
def get_network_info() -> Dict[str, Any]:
"""Get network interface information"""
try:
# Try ip -j addr (JSON output) first
result = subprocess.run(
["/usr/sbin/ip", "-j", "addr"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
import json
interfaces = []
try:
data = json.loads(result.stdout)
for iface in data:
addr_info = []
for addr in iface.get("addr_info", []):
addr_info.append({
"family": addr.get("family"),
"local": addr.get("local")
})
interfaces.append({
"name": iface.get("ifname"),
"state": iface.get("operstate", "UNKNOWN"),
"addresses": addr_info
})
return {"interfaces": interfaces}
except json.JSONDecodeError:
pass
# Fallback: read from /proc/net/dev
with open("/proc/net/dev", "r") as f:
lines = f.readlines()
interfaces = []
for line in lines[2:]: # Skip header lines
if ":" in line:
name = line.split(":")[0].strip()
interfaces.append({
"name": name,
"state": "UP",
"addresses": []
})
return {"interfaces": interfaces}
except Exception as e:
logger.error(f"Error getting network info: {e}")
return {"error": str(e)}
@staticmethod
def get_network_traffic() -> Dict[str, Any]:
"""Get network interface traffic (RX/TX bytes)"""
try:
with open("/proc/net/dev", "r") as f:
lines = f.readlines()
interfaces = []
# /proc/net/dev format (after colon):
# RX bytes, RX packets, RX errors, RX drops, RX fifo, RX frame, RX compressed, RX multicast,
# TX bytes, TX packets, TX errors, TX drops, TX fifo, TX collisions, TX carrier, TX compressed
for line in lines[2:]: # Skip header lines
if ":" in line:
name, stats_str = line.split(":")
name = name.strip()
stats = stats_str.split()
if len(stats) >= 16:
interfaces.append({
"name": name,
"rx_bytes": int(stats[0]),
"rx_packets": int(stats[1]),
"rx_errors": int(stats[2]),
"rx_drops": int(stats[3]),
"tx_bytes": int(stats[8]),
"tx_packets": int(stats[9]),
"tx_errors": int(stats[10]),
"tx_drops": int(stats[11])
})
return {"interfaces": interfaces}
except Exception as e:
logger.error(f"Error getting network traffic: {e}")
return {"error": str(e)}
@staticmethod
def get_disk_io() -> Dict[str, Any]:
"""Get disk I/O statistics (read/write operations and bytes)"""
try:
with open("/proc/diskstats", "r") as f:
lines = f.readlines()
disks = []
# /proc/diskstats format:
# major minor name reads_completed reads_merged reads_sectors reads_time_ms
# writes_completed writes_merged writes_sectors writes_time_ms in_progress io_time_ms weighted_io_time_ms
for line in lines:
fields = line.split()
if len(fields) >= 14:
major = int(fields[0])
minor = int(fields[1])
name = fields[2]
# Skip loop devices, ram disks, and other virtual disks
if name.startswith(('dm-', 'loop', 'ram', 'sr', 'zram')):
continue
# Only include actual storage devices (sda, sdb, nvme0n1, etc.)
if not any(name.startswith(prefix) for prefix in ['sd', 'nvme', 'hd', 'vd']):
continue
reads_completed = int(fields[3])
reads_sectors = int(fields[5])
writes_completed = int(fields[7])
writes_sectors = int(fields[9])
# Sectors are typically 512 bytes
reads_bytes = reads_sectors * 512
writes_bytes = writes_sectors * 512
disks.append({
"name": name,
"reads_completed": reads_completed,
"reads_bytes": reads_bytes,
"writes_completed": writes_completed,
"writes_bytes": writes_bytes
})
return {"disks": disks}
except Exception as e:
logger.error(f"Error getting disk I/O: {e}")
return {"error": str(e)}
@staticmethod
def get_services() -> Dict[str, Any]:
"""Get running systemd services"""
try:
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--type=service", "--state=running", "--no-pager", "--output=json"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
import json
try:
services = json.loads(result.stdout)
return {
"services": [
{
"name": svc.get("unit"),
"state": svc.get("active"),
"description": svc.get("description")
}
for svc in services if svc.get("unit", "").endswith(".service")
]
}
except json.JSONDecodeError:
pass
# Fallback: parse text output
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--type=service", "--state=running", "--no-pager"],
capture_output=True,
text=True,
timeout=10
)
services = []
for line in result.stdout.split("\n")[1:]:
if line.strip() and ".service" in line:
parts = line.split()
if len(parts) >= 2:
services.append({
"name": parts[0],
"state": "running",
"description": " ".join(parts[2:]) if len(parts) > 2 else ""
})
return {"services": services}
except Exception as e:
logger.error(f"Error getting services: {e}")
return {"error": str(e)}
@staticmethod
def get_all_units() -> Dict[str, Any]:
"""Get all systemd units (services, targets, sockets, timers, paths)"""
try:
# Get all units without filtering by state
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--all", "--no-pager", "--output=json"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
import json
try:
units_data = json.loads(result.stdout)
units = {
"services": [],
"targets": [],
"sockets": [],
"timers": [],
"paths": []
}
for unit in units_data:
name = unit.get("unit", "")
item = {
"name": name,
"active": unit.get("active"), # active/inactive
"sub": unit.get("sub"), # sub-state like "running", "exited", "enabled", etc.
"description": unit.get("description", "")
}
if name.endswith(".service"):
units["services"].append(item)
elif name.endswith(".target"):
units["targets"].append(item)
elif name.endswith(".socket"):
units["sockets"].append(item)
elif name.endswith(".timer"):
units["timers"].append(item)
elif name.endswith(".path"):
units["paths"].append(item)
return units
except json.JSONDecodeError:
pass
# Fallback: parse text output
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--all", "--no-pager"],
capture_output=True,
text=True,
timeout=10
)
units = {
"services": [],
"targets": [],
"sockets": [],
"timers": [],
"paths": []
}
for line in result.stdout.split("\n")[1:]:
if not line.strip():
continue
parts = line.split()
if len(parts) < 2:
continue
name = parts[0]
active = parts[1] if len(parts) > 1 else "unknown"
description = " ".join(parts[3:]) if len(parts) > 3 else ""
item = {
"name": name,
"active": active,
"sub": parts[2] if len(parts) > 2 else "",
"description": description
}
if name.endswith(".service"):
units["services"].append(item)
elif name.endswith(".target"):
units["targets"].append(item)
elif name.endswith(".socket"):
units["sockets"].append(item)
elif name.endswith(".timer"):
units["timers"].append(item)
elif name.endswith(".path"):
units["paths"].append(item)
return units
except Exception as e:
logger.error(f"Error getting units: {e}")
return {"error": str(e)}
@staticmethod
def get_journal_logs(limit: int = 20) -> Dict[str, Any]:
"""Get recent journal logs"""
try:
result = subprocess.run(
["/usr/bin/journalctl", "-n", str(limit), "--no-pager", "--output=short"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logs = [line.strip() for line in result.stdout.split("\n") if line.strip()]
return {"logs": logs}
return {"logs": []}
except Exception as e:
logger.error(f"Error getting journal logs: {e}")
return {"error": str(e)}
# Global instance
system_info = SystemInfo()
+221
View File
@@ -0,0 +1,221 @@
"""
System User and Group Management
Wrapper around /etc/passwd, /etc/group, useradd, groupadd, etc.
"""
import subprocess
import logging
import pwd
import grp
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
class SystemUserManager:
"""Manage system users and groups"""
def list_users(self) -> List[Dict[str, Any]]:
"""List all system users"""
users = []
try:
for entry in pwd.getwall():
users.append({
"username": entry.pw_name,
"uid": entry.pw_uid,
"gid": entry.pw_gid,
"gecos": entry.pw_gecos,
"home": entry.pw_dir,
"shell": entry.pw_shell
})
except Exception as e:
logger.error(f"Error listing users: {e}")
return sorted(users, key=lambda u: u["uid"])
def list_groups(self) -> List[Dict[str, Any]]:
"""List all system groups"""
groups = []
try:
for entry in grp.getgrall():
groups.append({
"groupname": entry.gr_name,
"gid": entry.gr_gid,
"members": entry.gr_mem
})
except Exception as e:
logger.error(f"Error listing groups: {e}")
return sorted(groups, key=lambda g: g["gid"])
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user details"""
try:
entry = pwd.getpwnam(username)
return {
"username": entry.pw_name,
"uid": entry.pw_uid,
"gid": entry.pw_gid,
"gecos": entry.pw_gecos,
"home": entry.pw_dir,
"shell": entry.pw_shell
}
except KeyError:
return None
except Exception as e:
logger.error(f"Error getting user {username}: {e}")
return None
def get_group(self, groupname: str) -> Optional[Dict[str, Any]]:
"""Get group details"""
try:
entry = grp.getgrnam(groupname)
return {
"groupname": entry.gr_name,
"gid": entry.gr_gid,
"members": entry.gr_mem
}
except KeyError:
return None
except Exception as e:
logger.error(f"Error getting group {groupname}: {e}")
return None
def create_user(
self,
username: str,
password: str,
home_dir: Optional[str] = None,
shell: str = "/bin/bash",
groups: Optional[List[str]] = None
) -> Dict[str, str]:
"""Create new system user"""
try:
cmd = ["useradd"]
if home_dir:
cmd.extend(["-d", home_dir])
cmd.extend(["-s", shell])
if groups:
cmd.extend(["-G", ",".join(groups)])
cmd.append(username)
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
logger.error(f"useradd failed: {result.stderr}")
return {"error": result.stderr}
# Set password
if password:
# Use chpasswd for password setting
passwd_cmd = subprocess.Popen(
["chpasswd"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
_, err = passwd_cmd.communicate(input=f"{username}:{password}\n")
if passwd_cmd.returncode != 0:
logger.error(f"chpasswd failed: {err}")
return {"error": f"User created but password failed: {err}"}
logger.info(f"Created user: {username}")
return {"status": "success", "username": username}
except Exception as e:
logger.error(f"Error creating user: {e}")
return {"error": str(e)}
def delete_user(self, username: str, remove_home: bool = False) -> Dict[str, str]:
"""Delete system user"""
try:
cmd = ["userdel"]
if remove_home:
cmd.append("-r")
cmd.append(username)
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
logger.error(f"userdel failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Deleted user: {username}")
return {"status": "success", "message": f"User {username} deleted"}
except Exception as e:
logger.error(f"Error deleting user: {e}")
return {"error": str(e)}
def create_group(self, groupname: str) -> Dict[str, str]:
"""Create new system group"""
try:
result = subprocess.run(
["groupadd", groupname],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
logger.error(f"groupadd failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Created group: {groupname}")
return {"status": "success", "groupname": groupname}
except Exception as e:
logger.error(f"Error creating group: {e}")
return {"error": str(e)}
def delete_group(self, groupname: str) -> Dict[str, str]:
"""Delete system group"""
try:
result = subprocess.run(
["groupdel", groupname],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
logger.error(f"groupdel failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Deleted group: {groupname}")
return {"status": "success", "message": f"Group {groupname} deleted"}
except Exception as e:
logger.error(f"Error deleting group: {e}")
return {"error": str(e)}
def add_user_to_group(self, username: str, groupname: str) -> Dict[str, str]:
"""Add user to group"""
try:
result = subprocess.run(
["usermod", "-aG", groupname, username],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
logger.error(f"usermod failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Added {username} to {groupname}")
return {"status": "success", "message": f"{username} added to {groupname}"}
except Exception as e:
logger.error(f"Error adding user to group: {e}")
return {"error": str(e)}
# Global instance
system_user_manager = SystemUserManager()
+464
View File
@@ -0,0 +1,464 @@
"""
ZFS Command Runner Wrapper für zpool/zfs CLI Commands
Handles subprocess execution, parsing, caching, error handling
"""
import subprocess
import json
import logging
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime, timedelta
import re
logger = logging.getLogger(__name__)
# Cache with TTL
@dataclass
class CacheEntry:
data: Any
expires_at: datetime
class ZFSCache:
def __init__(self):
self.pool_status = CacheEntry(None, datetime.now())
self.snapshots = CacheEntry(None, datetime.now())
self.datasets = CacheEntry(None, datetime.now())
def get(self, key: str) -> Optional[Any]:
cache_dict = {
"pool_status": self.pool_status,
"snapshots": self.snapshots,
"datasets": self.datasets,
}
if key not in cache_dict:
return None
entry = cache_dict[key]
if not entry or not entry.data:
return None
if datetime.now() > entry.expires_at:
return None
return entry.data
def set(self, key: str, data: Any, ttl_seconds: int = 60):
cache_dict = {
"pool_status": self.pool_status,
"snapshots": self.snapshots,
"datasets": self.datasets,
}
if key in cache_dict:
cache_dict[key].data = data
cache_dict[key].expires_at = datetime.now() + timedelta(seconds=ttl_seconds)
class ZFSRunner:
def __init__(self, timeout: int = 5):
self.timeout = timeout
self.cache = ZFSCache()
def run_command(self, cmd: List[str], timeout: Optional[int] = None) -> Tuple[str, str, int]:
"""
Run subprocess command with timeout
Returns: (stdout, stderr, returncode)
"""
if timeout is None:
timeout = self.timeout
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False
)
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
logger.error(f"Command timeout after {timeout}s: {' '.join(cmd)}")
return "", f"Command timeout after {timeout}s", -1
except FileNotFoundError:
logger.error(f"Command not found: {' '.join(cmd)}")
return "", f"Command not found: {cmd[0]}", -1
except Exception as e:
logger.error(f"Command execution error: {e}")
return "", str(e), -1
# ============== POOL OPERATIONS ==============
def list_pools(self) -> List[Dict[str, Any]]:
"""
Get list of ZFS pools with status
Uses cache (TTL 30s)
"""
cached = self.cache.get("pool_status")
if cached:
return cached
stdout, stderr, rc = self.run_command(["zpool", "list", "-H", "-p"])
if rc != 0:
logger.error(f"zpool list failed: {stderr}")
return []
pools = []
for line in stdout.strip().split("\n"):
if not line:
continue
parts = line.split()
if len(parts) < 10:
continue
pool = {
"name": parts[0],
"size": int(parts[1]),
"alloc": int(parts[2]),
"free": int(parts[3]),
"fragmentation": parts[7],
"capacity": parts[8],
"health": parts[9]
}
pools.append(pool)
self.cache.set("pool_status", pools, ttl_seconds=30)
return pools
def _parse_vdev_tree(self, config_lines: List[str]) -> List[Dict[str, Any]]:
"""
Parse VDEV tree from zpool status config section.
Uses indentation levels to reconstruct hierarchy.
Returns list of vdev dicts with name, state, and error counters (read/write/cksum).
"""
roots: List[Dict] = []
stack: List[tuple] = [] # (indent, vdev_dict)
for line in config_lines:
if not line.strip():
continue
# Skip header line (NAME STATE READ WRITE CKSUM)
if line.strip().startswith("NAME"):
continue
indent = len(line) - len(line.lstrip())
parts = line.split()
if not parts:
continue
name = parts[0]
state = parts[1] if len(parts) > 1 else "UNKNOWN"
# Parse error counters and convert to integers
read = 0
write = 0
cksum = 0
if len(parts) > 2:
try:
read = int(parts[2])
except (ValueError, IndexError):
read = 0
if len(parts) > 3:
try:
write = int(parts[3])
except (ValueError, IndexError):
write = 0
if len(parts) > 4:
try:
cksum = int(parts[4])
except (ValueError, IndexError):
cksum = 0
vdev: Dict[str, Any] = {
"name": name,
"state": state,
"read": read,
"write": write,
"cksum": cksum,
"children": []
}
# Pop stack entries that are at same or deeper indent
while stack and stack[-1][0] >= indent:
stack.pop()
if stack:
stack[-1][1]["children"].append(vdev)
else:
roots.append(vdev)
stack.append((indent, vdev))
return roots
def get_pool_status(self, pool_name: str) -> Dict[str, Any]:
"""
Get detailed pool status including VDEV tree and error counters
"""
stdout, stderr, rc = self.run_command(["zpool", "status", pool_name])
if rc != 0:
logger.error(f"zpool status failed for {pool_name}: {stderr}")
return {}
status: Dict[str, Any] = {
"name": pool_name,
"state": None,
"scan": None,
"errors": None,
"vdevs": [],
}
lines = stdout.split("\n")
in_config = False
config_lines: List[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("state:"):
status["state"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("scan:"):
status["scan"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("errors:"):
status["errors"] = stripped.split(":", 1)[1].strip()
in_config = False
elif stripped == "config:":
in_config = True
elif in_config:
# Collect config block lines (skip blank lines at start)
if stripped or config_lines:
config_lines.append(line)
if config_lines:
# Remove the pool name itself (first non-empty line after "NAME" header)
# and parse only child vdevs
parsed = self._parse_vdev_tree(config_lines)
# parsed[0] is the pool root node; its children are the top-level vdevs
if parsed and parsed[0]["name"] == pool_name:
status["vdevs"] = parsed[0]["children"]
else:
status["vdevs"] = parsed
return status
def scrub_pool(self, pool_name: str) -> Dict[str, str]:
"""
Start or resume scrub on pool
"""
stdout, stderr, rc = self.run_command(["zpool", "scrub", pool_name])
if rc != 0:
logger.error(f"zpool scrub failed for {pool_name}: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Scrub started for {pool_name}"}
# ============== DATASET/FILESYSTEM OPERATIONS ==============
def list_datasets(self, pool_name: str, max_depth: int = 2) -> List[Dict[str, Any]]:
"""
List datasets in pool (with depth limit for performance)
"""
cached = self.cache.get("datasets")
if cached and cached.get(pool_name):
return cached[pool_name]
stdout, stderr, rc = self.run_command([
"zfs", "list", "-d", str(max_depth), "-H", "-p",
"-o", "name,used,avail,refer,mountpoint,type",
pool_name
])
if rc != 0:
logger.error(f"zfs list failed for {pool_name}: {stderr}")
return []
datasets = []
for line in stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) < 6:
continue
dataset = {
"name": parts[0],
"used": int(parts[1]),
"avail": int(parts[2]),
"refer": int(parts[3]),
"mountpoint": parts[4],
"type": parts[5] # filesystem, volume, snapshot
}
datasets.append(dataset)
# Cache per pool
if not cached:
cached = {}
cached[pool_name] = datasets
self.cache.set("datasets", cached, ttl_seconds=60)
return datasets
def create_dataset(self, dataset_name: str, props: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""
Create new ZFS dataset/filesystem
"""
cmd = ["zfs", "create"]
if props:
for key, val in props.items():
cmd.extend(["-o", f"{key}={val}"])
cmd.append(dataset_name)
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs create failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Dataset {dataset_name} created"}
def set_dataset_properties(self, dataset_name: str, props: Dict[str, str]) -> Dict[str, str]:
"""Set ZFS dataset properties (compression, quota, reservation, etc.)"""
errors = []
for key, value in props.items():
if value is None:
continue
stdout, stderr, rc = self.run_command(["zfs", "set", f"{key}={value}", dataset_name])
if rc != 0:
errors.append(f"{key}: {stderr.strip()}")
if errors:
return {"status": "error", "message": "; ".join(errors)}
return {"status": "success", "message": f"Properties updated for {dataset_name}"}
def destroy_dataset(self, dataset_name: str, recursive: bool = False) -> Dict[str, str]:
"""
Destroy ZFS dataset
"""
cmd = ["zfs", "destroy"]
if recursive:
cmd.append("-r")
cmd.append(dataset_name)
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs destroy failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Dataset {dataset_name} destroyed"}
# ============== SNAPSHOT OPERATIONS ==============
def list_snapshots(self, dataset_name: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
"""
List snapshots (with limit for performance on many snapshots)
"""
cached = self.cache.get("snapshots")
if cached:
return cached
# If no dataset specified, list all
if dataset_name:
cmd = ["zfs", "list", "-t", "snapshot", "-d", "1", "-H", "-p",
"-o", "name,used,referenced,creation", dataset_name]
else:
# Get all snapshots, limited
cmd = ["zfs", "list", "-t", "snapshot", "-H", "-p",
"-o", "name,used,referenced,creation"]
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs list snapshots failed: {stderr}")
return []
snapshots = []
for line in stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) < 4:
continue
snapshot = {
"name": parts[0],
"used": int(parts[1]),
"referenced": int(parts[2]),
"creation": int(parts[3]), # Unix timestamp
}
snapshots.append(snapshot)
# Sort by creation time (newest first) and limit
snapshots.sort(key=lambda x: x["creation"], reverse=True)
snapshots = snapshots[:limit]
self.cache.set("snapshots", snapshots, ttl_seconds=60)
return snapshots
def create_snapshot(self, dataset_name: str, snapshot_name: Optional[str] = None) -> Dict[str, str]:
"""
Create snapshot with auto-generated name if not provided
"""
if not snapshot_name:
from datetime import datetime as dt
snapshot_name = dt.now().strftime("%Y%m%d-%H%M%S")
full_name = f"{dataset_name}@{snapshot_name}"
stdout, stderr, rc = self.run_command(["zfs", "snapshot", full_name])
if rc != 0:
logger.error(f"zfs snapshot failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Snapshot {full_name} created"}
def destroy_snapshot(self, snapshot_name: str, recursive: bool = False) -> Dict[str, str]:
"""
Destroy snapshot
"""
cmd = ["zfs", "destroy"]
if recursive:
cmd.append("-r")
cmd.append(snapshot_name)
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs destroy snapshot failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Snapshot {snapshot_name} destroyed"}
def rollback_snapshot(self, snapshot_name: str) -> Dict[str, str]:
"""
Rollback dataset to snapshot
WARNING: Destroys data after snapshot!
"""
stdout, stderr, rc = self.run_command(["zfs", "rollback", "-r", snapshot_name])
if rc != 0:
logger.error(f"zfs rollback failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Rolled back to {snapshot_name}"}
# ============== UTILITY ==============
def clear_cache(self):
"""Clear all caches"""
self.cache = ZFSCache()
logger.info("ZFS cache cleared")
# Global instance
zfs_runner = ZFSRunner()
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Navigator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #1b1b1f;
--bg-light: #252526;
--border: #3c3c3d;
--text: #f3f3f3;
--text-muted: #a0a0a0;
--primary: #0066ff;
--danger: #d52f2f;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
}
.nav-header {
background: var(--bg-light);
border-bottom: 1px solid var(--border);
padding: 12px 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.nav-buttons {
display: flex;
gap: 4px;
}
button {
background: var(--bg-light);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
button:hover {
background: var(--border);
}
button.primary {
background: var(--primary);
border-color: var(--primary);
color: white;
}
button.primary:hover {
background: #0055dd;
}
.address-bar {
flex: 1;
min-width: 200px;
background: var(--bg);
border: 1px solid var(--border);
padding: 8px 12px;
color: var(--text);
border-radius: 4px;
font-size: 12px;
}
.search-bar {
width: 200px;
background: var(--bg);
border: 1px solid var(--border);
padding: 8px 12px;
color: var(--text);
border-radius: 4px;
font-size: 12px;
}
.nav-main {
display: flex;
flex: 1;
overflow: hidden;
}
.nav-content {
flex: 1;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.nav-info-panel {
width: 280px;
background: var(--bg-light);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
}
.nav-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.nav-table thead {
position: sticky;
top: 0;
background: var(--bg);
border-bottom: 2px solid var(--border);
}
.nav-table th {
padding: 8px 12px;
text-align: left;
font-weight: 600;
cursor: pointer;
user-select: none;
color: var(--text-muted);
}
.nav-table th:hover {
background: var(--border);
}
.nav-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.nav-table tr {
cursor: pointer;
}
.nav-table tr:hover {
background: var(--border);
}
.nav-table tr.selected {
background: var(--primary);
}
.nav-file-icon {
margin-right: 8px;
width: 20px;
display: inline-block;
text-align: center;
}
.nav-footer {
background: var(--bg-light);
border-top: 1px solid var(--border);
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.nav-properties {
flex: 1;
}
.nav-property {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.nav-property-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 4px;
}
.nav-property-value {
font-size: 12px;
color: var(--text);
word-break: break-all;
}
.nav-property-input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 3px;
width: 100%;
font-size: 12px;
}
.nav-permissions-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 8px;
margin: 8px 0;
}
.nav-permission-row {
display: contents;
}
.nav-permission-label {
font-size: 11px;
color: var(--text-muted);
padding: 4px;
font-weight: 600;
}
.nav-permission-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.nav-permission-checkbox input {
cursor: pointer;
}
.hidden-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.hidden-toggle input[type="checkbox"] {
cursor: pointer;
}
</style>
</head>
<body>
<!-- Header -->
<div class="nav-header">
<div class="nav-buttons">
<button title="Back" onclick="navBack()">← Zurück</button>
<button title="Forward" onclick="navForward()">Vorwärts →</button>
<button title="Up" onclick="navUp()">↑ Nach oben</button>
<button title="Refresh" onclick="loadDirectory()">🔄 Aktualisieren</button>
</div>
<input type="text" class="address-bar" id="addressBar" placeholder="/tank/share"
onkeypress="if(event.key==='Enter') loadDirectory(this.value)">
<input type="text" class="search-bar" id="searchBar" placeholder="Suchen..."
onkeyup="filterFiles()">
<div class="nav-buttons">
<button class="primary" title="Neuer Ordner" onclick="createFolder()">📁 + Ordner</button>
<button class="primary" title="Neue Datei" onclick="createFile()">📄 + Datei</button>
<button class="primary" title="Upload" onclick="uploadFile()">⬆ Upload</button>
</div>
</div>
<!-- Main Content -->
<div class="nav-main">
<!-- File List -->
<div class="nav-content">
<table class="nav-table">
<thead>
<tr>
<th onclick="sortBy('name')" style="width: 40%;">📁 Name <span id="sort-name"></span></th>
<th onclick="sortBy('size')" style="width: 15%;">Größe <span id="sort-size"></span></th>
<th onclick="sortBy('modified')" style="width: 15%;">Geändert <span id="sort-modified"></span></th>
<th onclick="sortBy('permissions')" style="width: 15%;">Rechte <span id="sort-permissions"></span></th>
<th onclick="sortBy('owner')" style="width: 15%;">Besitzer <span id="sort-owner"></span></th>
</tr>
</thead>
<tbody id="fileList"></tbody>
</table>
</div>
<!-- Info Panel -->
<div class="nav-info-panel">
<div id="infoContent" style="display: none;">
<h3 id="selectedFileName" style="margin-bottom: 16px; font-size: 14px;"></h3>
<div id="viewProperties">
<div class="nav-property">
<div class="nav-property-label">Typ</div>
<div class="nav-property-value" id="propType">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Größe</div>
<div class="nav-property-value" id="propSize">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Geändert</div>
<div class="nav-property-value" id="propModified">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Besitzer</div>
<div class="nav-property-value" id="propOwner">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Gruppe</div>
<div class="nav-property-value" id="propGroup">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Rechte</div>
<div class="nav-property-value" id="propPermissions">-</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button onclick="editFile()" class="primary" style="flex: 1;">✎ Bearbeiten</button>
<button onclick="deleteFile()" style="flex: 1; background: var(--danger); border-color: var(--danger);">🗑 Löschen</button>
</div>
</div>
<div id="editProperties" style="display: none;">
<div class="nav-property">
<div class="nav-property-label">Besitzer</div>
<input type="text" id="editOwner" class="nav-property-input" placeholder="root">
</div>
<div class="nav-property">
<div class="nav-property-label">Gruppe</div>
<input type="text" id="editGroup" class="nav-property-input" placeholder="root">
</div>
<div class="nav-property">
<div class="nav-property-label">Berechtigungen</div>
<div class="nav-permissions-grid">
<div class="nav-permission-label"></div>
<div class="nav-permission-label">Lesen</div>
<div class="nav-permission-label">Schreiben</div>
<div class="nav-permission-label">Ausführen</div>
<div class="nav-permission-label">Besitzer</div>
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-r"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-w"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-x"></div>
<div class="nav-permission-label">Gruppe</div>
<div class="nav-permission-checkbox"><input type="checkbox" id="group-r"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="group-w"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="group-x"></div>
<div class="nav-permission-label">Andere</div>
<div class="nav-permission-checkbox"><input type="checkbox" id="other-r"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="other-w"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="other-x"></div>
</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button onclick="cancelEdit()" style="flex: 1;">✕ Abbrechen</button>
<button onclick="saveEdit()" class="primary" style="flex: 1;">💾 Speichern</button>
</div>
</div>
</div>
<div id="noSelection" style="text-align: center; padding: 32px 16px; color: var(--text-muted);">
<p style="font-size: 14px;">Wähle eine Datei aus</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="nav-footer">
<div>
<span id="fileStats">0 Dateien, 0 Ordner (0 B)</span>
</div>
<div style="flex: 1;"></div>
<div class="hidden-toggle">
<input type="checkbox" id="showHidden" onchange="loadDirectory()">
<label for="showHidden">Versteckte Dateien</label>
</div>
</div>
<script>
const API_URL = '';
let token = localStorage.getItem('access_token');
let currentPath = '';
let selectedFile = null;
let historyStack = [];
let historyIndex = -1;
async function apiCall(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}/api${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
alert('Session expired');
window.location.href = '/';
return null;
}
return response.json();
}
async function loadDirectory(path = '') {
if (path) {
currentPath = path;
}
document.getElementById('addressBar').value = currentPath;
const data = await apiCall(`/files/browse?path=${encodeURIComponent(currentPath)}`);
if (data && data.entries) {
let entries = data.entries;
// Filter hidden files
if (!document.getElementById('showHidden').checked) {
entries = entries.filter(e => !e.name.startsWith('.'));
}
// Sort by name
entries.sort((a, b) => a.name.localeCompare(b.name));
renderFileList(entries);
updateStats(entries);
}
}
function renderFileList(entries) {
const html = entries.map(file => `
<tr onclick="selectFile('${file.path}', this)" class="file-row">
<td><span class="nav-file-icon">${file.is_dir ? '📁' : '📄'}</span>${file.name}</td>
<td>${file.is_dir ? '-' : formatSize(file.size)}</td>
<td>${new Date(file.modified * 1000).toLocaleDateString('de-DE')}</td>
<td>${file.permissions}</td>
<td>${file.uid}</td>
</tr>
`).join('');
document.getElementById('fileList').innerHTML = html;
}
function selectFile(path, element) {
document.querySelectorAll('.file-row').forEach(e => e.classList.remove('selected'));
element.classList.add('selected');
// Load file info
loadFileInfo(path);
}
async function loadFileInfo(path) {
selectedFile = path;
const data = await apiCall(`/files/info?path=${encodeURIComponent(path)}`);
if (data) {
const isDir = data.is_dir;
document.getElementById('selectedFileName').textContent = data.name;
document.getElementById('propType').textContent = isDir ? 'Verzeichnis' : 'Datei';
document.getElementById('propSize').textContent = isDir ? '-' : formatSize(data.size);
document.getElementById('propModified').textContent = new Date(data.modified * 1000).toLocaleString('de-DE');
document.getElementById('propOwner').textContent = data.uid;
document.getElementById('propGroup').textContent = data.gid;
document.getElementById('propPermissions').textContent = data.permissions;
document.getElementById('noSelection').style.display = 'none';
document.getElementById('infoContent').style.display = 'block';
}
}
function editFile() {
document.getElementById('viewProperties').style.display = 'none';
document.getElementById('editProperties').style.display = 'block';
// Load current permissions into checkboxes
const perms = document.getElementById('propPermissions').textContent;
if (perms && perms.length >= 9) {
document.getElementById('owner-r').checked = perms[1] === 'r';
document.getElementById('owner-w').checked = perms[2] === 'w';
document.getElementById('owner-x').checked = perms[3] === 'x';
document.getElementById('group-r').checked = perms[4] === 'r';
document.getElementById('group-w').checked = perms[5] === 'w';
document.getElementById('group-x').checked = perms[6] === 'x';
document.getElementById('other-r').checked = perms[7] === 'r';
document.getElementById('other-w').checked = perms[8] === 'w';
document.getElementById('other-x').checked = perms[9] === 'x';
}
}
function cancelEdit() {
document.getElementById('viewProperties').style.display = 'block';
document.getElementById('editProperties').style.display = 'none';
}
async function saveEdit() {
// Calculate mode from checkboxes
const mode = calculateMode();
try {
await apiCall(`/files/permissions`, {
method: 'POST',
body: JSON.stringify({
path: selectedFile,
mode: mode
})
});
alert('Rechte aktualisiert');
cancelEdit();
loadDirectory();
} catch (e) {
alert('Fehler: ' + e);
}
}
function calculateMode() {
let mode = 0;
if (document.getElementById('owner-r').checked) mode += 400;
if (document.getElementById('owner-w').checked) mode += 200;
if (document.getElementById('owner-x').checked) mode += 100;
if (document.getElementById('group-r').checked) mode += 40;
if (document.getElementById('group-w').checked) mode += 20;
if (document.getElementById('group-x').checked) mode += 10;
if (document.getElementById('other-r').checked) mode += 4;
if (document.getElementById('other-w').checked) mode += 2;
if (document.getElementById('other-x').checked) mode += 1;
return mode.toString(8).padStart(3, '0');
}
async function deleteFile() {
if (!confirm(`Löschen: ${selectedFile}?`)) return;
try {
await apiCall(`/files/delete?path=${encodeURIComponent(selectedFile)}`, {
method: 'DELETE'
});
loadDirectory();
document.getElementById('noSelection').style.display = 'block';
document.getElementById('infoContent').style.display = 'none';
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createFolder() {
const name = prompt('Ordnername:');
if (!name) return;
const path = currentPath ? `${currentPath}/${name}` : name;
try {
await apiCall(`/files/mkdir`, {
method: 'POST',
body: JSON.stringify({ path })
});
loadDirectory();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createFile() {
const name = prompt('Dateiname:');
if (!name) return;
const path = currentPath ? `${currentPath}/${name}` : name;
try {
await apiCall(`/files/create`, {
method: 'POST',
body: JSON.stringify({ path })
});
loadDirectory();
} catch (e) {
alert('Fehler: ' + e);
}
}
function uploadFile() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = async (e) => {
const files = e.target.files;
for (let file of files) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(`${API_URL}/api/files/upload?path=${encodeURIComponent(currentPath)}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (!response.ok) throw new Error(await response.text());
} catch (e) {
alert(`Fehler bei ${file.name}: ${e}`);
}
}
loadDirectory();
};
input.click();
}
function navBack() {
if (historyIndex > 0) {
historyIndex--;
currentPath = historyStack[historyIndex];
loadDirectory();
}
}
function navForward() {
if (historyIndex < historyStack.length - 1) {
historyIndex++;
currentPath = historyStack[historyIndex];
loadDirectory();
}
}
function navUp() {
const parts = currentPath.split('/').filter(p => p);
parts.pop();
currentPath = '/' + parts.join('/');
if (currentPath === '/') currentPath = '';
loadDirectory();
}
function sortBy(key) {
// TODO: Implement sorting
}
function filterFiles() {
const search = document.getElementById('searchBar').value.toLowerCase();
document.querySelectorAll('.file-row').forEach(row => {
const name = row.textContent.toLowerCase();
row.style.display = name.includes(search) ? '' : 'none';
});
}
function updateStats(entries) {
const files = entries.filter(e => !e.is_dir).length;
const dirs = entries.filter(e => e.is_dir).length;
const size = entries.reduce((sum, e) => sum + (e.size || 0), 0);
document.getElementById('fileStats').textContent =
`${files} Datei(en), ${dirs} Ordner (${formatSize(size)})`;
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadDirectory();
});
</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{
"admin": {
"password_hash": "$2b$12$A3V09qHeoWP66hs6z/QINOmdaTV2Qv/lm6KbFlLpmce4fFiEl5dVi",
"role": "admin"
},
"pi": {
"password_hash": "$2b$12$enHCoZRdk8Sl0g7ANNSdUeh.NBX8sTebQxR8CUEngtdZPeqbJ75eW",
"role": "admin"
}
}