ZMB Webui: Complete Project – Rebrand & Initial Clean Commit

ARCHITECTURE
============
Backend: FastAPI + uvicorn (port 8000)
  - JWT authentication with PAM system users
  - ZFS CLI wrapper with caching (30-60s TTL)
  - WebSocket pool status broadcaster (30s interval)
  - Services: auth, zfs_runner, file_manager, shares, identities, system_info
  - Routers: pools, datasets, snapshots, shares, identities, navigator, system

Frontend: Next.js 15 + TypeScript (static export)
  - Incremental Static Regeneration (ISR) for weak hardware
  - Type-safe API client (lib/api.ts)
  - Dark mode + custom Tailwind theme
  - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc.

DEPLOYMENT
==========
Test Target: 192.168.1.179:8090 (Debian LXC)
Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64)
Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh)

FEATURES COMPLETED
==================
Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage)
  - Real-time stats with color-coded progress bars
  - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns)
  - ISR-optimized for fast loads on weak hardware

REBRANDING
==========
Renamed throughout:
  - Project: 'ZFS Manager' → 'ZMB Webui'
  - Services: 'zfs-manager' → 'zmb-webui'
  - Systemd units: zfs-manager-backend → zmb-webui-backend
  - Configuration files and documentation

Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 6d74d874b6
104 changed files with 28836 additions and 0 deletions
View File
+89
View File
@@ -0,0 +1,89 @@
"""
JWT Authentication Service
Handles user login via PAM (Linux system users), token generation, and verification
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("ZFS_SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 8
# Try to import PAM for system authentication
try:
import pam
PAM_AVAILABLE = True
except ImportError:
PAM_AVAILABLE = False
logger.warning("python-pam not installed, PAM authentication unavailable")
class AuthService:
def __init__(self):
"""Initialize auth service with PAM (Linux system users)"""
if PAM_AVAILABLE:
logger.info("Using PAM authentication (Linux system users)")
else:
logger.error("PAM not available - install python-pam for authentication")
def authenticate_user(self, username: str, password: str) -> Optional[dict]:
"""
Authenticate user via PAM (Linux system users like 'pi', 'root')
Returns user data if valid, None otherwise
"""
if not PAM_AVAILABLE:
logger.error("PAM not available")
return None
try:
p = pam.pam()
if p.authenticate(username, password):
logger.info(f"User {username} authenticated via PAM")
return {
"username": username,
"source": "pam"
}
else:
logger.warning(f"PAM authentication failed for user {username}: {p.reason}")
return None
except Exception as e:
logger.error(f"PAM authentication error: {e}")
return None
def create_access_token(self, username: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
if expires_delta is None:
expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
expire = datetime.utcnow() + expires_delta
to_encode = {"sub": username, "exp": expire}
try:
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
except Exception as e:
logger.error(f"Failed to create token: {e}")
raise
def verify_token(self, token: str) -> Optional[str]:
"""Verify JWT token and return username"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
# Global instance
auth_service = AuthService()
+89
View File
@@ -0,0 +1,89 @@
"""
JWT Authentication Service
Handles user login via PAM (Linux system users), token generation, and verification
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("ZFS_SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 8
# Try to import PAM for system authentication
try:
import pam
PAM_AVAILABLE = True
except ImportError:
PAM_AVAILABLE = False
logger.warning("python-pam not installed, PAM authentication unavailable")
class AuthService:
def __init__(self):
"""Initialize auth service with PAM (Linux system users)"""
if PAM_AVAILABLE:
logger.info("Using PAM authentication (Linux system users)")
else:
logger.error("PAM not available - install python-pam for authentication")
def authenticate_user(self, username: str, password: str) -> Optional[dict]:
"""
Authenticate user via PAM (Linux system users like 'pi', 'root')
Returns user data if valid, None otherwise
"""
if not PAM_AVAILABLE:
logger.error("PAM not available")
return None
try:
p = pam.pam()
if p.authenticate(username, password):
logger.info(f"User {username} authenticated via PAM")
return {
"username": username,
"source": "pam"
}
else:
logger.warning(f"PAM authentication failed for user {username}: {p.reason}")
return None
except Exception as e:
logger.error(f"PAM authentication error: {e}")
return None
def create_access_token(self, username: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
if expires_delta is None:
expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
expire = datetime.utcnow() + expires_delta
to_encode = {"sub": username, "exp": expire}
try:
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
except Exception as e:
logger.error(f"Failed to create token: {e}")
raise
def verify_token(self, token: str) -> Optional[str]:
"""Verify JWT token and return username"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
# Global instance
auth_service = AuthService()
+644
View File
@@ -0,0 +1,644 @@
"""
File Manager Service Browse, upload, download files in /tank/share
Similar to cockpit-files but optimized for ZFS shares
"""
import os
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
from stat import filemode
from datetime import datetime
import stat
logger = logging.getLogger(__name__)
# Root directory for file operations (ZFS share)
BASE_PATH = Path("/tank/share")
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB max upload
class FileInfo:
"""File/Directory information"""
def __init__(self, path: Path, base_path: Optional[Path] = None):
self.path = path
self.base_path = base_path or BASE_PATH
self.name = path.name
self.is_link = path.is_symlink()
self.link_target = None
try:
# For symlinks, read the target
if self.is_link:
self.link_target = os.readlink(path)
# Use lstat for symlink itself, not the target
self.stat = path.lstat()
else:
self.stat = path.stat()
self.is_dir = path.is_dir()
self.size = self.stat.st_size
self.modified = self.stat.st_mtime
self.mode = filemode(self.stat.st_mode)
self.uid = self.stat.st_uid
self.gid = self.stat.st_gid
self.error = None
except Exception as e:
self.is_dir = False
self.size = 0
self.modified = 0
self.mode = "---------"
self.uid = 0
self.gid = 0
self.error = str(e)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dict for JSON response"""
result = {
"name": self.name,
"path": str(self.path.relative_to(self.base_path)),
"is_dir": self.is_dir,
"is_link": self.is_link,
"size": self.size,
"modified": self.modified,
"modified_iso": datetime.fromtimestamp(self.modified).isoformat(),
"permissions": self.mode,
"uid": self.uid,
"gid": self.gid,
"error": self.error
}
if self.link_target:
result["link_target"] = self.link_target
return result
class FileManager:
def __init__(self, base_path: Optional[Path] = None):
self.base_path = base_path or BASE_PATH
# Ensure base path exists
if not self.base_path.exists():
logger.warning(f"Base path does not exist: {self.base_path}")
self.base_path.mkdir(parents=True, exist_ok=True)
def _resolve_path(self, rel_path: str) -> Optional[Path]:
"""
Resolve and validate path (prevent directory traversal attacks)
Returns absolute path if safe, None otherwise
"""
try:
if not rel_path:
return self.base_path
# Remove leading slash and resolve
rel_path = rel_path.lstrip("/")
target = (self.base_path / rel_path).resolve()
# Ensure target is within base_path
if not str(target).startswith(str(self.base_path.resolve())):
logger.warning(f"Path traversal attempt: {rel_path}")
return None
return target
except Exception as e:
logger.error(f"Path resolution error: {e}")
return None
def list_directory(self, rel_path: str = "") -> Dict[str, Any]:
"""
List directory contents
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
if not target.is_dir():
return {"error": "Not a directory"}
try:
entries = []
for item in sorted(target.iterdir()):
entries.append(FileInfo(item, self.base_path).to_dict())
return {
"path": rel_path or "/",
"entries": entries,
"total": len(entries)
}
except PermissionError:
return {"error": "Permission denied"}
except Exception as e:
logger.error(f"Error listing directory: {e}")
return {"error": str(e)}
def list_subdirectories(self, rel_path: str = "") -> Dict[str, Any]:
"""
List only subdirectories of a path (for tree sidebar navigation).
Returns: { dirs: [{name, path, has_children}] }
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
if not target.is_dir():
return {"error": "Not a directory"}
try:
dirs = []
for item in sorted(target.iterdir()):
try:
if not item.is_dir():
continue
# Check if has any subdirectory children
has_children = any(
c.is_dir() for c in item.iterdir()
)
except PermissionError:
has_children = False
dirs.append({
"name": item.name,
"path": str(item.relative_to(self.base_path)),
"has_children": has_children
})
return {"path": rel_path or "/", "dirs": dirs}
except PermissionError:
return {"error": "Permission denied"}
except Exception as e:
logger.error(f"Error listing subdirectories: {e}")
return {"error": str(e)}
def get_file_info(self, rel_path: str) -> Dict[str, Any]:
"""
Get detailed info about file/directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
file_info = FileInfo(target, self.base_path).to_dict()
# Add directory listing if it's a directory
if target.is_dir():
try:
children = sorted(target.iterdir())
file_info["children_count"] = len(children)
except PermissionError:
file_info["children_count"] = -1
# Add file preview for text files
if target.is_file() and target.stat().st_size < 1024 * 100: # < 100KB
try:
if target.suffix in [".txt", ".log", ".md", ".json", ".yaml", ".yml"]:
with open(target, "r", encoding="utf-8", errors="replace") as f:
preview = f.read(1000)
file_info["preview"] = preview
except Exception as e:
logger.debug(f"Could not read preview: {e}")
return file_info
except Exception as e:
logger.error(f"Error getting file info: {e}")
return {"error": str(e)}
def read_file(self, rel_path: str, limit: Optional[int] = None) -> Dict[str, Any]:
"""
Read file content (with optional limit)
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "File not found"}
if not target.is_file():
return {"error": "Not a file"}
# Prevent reading huge files at once
if target.stat().st_size > 10 * 1024 * 1024: # > 10MB
return {
"error": "File too large",
"size": target.stat().st_size,
"message": "Use download endpoint for large files"
}
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
if limit:
content = f.read(limit)
else:
content = f.read()
return {
"path": rel_path,
"content": content,
"size": len(content),
"encoding": "utf-8"
}
except Exception as e:
logger.error(f"Error reading file: {e}")
return {"error": str(e)}
def create_file(self, rel_path: str, content: str = "") -> Dict[str, str]:
"""
Create new file or empty file
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
try:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
return {"error": "File already exists"}
with open(target, "w") as f:
f.write(content)
logger.info(f"Created file: {target}")
return {"status": "success", "path": rel_path}
except Exception as e:
logger.error(f"Error creating file: {e}")
return {"error": str(e)}
def delete_file_or_dir(self, rel_path: str, recursive: bool = False) -> Dict[str, str]:
"""
Delete file or directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
if target.is_file():
target.unlink()
logger.info(f"Deleted file: {target}")
return {"status": "success", "message": f"File deleted: {rel_path}"}
elif target.is_dir():
if not recursive and list(target.iterdir()):
return {"error": "Directory not empty"}
import shutil
shutil.rmtree(target)
logger.info(f"Deleted directory: {target}")
return {"status": "success", "message": f"Directory deleted: {rel_path}"}
return {"error": "Unknown error"}
except PermissionError:
return {"error": "Permission denied"}
except Exception as e:
logger.error(f"Error deleting: {e}")
return {"error": str(e)}
def rename_file(self, rel_path: str, new_name: str) -> Dict[str, str]:
"""
Rename file or directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
# Validate new name (no path separators)
if "/" in new_name or "\\" in new_name:
return {"error": "Invalid filename"}
try:
new_path = target.parent / new_name
if new_path.exists():
return {"error": "Target already exists"}
target.rename(new_path)
logger.info(f"Renamed {target} to {new_path}")
return {
"status": "success",
"old_path": rel_path,
"new_path": str(new_path.relative_to(self.base_path))
}
except Exception as e:
logger.error(f"Error renaming: {e}")
return {"error": str(e)}
def mkdir(self, rel_path: str) -> Dict[str, str]:
"""
Create directory
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if target.exists():
return {"error": "Directory already exists"}
try:
target.mkdir(parents=True, exist_ok=False)
logger.info(f"Created directory: {target}")
return {"status": "success", "path": rel_path}
except Exception as e:
logger.error(f"Error creating directory: {e}")
return {"error": str(e)}
def get_space_info(self) -> Dict[str, Any]:
"""
Get space usage of base path via ZFS or fallback to df
"""
try:
import subprocess
# Try to get space from ZFS first
result = subprocess.run(
["zfs", "list", "-H", "-p", "-o", "used,avail,refer", "tank/share"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
parts = result.stdout.strip().split()
if len(parts) >= 3:
used = int(parts[0])
available = int(parts[1])
return {
"used": used,
"available": available,
"actual": int(parts[2]),
"total": used + available
}
# Fallback to df if ZFS is not available
result = subprocess.run(
["df", "-B1", str(self.base_path)],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) >= 2:
# Parse df output: Filesystem 1B-blocks Used Available Use% Mounted on
parts = lines[1].split()
if len(parts) >= 4:
total = int(parts[1])
used = int(parts[2])
available = int(parts[3])
return {
"used": used,
"available": available,
"total": total
}
return {"error": "Could not get space info"}
except Exception as e:
logger.error(f"Error getting space info: {e}")
return {"error": str(e)}
def change_permissions(self, rel_path: str, mode: str) -> Dict[str, Any]:
"""
Change file/directory permissions (chmod)
mode: octal string like "755" or "644"
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
import subprocess
# Convert octal mode string to int
mode_int = int(mode, 8)
result = subprocess.run(
["chmod", mode, str(target)],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Changed permissions for {target} to {mode}")
return {
"status": "success",
"path": rel_path,
"permissions": mode
}
else:
logger.error(f"Failed to change permissions: {result.stderr.decode()}")
return {"error": f"Failed to change permissions: {result.stderr.decode()}"}
except Exception as e:
logger.error(f"Error changing permissions: {e}")
return {"error": str(e)}
def change_owner(self, rel_path: str, owner: str, group: Optional[str] = None) -> Dict[str, Any]:
"""
Change file/directory owner (chown)
owner: username or uid
group: groupname or gid (optional)
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
try:
import subprocess
if group:
chown_spec = f"{owner}:{group}"
else:
chown_spec = owner
result = subprocess.run(
["chown", chown_spec, str(target)],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Changed owner for {target} to {chown_spec}")
return {
"status": "success",
"path": rel_path,
"owner": owner,
"group": group
}
else:
logger.error(f"Failed to change owner: {result.stderr.decode()}")
return {"error": f"Failed to change owner: {result.stderr.decode()}"}
except Exception as e:
logger.error(f"Error changing owner: {e}")
return {"error": str(e)}
def change_permissions_recursive(self, rel_path: str, mode: str) -> Dict[str, Any]:
"""
Change permissions recursively for directory and contents (chmod -R)
"""
target = self._resolve_path(rel_path)
if not target:
return {"error": "Invalid path"}
if not target.exists():
return {"error": "Path not found"}
if not target.is_dir():
return {"error": "Path is not a directory"}
try:
import subprocess
result = subprocess.run(
["chmod", "-R", mode, str(target)],
capture_output=True,
timeout=30
)
if result.returncode == 0:
logger.info(f"Changed permissions recursively for {target} to {mode}")
return {
"status": "success",
"path": rel_path,
"permissions": mode,
"recursive": True
}
else:
logger.error(f"Failed to change permissions: {result.stderr.decode()}")
return {"error": f"Failed to change permissions: {result.stderr.decode()}"}
except Exception as e:
logger.error(f"Error changing permissions: {e}")
return {"error": str(e)}
def copy_file(self, src_rel: str, dst_rel: str, overwrite: bool = False) -> Dict[str, Any]:
"""
Copy file or directory
"""
src = self._resolve_path(src_rel)
dst = self._resolve_path(dst_rel)
if not src or not dst:
return {"error": "Invalid path"}
if not src.exists():
return {"error": "Source path not found"}
if dst.exists() and not overwrite:
return {"error": "Destination already exists"}
try:
import shutil
if src.is_file():
shutil.copy2(src, dst)
else:
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
logger.info(f"Copied {src} to {dst}")
return {
"status": "success",
"src": src_rel,
"dst": dst_rel
}
except Exception as e:
logger.error(f"Error copying: {e}")
return {"error": str(e)}
def move_file(self, src_rel: str, dst_rel: str, overwrite: bool = False) -> Dict[str, Any]:
"""
Move (rename) file or directory
"""
src = self._resolve_path(src_rel)
dst = self._resolve_path(dst_rel)
if not src or not dst:
return {"error": "Invalid path"}
if not src.exists():
return {"error": "Source path not found"}
if dst.exists() and not overwrite:
return {"error": "Destination already exists"}
try:
import shutil
if dst.exists() and overwrite:
if dst.is_dir():
shutil.rmtree(dst)
else:
dst.unlink()
shutil.move(str(src), str(dst))
logger.info(f"Moved {src} to {dst}")
return {
"status": "success",
"src": src_rel,
"dst": dst_rel
}
except Exception as e:
logger.error(f"Error moving: {e}")
return {"error": str(e)}
def search_files(self, query: str, search_path: str = "", max_results: int = 50) -> List[Dict[str, Any]]:
"""
Search for files by name (case-insensitive)
"""
target = self._resolve_path(search_path) if search_path else self.base_path
if not target or not target.exists():
return []
results = []
query_lower = query.lower()
try:
for root, dirs, files in target.walk():
# Search in directories
for d in sorted(dirs):
if query_lower in d.lower():
dir_path = Path(root) / d
results.append(FileInfo(dir_path, self.base_path).to_dict())
if len(results) >= max_results:
return results
# Search in files
for f in sorted(files):
if query_lower in f.lower():
file_path = Path(root) / f
results.append(FileInfo(file_path, self.base_path).to_dict())
if len(results) >= max_results:
return results
return results
except Exception as e:
logger.error(f"Error searching files: {e}")
return []
# Global instance
file_manager = FileManager()
+572
View File
@@ -0,0 +1,572 @@
"""
User and Group Management Service
Handle system users, groups, and PAM operations
"""
import subprocess
import logging
import pwd
import grp
from typing import List, Dict, Any, Optional
from pathlib import Path
try:
import spwd
except ImportError:
spwd = None
logger = logging.getLogger(__name__)
class IdentitiesManager:
"""Manage system users and groups"""
def list_users(self) -> List[Dict[str, Any]]:
"""List all system users"""
users = []
try:
# Use getpwall() which returns an iterator
import getpass
# Fallback: read /etc/passwd directly
result = subprocess.run(
["/usr/bin/getent", "passwd"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) < 7:
continue
try:
username = parts[0]
uid = int(parts[2])
gid = int(parts[3])
gecos = parts[4]
home = parts[5]
shell = parts[6]
users.append({
'username': username,
'uid': uid,
'gid': gid,
'gecos': gecos,
'home': home,
'shell': shell,
'locked': self._is_user_locked(username)
})
except Exception as e:
logger.warning(f"Error parsing user line: {e}")
return sorted(users, key=lambda x: x['uid'])
except Exception as e:
logger.error(f"Error listing users: {e}")
return []
def list_groups(self) -> List[Dict[str, Any]]:
"""List all system groups"""
groups = []
try:
# Use getent to read groups
result = subprocess.run(
["/usr/bin/getent", "group"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) < 4:
continue
try:
groupname = parts[0]
gid = int(parts[2])
members = [m.strip() for m in parts[3].split(',') if m.strip()]
groups.append({
'groupname': groupname,
'gid': gid,
'members': members
})
except Exception as e:
logger.warning(f"Error parsing group line: {e}")
return sorted(groups, key=lambda x: x['gid'])
except Exception as e:
logger.error(f"Error listing groups: {e}")
return []
def get_user_groups(self, username: str) -> List[str]:
"""Get all groups a user belongs to"""
try:
groups = []
for entry in grp.getall():
if username in entry.gr_mem:
groups.append(entry.gr_name)
# Also add primary group
try:
user_entry = pwd.getpwnam(username)
primary_group = grp.getgrgid(user_entry.pw_gid)
if primary_group.gr_name not in groups:
groups.append(primary_group.gr_name)
except KeyError:
pass
return sorted(groups)
except Exception as e:
logger.error(f"Error getting groups for {username}: {e}")
return []
def create_user(self, username: str, home_dir: Optional[str] = None,
shell: str = "/bin/bash", gecos: str = "") -> bool:
"""Create new system user"""
try:
# Build useradd command
cmd = ["/usr/sbin/useradd"]
if home_dir:
cmd.extend(["-d", home_dir])
else:
cmd.extend(["-d", f"/home/{username}"])
cmd.extend(["-s", shell])
if gecos:
cmd.extend(["-c", gecos])
cmd.extend(["-m", username]) # -m to create home directory
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0:
logger.info(f"User created: {username}")
return True
else:
logger.error(f"Failed to create user {username}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error creating user: {e}")
return False
def delete_user(self, username: str, remove_home: bool = True) -> bool:
"""Delete system user and Samba user"""
try:
# First delete Samba user if exists (using pdbedit for better handling)
try:
subprocess.run(
["/usr/bin/pdbedit", "-x", "-u", username],
capture_output=True,
timeout=10
)
except Exception:
pass # Samba not installed or user doesn't exist
# Delete system user
cmd = ["/usr/sbin/userdel"]
if remove_home:
cmd.append("-r")
cmd.append(username)
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0:
logger.info(f"User deleted: {username}")
return True
else:
logger.error(f"Failed to delete user {username}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error deleting user: {e}")
return False
def create_group(self, groupname: str) -> bool:
"""Create new system group"""
try:
result = subprocess.run(
["/usr/sbin/groupadd", groupname],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Group created: {groupname}")
return True
else:
logger.error(f"Failed to create group {groupname}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error creating group: {e}")
return False
def delete_group(self, groupname: str) -> bool:
"""Delete system group"""
try:
result = subprocess.run(
["/usr/sbin/groupdel", groupname],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Group deleted: {groupname}")
return True
else:
logger.error(f"Failed to delete group {groupname}: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error deleting group: {e}")
return False
def add_user_to_group(self, username: str, groupname: str) -> bool:
"""Add user to group"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-aG", groupname, username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User {username} added to group {groupname}")
return True
else:
logger.error(f"Failed to add user to group: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error adding user to group: {e}")
return False
def remove_user_from_group(self, username: str, groupname: str) -> bool:
"""Remove user from group"""
try:
result = subprocess.run(
["/usr/sbin/gpasswd", "-d", username, groupname],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User {username} removed from group {groupname}")
return True
else:
logger.error(f"Failed to remove user from group: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error removing user from group: {e}")
return False
def change_password(self, username: str, password: str) -> bool:
"""Change user password via chpasswd"""
try:
# Use chpasswd for password changes
result = subprocess.run(
["/usr/sbin/chpasswd"],
input=f"{username}:{password}\n",
text=True,
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Password changed for {username}")
return True
else:
logger.error(f"Failed to change password: {result.stderr}")
return False
except Exception as e:
logger.error(f"Error changing password: {e}")
return False
def change_shell(self, username: str, shell: str) -> bool:
"""Change user shell"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-s", shell, username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Shell changed for {username} to {shell}")
return True
else:
logger.error(f"Failed to change shell: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error changing shell: {e}")
return False
def _is_user_locked(self, username: str) -> bool:
"""Check if user account is locked"""
if not spwd:
return False
try:
entry = spwd.getspnam(username)
return entry.sp_lstchg == 0 or entry.sp_max == 0
except (KeyError, PermissionError):
return False
except Exception as e:
logger.warning(f"Error checking lock status: {e}")
return False
def lock_user(self, username: str) -> bool:
"""Lock user account"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-L", username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User locked: {username}")
return True
else:
logger.error(f"Failed to lock user: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error locking user: {e}")
return False
def unlock_user(self, username: str) -> bool:
"""Unlock user account"""
try:
result = subprocess.run(
["/usr/sbin/usermod", "-U", username],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"User unlocked: {username}")
return True
else:
logger.error(f"Failed to unlock user: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error unlocking user: {e}")
return False
def set_samba_password(self, username: str, password: str) -> bool:
"""Set Samba password for user"""
try:
# Use smbpasswd to set Samba password
# -a flag: add/update user
# -s flag: read password from stdin
result = subprocess.run(
["/usr/bin/smbpasswd", "-a", "-s", username],
input=f"{password}\n{password}\n",
text=True,
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Samba password set for {username}")
return True
else:
logger.error(f"Failed to set Samba password: {result.stderr}")
return False
except FileNotFoundError:
logger.error("smbpasswd command not found - Samba not installed?")
return False
except Exception as e:
logger.error(f"Error setting Samba password: {e}")
return False
def get_login_history(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get recent login history using last command"""
import re
from datetime import datetime
logins = []
days_of_week = {'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'}
months = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12}
current_year = datetime.now().year
try:
result = subprocess.run(
["last", "-n", str(limit)],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
# Skip header, footer, and system entries
if 'wtmp' in line or 'begins' in line:
continue
try:
# Find the day-of-week anchor (reliable marker for date section)
tokens = line.split()
day_idx = -1
for i, token in enumerate(tokens):
if token in days_of_week:
day_idx = i
break
if day_idx < 1: # Need at least username before day
continue
# Extract components from token positions
username = tokens[0]
# Skip system entries
if username in ['wtmp', 'reboot', 'kernel']:
continue
# Try to find full username from /etc/passwd (wtmp truncates to 8 chars)
full_username = self._get_full_username(username)
if full_username:
username = full_username
# Everything between username and day-of-week is TTY/host
device_host_tokens = tokens[1:day_idx]
# TTY is typically first token if it contains '/' or starts with 'pts'/'tty'
tty = '-'
host = '-'
if device_host_tokens:
first = device_host_tokens[0]
if '/' in first or first.startswith('pts') or first.startswith('tty'):
tty = first
# Remaining tokens are host
if len(device_host_tokens) > 1:
host = ' '.join(device_host_tokens[1:])
else:
# No TTY, all tokens are host
host = ' '.join(device_host_tokens)
# Extract date components (should be: day month date time)
# Note: year is NOT in standard last output, we need to infer it
if day_idx + 3 < len(tokens):
day = tokens[day_idx]
month = tokens[day_idx + 1]
date = tokens[day_idx + 2]
time_str = tokens[day_idx + 3]
# Validate month
if month not in months:
logger.debug(f"Invalid month '{month}' in line: {line}")
continue
# Use current year (last entries are usually recent)
year = current_year
else:
logger.debug(f"Not enough date tokens in line: {line}")
continue
# Extract duration from end of line (in parentheses)
duration_match = re.search(r'\(([^)]+)\)\s*$', line)
duration = duration_match.group(1) if duration_match else 'still logged in'
logins.append({
'username': username,
'tty': tty,
'host': host,
'date': f"{year}-{months[month]:02d}-{date.zfill(2)}",
'time': time_str,
'duration': duration,
'login_str': f"{day} {month} {date} {time_str} {year}"
})
except Exception as e:
logger.debug(f"Error parsing login line '{line}': {e}")
continue
return logins
except Exception as e:
logger.error(f"Error getting login history: {e}")
return []
def _get_full_username(self, truncated: str) -> Optional[str]:
"""Find full username from /etc/passwd when wtmp has truncated it (8 char limit)
wtmp truncates usernames to 8 characters, so we need to look up the full name
in /etc/passwd by matching usernames that start with the truncated name.
"""
try:
result = subprocess.run(
["/usr/bin/getent", "passwd"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if parts and parts[0].startswith(truncated):
# Found a username that starts with the truncated name
if len(parts[0]) > len(truncated):
# It's longer than the truncated version
return parts[0]
return None
except Exception as e:
logger.debug(f"Error finding full username for '{truncated}': {e}")
return None
def list_samba_users(self) -> List[Dict[str, Any]]:
"""List all Samba users using pdbedit"""
users = []
try:
result = subprocess.run(
["/usr/bin/pdbedit", "-L"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
# pdbedit output format: username:uid:comment
parts = line.split(':')
if len(parts) >= 2:
try:
username = parts[0]
uid = int(parts[1])
comment = parts[2] if len(parts) > 2 else ""
users.append({
'username': username,
'uid': uid,
'comment': comment,
'type': 'samba'
})
except (ValueError, IndexError) as e:
logger.warning(f"Error parsing Samba user line: {e}")
return sorted(users, key=lambda x: x['username'])
except FileNotFoundError:
logger.warning("pdbedit not found - Samba may not be installed")
return []
except Exception as e:
logger.error(f"Error listing Samba users: {e}")
return []
identities_manager = IdentitiesManager()
+299
View File
@@ -0,0 +1,299 @@
"""
Samba and NFS Shares Management
Handles /etc/samba/smb.conf and /etc/exports
"""
import re
import subprocess
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
SAMBA_CONFIG = Path("/etc/samba/smb.conf")
NFS_EXPORTS = Path("/etc/exports")
class SharesManager:
"""Manage Samba and NFS shares"""
def list_samba_shares(self) -> List[Dict[str, Any]]:
"""Parse /etc/samba/smb.conf and return shares"""
if not SAMBA_CONFIG.exists():
return []
shares = []
try:
with open(SAMBA_CONFIG, 'r') as f:
content = f.read()
current_share = None
for line in content.split('\n'):
line = line.strip()
if not line or line.startswith('#') or line.startswith(';'):
continue
if line.startswith('[') and line.endswith(']'):
current_share = line[1:-1]
if current_share.lower() != 'global':
shares.append({'name': current_share, 'path': None, 'comment': None})
else:
current_share = None
continue
if '=' in line and current_share:
key, value = line.split('=', 1)
key = key.strip().lower()
value = value.strip()
if key == 'path':
shares[-1]['path'] = value
elif key == 'comment':
shares[-1]['comment'] = value
return [s for s in shares if s['path']]
except Exception as e:
logger.error(f"Error parsing Samba config: {e}")
return []
def create_samba_share(self, name: str, path: str, comment: Optional[str] = None) -> bool:
"""Add Samba share to /etc/samba/smb.conf"""
if not SAMBA_CONFIG.exists() or not name.strip() or not path.strip():
return False
try:
name = name.strip()
path = path.strip()
section = f"\n[{name}]\n path = {path}\n"
if comment:
section += f" comment = {comment}\n"
section += f" browseable = yes\n read only = no\n"
with open(SAMBA_CONFIG, 'a') as f:
f.write(section)
subprocess.run(['smbcontrol', 'smbd', 'reload-config'], capture_output=True, timeout=10)
logger.info(f"Samba share created: {name}")
return True
except Exception as e:
logger.error(f"Error creating Samba share: {e}")
return False
def delete_samba_share(self, name: str) -> bool:
"""Remove Samba share from /etc/samba/smb.conf"""
if not SAMBA_CONFIG.exists():
return False
try:
with open(SAMBA_CONFIG, 'r') as f:
content = f.read()
pattern = rf"\n\[{re.escape(name)}\].*?(?=\n\[|\Z)"
new_content = re.sub(pattern, '', content, flags=re.DOTALL)
if new_content == content:
return False
with open(SAMBA_CONFIG, 'w') as f:
f.write(new_content)
subprocess.run(['smbcontrol', 'smbd', 'reload-config'], capture_output=True, timeout=10)
logger.info(f"Samba share deleted: {name}")
return True
except Exception as e:
logger.error(f"Error deleting Samba share: {e}")
return False
def list_nfs_shares(self) -> List[Dict[str, Any]]:
"""Parse /etc/exports and return NFS shares"""
if not NFS_EXPORTS.exists():
return []
shares = []
try:
with open(NFS_EXPORTS, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split()
if len(parts) >= 2:
path = parts[0]
rest = ' '.join(parts[1:])
clients = rest[:rest.index('(')].strip() if '(' in rest else rest
options = rest[rest.index('(') + 1:rest.index(')')] if '(' in rest else None
shares.append({'path': path, 'clients': clients, 'options': options})
return shares
except Exception as e:
logger.error(f"Error parsing NFS exports: {e}")
return []
def create_nfs_share(self, path: str, clients: str, options: Optional[str] = None) -> bool:
"""Add NFS share to /etc/exports"""
if not NFS_EXPORTS.exists() or not path.strip() or not clients.strip():
return False
try:
path = path.strip()
clients = clients.strip()
if not options:
options = "rw,sync,no_subtree_check"
export_line = f"{path} {clients}({options})\n"
with open(NFS_EXPORTS, 'a') as f:
f.write(export_line)
subprocess.run(['exportfs', '-r'], capture_output=True, timeout=10)
logger.info(f"NFS share created: {path}")
return True
except Exception as e:
logger.error(f"Error creating NFS share: {e}")
return False
def delete_nfs_share(self, path: str) -> bool:
"""Remove NFS share from /etc/exports"""
if not NFS_EXPORTS.exists():
return False
try:
with open(NFS_EXPORTS, 'r') as f:
lines = f.readlines()
new_lines = [l for l in lines if not l.strip().startswith(path)]
if len(new_lines) == len(lines):
return False
with open(NFS_EXPORTS, 'w') as f:
f.writelines(new_lines)
subprocess.run(['exportfs', '-r'], capture_output=True, timeout=10)
logger.info(f"NFS share deleted: {path}")
return True
except Exception as e:
logger.error(f"Error deleting NFS share: {e}")
return False
def get_samba_global_config(self) -> Dict[str, Any]:
"""Read Samba global configuration section"""
if not SAMBA_CONFIG.exists():
return {"raw": ""}
try:
with open(SAMBA_CONFIG, 'r') as f:
content = f.read()
# Extract global section
global_section = ""
lines = content.split('\n')
in_global = False
for line in lines:
if line.strip().startswith('[global]'):
in_global = True
continue
if in_global:
if line.strip().startswith('['):
break
global_section += line + '\n'
return {"raw": global_section.strip()}
except Exception as e:
logger.error(f"Error reading Samba global config: {e}")
return {"raw": ""}
def set_samba_global_config(self, config_text: str) -> bool:
"""Write Samba global configuration section"""
if not SAMBA_CONFIG.exists():
return False
try:
with open(SAMBA_CONFIG, 'r') as f:
lines = f.readlines()
# Find global section and shares
output_lines = []
skip_global = False
for i, line in enumerate(lines):
if line.strip().startswith('[global]'):
skip_global = True
output_lines.append('[global]\n')
# Add config lines
for config_line in config_text.split('\n'):
if config_line.strip():
output_lines.append(' ' + config_line + '\n')
output_lines.append('\n')
continue
if skip_global:
if line.strip().startswith('['):
skip_global = False
output_lines.append(line)
continue
output_lines.append(line)
with open(SAMBA_CONFIG, 'w') as f:
f.writelines(output_lines)
subprocess.run(['smbcontrol', 'smbd', 'reload-config'], capture_output=True, timeout=10)
logger.info("Samba global config updated")
return True
except Exception as e:
logger.error(f"Error writing Samba global config: {e}")
return False
def import_samba_config(self, config_file: str) -> bool:
"""Import Samba configuration using net conf import"""
try:
# Use net conf import to load configuration from file
result = subprocess.run(
['net', 'conf', 'import', config_file],
capture_output=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"Samba config imported from {config_file}")
return True
else:
logger.error(f"Failed to import Samba config: {result.stderr.decode()}")
return False
except Exception as e:
logger.error(f"Error importing Samba config: {e}")
return False
def get_nfs_config(self) -> Dict[str, Any]:
"""Read /etc/exports and return as config object"""
if not NFS_EXPORTS.exists():
return {"exports": "", "note": "NFS not configured"}
try:
with open(NFS_EXPORTS, 'r') as f:
content = f.read()
return {"exports": content, "path": str(NFS_EXPORTS)}
except Exception as e:
logger.error(f"Error reading NFS config: {e}")
return {"error": str(e), "path": str(NFS_EXPORTS)}
def set_nfs_config(self, content: str) -> bool:
"""Write to /etc/exports and reload NFS"""
if not NFS_EXPORTS.exists():
return False
try:
with open(NFS_EXPORTS, 'w') as f:
f.write(content)
subprocess.run(['exportfs', '-r'], capture_output=True, timeout=10)
logger.info("NFS config updated")
return True
except Exception as e:
logger.error(f"Error writing NFS config: {e}")
return False
share_manager = SharesManager()
+588
View File
@@ -0,0 +1,588 @@
"""
System Information Service
Hostname, time, updates, CPU, memory, etc.
"""
import subprocess
import logging
import socket
import platform
from typing import Dict, Any, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
class SystemInfo:
"""Get system information"""
@staticmethod
def get_hostname() -> Dict[str, str]:
"""Get system hostname"""
try:
with open("/etc/hostname", "r") as f:
hostname = f.read().strip()
return {"hostname": hostname}
except Exception as e:
logger.error(f"Error getting hostname: {e}")
return {"error": str(e)}
@staticmethod
def set_hostname(hostname: str) -> Dict[str, str]:
"""Set system hostname"""
try:
with open("/etc/hostname", "w") as f:
f.write(hostname)
# Also update hostnamectl if available
subprocess.run(
["hostnamectl", "set-hostname", hostname],
capture_output=True,
check=False
)
logger.info(f"Set hostname to {hostname}")
return {"status": "success", "hostname": hostname}
except Exception as e:
logger.error(f"Error setting hostname: {e}")
return {"error": str(e)}
@staticmethod
def get_system_info() -> Dict[str, Any]:
"""Get general system information"""
try:
uname = platform.uname()
info = {
"hostname": socket.gethostname(),
"system": uname.system,
"kernel": uname.release,
"machine": uname.machine,
"processor": platform.processor(),
"python": platform.python_version()
}
# Get machine ID
try:
with open("/etc/machine-id", "r") as f:
info["machine_id"] = f.read().strip()
except:
pass
# Get hardware model
try:
result = subprocess.run(
["dmidecode", "-s", "system-product-name"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
info["model"] = result.stdout.strip()
except:
pass
# Get domain name
try:
result = subprocess.run(
["domainname"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
domain = result.stdout.strip()
if domain and domain != "(none)":
info["domain"] = domain
except:
pass
return info
except Exception as e:
logger.error(f"Error getting system info: {e}")
return {"error": str(e)}
@staticmethod
def get_uptime() -> Dict[str, Any]:
"""Get system uptime and boot time"""
try:
import time
with open("/proc/uptime", "r") as f:
uptime_seconds = int(float(f.read().split()[0]))
days = uptime_seconds // 86400
hours = (uptime_seconds % 86400) // 3600
minutes = (uptime_seconds % 3600) // 60
# Calculate boot timestamp
current_time = time.time()
boot_timestamp = current_time - uptime_seconds
return {
"uptime_seconds": uptime_seconds,
"uptime_string": f"{days}d {hours}h {minutes}m",
"uptime_formatted": {
"days": days,
"hours": hours,
"minutes": minutes
},
"boot_time": int(boot_timestamp)
}
except Exception as e:
logger.error(f"Error getting uptime: {e}")
return {"error": str(e)}
@staticmethod
def get_memory() -> Dict[str, Any]:
"""Get memory usage"""
try:
with open("/proc/meminfo", "r") as f:
lines = f.readlines()
meminfo = {}
for line in lines:
key, value = line.split(":")
meminfo[key.strip()] = int(value.split()[0]) * 1024 # Convert to bytes
return {
"total": meminfo.get("MemTotal", 0),
"available": meminfo.get("MemAvailable", 0),
"used": meminfo.get("MemTotal", 0) - meminfo.get("MemAvailable", 0),
"free": meminfo.get("MemFree", 0),
"swap_total": meminfo.get("SwapTotal", 0),
"swap_free": meminfo.get("SwapFree", 0),
"swap_used": meminfo.get("SwapTotal", 0) - meminfo.get("SwapFree", 0)
}
except Exception as e:
logger.error(f"Error getting memory info: {e}")
return {"error": str(e)}
@staticmethod
def get_cpu_info() -> Dict[str, Any]:
"""Get CPU information"""
try:
import psutil
except ImportError:
logger.debug("psutil not installed, using fallback")
try:
with open("/proc/cpuinfo", "r") as f:
cpuinfo_text = f.read()
cpu_count = cpuinfo_text.count("processor")
return {
"count": cpu_count,
"load_average": open("/proc/loadavg").read().split()[:3]
}
except Exception as e:
logger.error(f"Error getting CPU info: {e}")
return {"error": str(e)}
try:
return {
"count": psutil.cpu_count(),
"percent": psutil.cpu_percent(interval=1),
"load_average": [round(x, 2) for x in __import__("os").getloadavg()]
}
except Exception as e:
logger.error(f"Error getting CPU info with psutil: {e}")
return {"error": str(e)}
@staticmethod
def get_time() -> Dict[str, str]:
"""Get system time"""
try:
now = datetime.now()
return {
"iso": now.isoformat(),
"timestamp": int(now.timestamp()),
"timezone": datetime.now().astimezone().tzinfo.__str__()
}
except Exception as e:
logger.error(f"Error getting time: {e}")
return {"error": str(e)}
@staticmethod
def set_time(iso_string: str) -> Dict[str, str]:
"""Set system time (requires root)"""
try:
dt = datetime.fromisoformat(iso_string)
result = subprocess.run(
["date", "-s", dt.strftime("%Y-%m-%d %H:%M:%S")],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
return {"error": result.stderr}
# Sync hardware clock
subprocess.run(["hwclock", "--systohc"], check=False)
logger.info(f"Set time to {iso_string}")
return {"status": "success", "time": iso_string}
except Exception as e:
logger.error(f"Error setting time: {e}")
return {"error": str(e)}
@staticmethod
def get_updates() -> Dict[str, Any]:
"""Check available updates"""
try:
result = subprocess.run(
["apt", "list", "--upgradable"],
capture_output=True,
text=True,
timeout=10,
check=False
)
if result.returncode != 0:
return {"error": result.stderr}
packages = []
for line in result.stdout.split("\n")[1:]:
if line.strip():
parts = line.split("/")
if len(parts) >= 2:
packages.append({
"package": parts[0].strip(),
"current": parts[1].split("[")[0].strip() if "[" in line else ""
})
return {
"available": len(packages),
"packages": packages
}
except Exception as e:
logger.error(f"Error checking updates: {e}")
return {"error": str(e)}
@staticmethod
def reboot() -> Dict[str, str]:
"""Reboot system (requires root)"""
try:
subprocess.Popen(["shutdown", "-r", "now"])
return {"status": "success", "message": "System rebooting..."}
except Exception as e:
logger.error(f"Error rebooting: {e}")
return {"error": str(e)}
@staticmethod
def shutdown() -> Dict[str, str]:
"""Shutdown system (requires root)"""
try:
subprocess.Popen(["shutdown", "-h", "now"])
return {"status": "success", "message": "System shutting down..."}
except Exception as e:
logger.error(f"Error shutting down: {e}")
return {"error": str(e)}
@staticmethod
def get_network_info() -> Dict[str, Any]:
"""Get network interface information"""
try:
# Try ip -j addr (JSON output) first
result = subprocess.run(
["/usr/sbin/ip", "-j", "addr"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
import json
interfaces = []
try:
data = json.loads(result.stdout)
for iface in data:
addr_info = []
for addr in iface.get("addr_info", []):
addr_info.append({
"family": addr.get("family"),
"local": addr.get("local")
})
interfaces.append({
"name": iface.get("ifname"),
"state": iface.get("operstate", "UNKNOWN"),
"addresses": addr_info
})
return {"interfaces": interfaces}
except json.JSONDecodeError:
pass
# Fallback: read from /proc/net/dev
with open("/proc/net/dev", "r") as f:
lines = f.readlines()
interfaces = []
for line in lines[2:]: # Skip header lines
if ":" in line:
name = line.split(":")[0].strip()
interfaces.append({
"name": name,
"state": "UP",
"addresses": []
})
return {"interfaces": interfaces}
except Exception as e:
logger.error(f"Error getting network info: {e}")
return {"error": str(e)}
@staticmethod
def get_network_traffic() -> Dict[str, Any]:
"""Get network interface traffic (RX/TX bytes)"""
try:
with open("/proc/net/dev", "r") as f:
lines = f.readlines()
interfaces = []
# /proc/net/dev format (after colon):
# RX bytes, RX packets, RX errors, RX drops, RX fifo, RX frame, RX compressed, RX multicast,
# TX bytes, TX packets, TX errors, TX drops, TX fifo, TX collisions, TX carrier, TX compressed
for line in lines[2:]: # Skip header lines
if ":" in line:
name, stats_str = line.split(":")
name = name.strip()
stats = stats_str.split()
if len(stats) >= 16:
interfaces.append({
"name": name,
"rx_bytes": int(stats[0]),
"rx_packets": int(stats[1]),
"rx_errors": int(stats[2]),
"rx_drops": int(stats[3]),
"tx_bytes": int(stats[8]),
"tx_packets": int(stats[9]),
"tx_errors": int(stats[10]),
"tx_drops": int(stats[11])
})
return {"interfaces": interfaces}
except Exception as e:
logger.error(f"Error getting network traffic: {e}")
return {"error": str(e)}
@staticmethod
def get_disk_io() -> Dict[str, Any]:
"""Get disk I/O statistics (read/write operations and bytes)"""
try:
with open("/proc/diskstats", "r") as f:
lines = f.readlines()
disks = []
# /proc/diskstats format:
# major minor name reads_completed reads_merged reads_sectors reads_time_ms
# writes_completed writes_merged writes_sectors writes_time_ms in_progress io_time_ms weighted_io_time_ms
for line in lines:
fields = line.split()
if len(fields) >= 14:
major = int(fields[0])
minor = int(fields[1])
name = fields[2]
# Skip loop devices, ram disks, and other virtual disks
if name.startswith(('dm-', 'loop', 'ram', 'sr', 'zram')):
continue
# Only include actual storage devices (sda, sdb, nvme0n1, etc.)
if not any(name.startswith(prefix) for prefix in ['sd', 'nvme', 'hd', 'vd']):
continue
reads_completed = int(fields[3])
reads_sectors = int(fields[5])
writes_completed = int(fields[7])
writes_sectors = int(fields[9])
# Sectors are typically 512 bytes
reads_bytes = reads_sectors * 512
writes_bytes = writes_sectors * 512
disks.append({
"name": name,
"reads_completed": reads_completed,
"reads_bytes": reads_bytes,
"writes_completed": writes_completed,
"writes_bytes": writes_bytes
})
return {"disks": disks}
except Exception as e:
logger.error(f"Error getting disk I/O: {e}")
return {"error": str(e)}
@staticmethod
def get_services() -> Dict[str, Any]:
"""Get running systemd services"""
try:
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--type=service", "--state=running", "--no-pager", "--output=json"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
import json
try:
services = json.loads(result.stdout)
return {
"services": [
{
"name": svc.get("unit"),
"state": svc.get("active"),
"description": svc.get("description")
}
for svc in services if svc.get("unit", "").endswith(".service")
]
}
except json.JSONDecodeError:
pass
# Fallback: parse text output
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--type=service", "--state=running", "--no-pager"],
capture_output=True,
text=True,
timeout=10
)
services = []
for line in result.stdout.split("\n")[1:]:
if line.strip() and ".service" in line:
parts = line.split()
if len(parts) >= 2:
services.append({
"name": parts[0],
"state": "running",
"description": " ".join(parts[2:]) if len(parts) > 2 else ""
})
return {"services": services}
except Exception as e:
logger.error(f"Error getting services: {e}")
return {"error": str(e)}
@staticmethod
def get_all_units() -> Dict[str, Any]:
"""Get all systemd units (services, targets, sockets, timers, paths)"""
try:
# Get all units without filtering by state
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--all", "--no-pager", "--output=json"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
import json
try:
units_data = json.loads(result.stdout)
units = {
"services": [],
"targets": [],
"sockets": [],
"timers": [],
"paths": []
}
for unit in units_data:
name = unit.get("unit", "")
item = {
"name": name,
"active": unit.get("active"), # active/inactive
"sub": unit.get("sub"), # sub-state like "running", "exited", "enabled", etc.
"description": unit.get("description", "")
}
if name.endswith(".service"):
units["services"].append(item)
elif name.endswith(".target"):
units["targets"].append(item)
elif name.endswith(".socket"):
units["sockets"].append(item)
elif name.endswith(".timer"):
units["timers"].append(item)
elif name.endswith(".path"):
units["paths"].append(item)
return units
except json.JSONDecodeError:
pass
# Fallback: parse text output
result = subprocess.run(
["/usr/bin/systemctl", "list-units", "--all", "--no-pager"],
capture_output=True,
text=True,
timeout=10
)
units = {
"services": [],
"targets": [],
"sockets": [],
"timers": [],
"paths": []
}
for line in result.stdout.split("\n")[1:]:
if not line.strip():
continue
parts = line.split()
if len(parts) < 2:
continue
name = parts[0]
active = parts[1] if len(parts) > 1 else "unknown"
description = " ".join(parts[3:]) if len(parts) > 3 else ""
item = {
"name": name,
"active": active,
"sub": parts[2] if len(parts) > 2 else "",
"description": description
}
if name.endswith(".service"):
units["services"].append(item)
elif name.endswith(".target"):
units["targets"].append(item)
elif name.endswith(".socket"):
units["sockets"].append(item)
elif name.endswith(".timer"):
units["timers"].append(item)
elif name.endswith(".path"):
units["paths"].append(item)
return units
except Exception as e:
logger.error(f"Error getting units: {e}")
return {"error": str(e)}
@staticmethod
def get_journal_logs(limit: int = 20) -> Dict[str, Any]:
"""Get recent journal logs"""
try:
result = subprocess.run(
["/usr/bin/journalctl", "-n", str(limit), "--no-pager", "--output=short"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logs = [line.strip() for line in result.stdout.split("\n") if line.strip()]
return {"logs": logs}
return {"logs": []}
except Exception as e:
logger.error(f"Error getting journal logs: {e}")
return {"error": str(e)}
# Global instance
system_info = SystemInfo()
+221
View File
@@ -0,0 +1,221 @@
"""
System User and Group Management
Wrapper around /etc/passwd, /etc/group, useradd, groupadd, etc.
"""
import subprocess
import logging
import pwd
import grp
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
class SystemUserManager:
"""Manage system users and groups"""
def list_users(self) -> List[Dict[str, Any]]:
"""List all system users"""
users = []
try:
for entry in pwd.getwall():
users.append({
"username": entry.pw_name,
"uid": entry.pw_uid,
"gid": entry.pw_gid,
"gecos": entry.pw_gecos,
"home": entry.pw_dir,
"shell": entry.pw_shell
})
except Exception as e:
logger.error(f"Error listing users: {e}")
return sorted(users, key=lambda u: u["uid"])
def list_groups(self) -> List[Dict[str, Any]]:
"""List all system groups"""
groups = []
try:
for entry in grp.getgrall():
groups.append({
"groupname": entry.gr_name,
"gid": entry.gr_gid,
"members": entry.gr_mem
})
except Exception as e:
logger.error(f"Error listing groups: {e}")
return sorted(groups, key=lambda g: g["gid"])
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user details"""
try:
entry = pwd.getpwnam(username)
return {
"username": entry.pw_name,
"uid": entry.pw_uid,
"gid": entry.pw_gid,
"gecos": entry.pw_gecos,
"home": entry.pw_dir,
"shell": entry.pw_shell
}
except KeyError:
return None
except Exception as e:
logger.error(f"Error getting user {username}: {e}")
return None
def get_group(self, groupname: str) -> Optional[Dict[str, Any]]:
"""Get group details"""
try:
entry = grp.getgrnam(groupname)
return {
"groupname": entry.gr_name,
"gid": entry.gr_gid,
"members": entry.gr_mem
}
except KeyError:
return None
except Exception as e:
logger.error(f"Error getting group {groupname}: {e}")
return None
def create_user(
self,
username: str,
password: str,
home_dir: Optional[str] = None,
shell: str = "/bin/bash",
groups: Optional[List[str]] = None
) -> Dict[str, str]:
"""Create new system user"""
try:
cmd = ["useradd"]
if home_dir:
cmd.extend(["-d", home_dir])
cmd.extend(["-s", shell])
if groups:
cmd.extend(["-G", ",".join(groups)])
cmd.append(username)
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
logger.error(f"useradd failed: {result.stderr}")
return {"error": result.stderr}
# Set password
if password:
# Use chpasswd for password setting
passwd_cmd = subprocess.Popen(
["chpasswd"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
_, err = passwd_cmd.communicate(input=f"{username}:{password}\n")
if passwd_cmd.returncode != 0:
logger.error(f"chpasswd failed: {err}")
return {"error": f"User created but password failed: {err}"}
logger.info(f"Created user: {username}")
return {"status": "success", "username": username}
except Exception as e:
logger.error(f"Error creating user: {e}")
return {"error": str(e)}
def delete_user(self, username: str, remove_home: bool = False) -> Dict[str, str]:
"""Delete system user"""
try:
cmd = ["userdel"]
if remove_home:
cmd.append("-r")
cmd.append(username)
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
logger.error(f"userdel failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Deleted user: {username}")
return {"status": "success", "message": f"User {username} deleted"}
except Exception as e:
logger.error(f"Error deleting user: {e}")
return {"error": str(e)}
def create_group(self, groupname: str) -> Dict[str, str]:
"""Create new system group"""
try:
result = subprocess.run(
["groupadd", groupname],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
logger.error(f"groupadd failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Created group: {groupname}")
return {"status": "success", "groupname": groupname}
except Exception as e:
logger.error(f"Error creating group: {e}")
return {"error": str(e)}
def delete_group(self, groupname: str) -> Dict[str, str]:
"""Delete system group"""
try:
result = subprocess.run(
["groupdel", groupname],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
logger.error(f"groupdel failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Deleted group: {groupname}")
return {"status": "success", "message": f"Group {groupname} deleted"}
except Exception as e:
logger.error(f"Error deleting group: {e}")
return {"error": str(e)}
def add_user_to_group(self, username: str, groupname: str) -> Dict[str, str]:
"""Add user to group"""
try:
result = subprocess.run(
["usermod", "-aG", groupname, username],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
logger.error(f"usermod failed: {result.stderr}")
return {"error": result.stderr}
logger.info(f"Added {username} to {groupname}")
return {"status": "success", "message": f"{username} added to {groupname}"}
except Exception as e:
logger.error(f"Error adding user to group: {e}")
return {"error": str(e)}
# Global instance
system_user_manager = SystemUserManager()
+464
View File
@@ -0,0 +1,464 @@
"""
ZFS Command Runner Wrapper für zpool/zfs CLI Commands
Handles subprocess execution, parsing, caching, error handling
"""
import subprocess
import json
import logging
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime, timedelta
import re
logger = logging.getLogger(__name__)
# Cache with TTL
@dataclass
class CacheEntry:
data: Any
expires_at: datetime
class ZFSCache:
def __init__(self):
self.pool_status = CacheEntry(None, datetime.now())
self.snapshots = CacheEntry(None, datetime.now())
self.datasets = CacheEntry(None, datetime.now())
def get(self, key: str) -> Optional[Any]:
cache_dict = {
"pool_status": self.pool_status,
"snapshots": self.snapshots,
"datasets": self.datasets,
}
if key not in cache_dict:
return None
entry = cache_dict[key]
if not entry or not entry.data:
return None
if datetime.now() > entry.expires_at:
return None
return entry.data
def set(self, key: str, data: Any, ttl_seconds: int = 60):
cache_dict = {
"pool_status": self.pool_status,
"snapshots": self.snapshots,
"datasets": self.datasets,
}
if key in cache_dict:
cache_dict[key].data = data
cache_dict[key].expires_at = datetime.now() + timedelta(seconds=ttl_seconds)
class ZFSRunner:
def __init__(self, timeout: int = 5):
self.timeout = timeout
self.cache = ZFSCache()
def run_command(self, cmd: List[str], timeout: Optional[int] = None) -> Tuple[str, str, int]:
"""
Run subprocess command with timeout
Returns: (stdout, stderr, returncode)
"""
if timeout is None:
timeout = self.timeout
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False
)
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
logger.error(f"Command timeout after {timeout}s: {' '.join(cmd)}")
return "", f"Command timeout after {timeout}s", -1
except FileNotFoundError:
logger.error(f"Command not found: {' '.join(cmd)}")
return "", f"Command not found: {cmd[0]}", -1
except Exception as e:
logger.error(f"Command execution error: {e}")
return "", str(e), -1
# ============== POOL OPERATIONS ==============
def list_pools(self) -> List[Dict[str, Any]]:
"""
Get list of ZFS pools with status
Uses cache (TTL 30s)
"""
cached = self.cache.get("pool_status")
if cached:
return cached
stdout, stderr, rc = self.run_command(["zpool", "list", "-H", "-p"])
if rc != 0:
logger.error(f"zpool list failed: {stderr}")
return []
pools = []
for line in stdout.strip().split("\n"):
if not line:
continue
parts = line.split()
if len(parts) < 10:
continue
pool = {
"name": parts[0],
"size": int(parts[1]),
"alloc": int(parts[2]),
"free": int(parts[3]),
"fragmentation": parts[7],
"capacity": parts[8],
"health": parts[9]
}
pools.append(pool)
self.cache.set("pool_status", pools, ttl_seconds=30)
return pools
def _parse_vdev_tree(self, config_lines: List[str]) -> List[Dict[str, Any]]:
"""
Parse VDEV tree from zpool status config section.
Uses indentation levels to reconstruct hierarchy.
Returns list of vdev dicts with name, state, and error counters (read/write/cksum).
"""
roots: List[Dict] = []
stack: List[tuple] = [] # (indent, vdev_dict)
for line in config_lines:
if not line.strip():
continue
# Skip header line (NAME STATE READ WRITE CKSUM)
if line.strip().startswith("NAME"):
continue
indent = len(line) - len(line.lstrip())
parts = line.split()
if not parts:
continue
name = parts[0]
state = parts[1] if len(parts) > 1 else "UNKNOWN"
# Parse error counters and convert to integers
read = 0
write = 0
cksum = 0
if len(parts) > 2:
try:
read = int(parts[2])
except (ValueError, IndexError):
read = 0
if len(parts) > 3:
try:
write = int(parts[3])
except (ValueError, IndexError):
write = 0
if len(parts) > 4:
try:
cksum = int(parts[4])
except (ValueError, IndexError):
cksum = 0
vdev: Dict[str, Any] = {
"name": name,
"state": state,
"read": read,
"write": write,
"cksum": cksum,
"children": []
}
# Pop stack entries that are at same or deeper indent
while stack and stack[-1][0] >= indent:
stack.pop()
if stack:
stack[-1][1]["children"].append(vdev)
else:
roots.append(vdev)
stack.append((indent, vdev))
return roots
def get_pool_status(self, pool_name: str) -> Dict[str, Any]:
"""
Get detailed pool status including VDEV tree and error counters
"""
stdout, stderr, rc = self.run_command(["zpool", "status", pool_name])
if rc != 0:
logger.error(f"zpool status failed for {pool_name}: {stderr}")
return {}
status: Dict[str, Any] = {
"name": pool_name,
"state": None,
"scan": None,
"errors": None,
"vdevs": [],
}
lines = stdout.split("\n")
in_config = False
config_lines: List[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("state:"):
status["state"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("scan:"):
status["scan"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("errors:"):
status["errors"] = stripped.split(":", 1)[1].strip()
in_config = False
elif stripped == "config:":
in_config = True
elif in_config:
# Collect config block lines (skip blank lines at start)
if stripped or config_lines:
config_lines.append(line)
if config_lines:
# Remove the pool name itself (first non-empty line after "NAME" header)
# and parse only child vdevs
parsed = self._parse_vdev_tree(config_lines)
# parsed[0] is the pool root node; its children are the top-level vdevs
if parsed and parsed[0]["name"] == pool_name:
status["vdevs"] = parsed[0]["children"]
else:
status["vdevs"] = parsed
return status
def scrub_pool(self, pool_name: str) -> Dict[str, str]:
"""
Start or resume scrub on pool
"""
stdout, stderr, rc = self.run_command(["zpool", "scrub", pool_name])
if rc != 0:
logger.error(f"zpool scrub failed for {pool_name}: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Scrub started for {pool_name}"}
# ============== DATASET/FILESYSTEM OPERATIONS ==============
def list_datasets(self, pool_name: str, max_depth: int = 2) -> List[Dict[str, Any]]:
"""
List datasets in pool (with depth limit for performance)
"""
cached = self.cache.get("datasets")
if cached and cached.get(pool_name):
return cached[pool_name]
stdout, stderr, rc = self.run_command([
"zfs", "list", "-d", str(max_depth), "-H", "-p",
"-o", "name,used,avail,refer,mountpoint,type",
pool_name
])
if rc != 0:
logger.error(f"zfs list failed for {pool_name}: {stderr}")
return []
datasets = []
for line in stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) < 6:
continue
dataset = {
"name": parts[0],
"used": int(parts[1]),
"avail": int(parts[2]),
"refer": int(parts[3]),
"mountpoint": parts[4],
"type": parts[5] # filesystem, volume, snapshot
}
datasets.append(dataset)
# Cache per pool
if not cached:
cached = {}
cached[pool_name] = datasets
self.cache.set("datasets", cached, ttl_seconds=60)
return datasets
def create_dataset(self, dataset_name: str, props: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""
Create new ZFS dataset/filesystem
"""
cmd = ["zfs", "create"]
if props:
for key, val in props.items():
cmd.extend(["-o", f"{key}={val}"])
cmd.append(dataset_name)
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs create failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Dataset {dataset_name} created"}
def set_dataset_properties(self, dataset_name: str, props: Dict[str, str]) -> Dict[str, str]:
"""Set ZFS dataset properties (compression, quota, reservation, etc.)"""
errors = []
for key, value in props.items():
if value is None:
continue
stdout, stderr, rc = self.run_command(["zfs", "set", f"{key}={value}", dataset_name])
if rc != 0:
errors.append(f"{key}: {stderr.strip()}")
if errors:
return {"status": "error", "message": "; ".join(errors)}
return {"status": "success", "message": f"Properties updated for {dataset_name}"}
def destroy_dataset(self, dataset_name: str, recursive: bool = False) -> Dict[str, str]:
"""
Destroy ZFS dataset
"""
cmd = ["zfs", "destroy"]
if recursive:
cmd.append("-r")
cmd.append(dataset_name)
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs destroy failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Dataset {dataset_name} destroyed"}
# ============== SNAPSHOT OPERATIONS ==============
def list_snapshots(self, dataset_name: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
"""
List snapshots (with limit for performance on many snapshots)
"""
cached = self.cache.get("snapshots")
if cached:
return cached
# If no dataset specified, list all
if dataset_name:
cmd = ["zfs", "list", "-t", "snapshot", "-d", "1", "-H", "-p",
"-o", "name,used,referenced,creation", dataset_name]
else:
# Get all snapshots, limited
cmd = ["zfs", "list", "-t", "snapshot", "-H", "-p",
"-o", "name,used,referenced,creation"]
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs list snapshots failed: {stderr}")
return []
snapshots = []
for line in stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) < 4:
continue
snapshot = {
"name": parts[0],
"used": int(parts[1]),
"referenced": int(parts[2]),
"creation": int(parts[3]), # Unix timestamp
}
snapshots.append(snapshot)
# Sort by creation time (newest first) and limit
snapshots.sort(key=lambda x: x["creation"], reverse=True)
snapshots = snapshots[:limit]
self.cache.set("snapshots", snapshots, ttl_seconds=60)
return snapshots
def create_snapshot(self, dataset_name: str, snapshot_name: Optional[str] = None) -> Dict[str, str]:
"""
Create snapshot with auto-generated name if not provided
"""
if not snapshot_name:
from datetime import datetime as dt
snapshot_name = dt.now().strftime("%Y%m%d-%H%M%S")
full_name = f"{dataset_name}@{snapshot_name}"
stdout, stderr, rc = self.run_command(["zfs", "snapshot", full_name])
if rc != 0:
logger.error(f"zfs snapshot failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Snapshot {full_name} created"}
def destroy_snapshot(self, snapshot_name: str, recursive: bool = False) -> Dict[str, str]:
"""
Destroy snapshot
"""
cmd = ["zfs", "destroy"]
if recursive:
cmd.append("-r")
cmd.append(snapshot_name)
stdout, stderr, rc = self.run_command(cmd)
if rc != 0:
logger.error(f"zfs destroy snapshot failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Snapshot {snapshot_name} destroyed"}
def rollback_snapshot(self, snapshot_name: str) -> Dict[str, str]:
"""
Rollback dataset to snapshot
WARNING: Destroys data after snapshot!
"""
stdout, stderr, rc = self.run_command(["zfs", "rollback", "-r", snapshot_name])
if rc != 0:
logger.error(f"zfs rollback failed: {stderr}")
return {"status": "error", "message": stderr}
return {"status": "success", "message": f"Rolled back to {snapshot_name}"}
# ============== UTILITY ==============
def clear_cache(self):
"""Clear all caches"""
self.cache = ZFSCache()
logger.info("ZFS cache cleared")
# Global instance
zfs_runner = ZFSRunner()