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