add installer alpha version
This commit is contained in:
@@ -36,6 +36,10 @@ func (a *App) handleListPools(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Ensure we always return an array, not null
|
||||
if pools == nil {
|
||||
pools = []models.Pool{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pools)
|
||||
}
|
||||
|
||||
@@ -215,6 +219,10 @@ func (a *App) handleListDatasets(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Ensure we always return an array, not null
|
||||
if datasets == nil {
|
||||
datasets = []models.Dataset{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, datasets)
|
||||
}
|
||||
|
||||
@@ -398,6 +406,10 @@ func (a *App) handleListSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Ensure we always return an array, not null
|
||||
if snapshots == nil {
|
||||
snapshots = []models.Snapshot{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, snapshots)
|
||||
}
|
||||
|
||||
@@ -485,6 +497,10 @@ func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request)
|
||||
} else {
|
||||
policies = a.snapshotPolicy.List()
|
||||
}
|
||||
// Ensure we always return an array, not null
|
||||
if policies == nil {
|
||||
policies = []models.SnapshotPolicy{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, policies)
|
||||
}
|
||||
|
||||
@@ -1322,6 +1338,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
req.Role = models.RoleViewer // Default role
|
||||
}
|
||||
|
||||
// Normalize role to lowercase for comparison
|
||||
roleStr := strings.ToLower(string(req.Role))
|
||||
req.Role = models.Role(roleStr)
|
||||
|
||||
// Validate role
|
||||
if req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
||||
@@ -1376,6 +1396,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize role to lowercase if provided
|
||||
if req.Role != "" {
|
||||
roleStr := strings.ToLower(string(req.Role))
|
||||
req.Role = models.Role(roleStr)
|
||||
}
|
||||
|
||||
// Validate role if provided
|
||||
if req.Role != "" && req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
||||
|
||||
@@ -55,11 +55,40 @@ type App struct {
|
||||
}
|
||||
|
||||
func New(cfg Config) (*App, error) {
|
||||
// Resolve paths relative to executable or current working directory
|
||||
if cfg.TemplatesDir == "" {
|
||||
cfg.TemplatesDir = "web/templates"
|
||||
// Try multiple locations for templates
|
||||
possiblePaths := []string{
|
||||
"web/templates",
|
||||
"./web/templates",
|
||||
"/opt/atlas/web/templates",
|
||||
}
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
cfg.TemplatesDir = path
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.TemplatesDir == "" {
|
||||
cfg.TemplatesDir = "web/templates" // Default fallback
|
||||
}
|
||||
}
|
||||
if cfg.StaticDir == "" {
|
||||
cfg.StaticDir = "web/static"
|
||||
// Try multiple locations for static files
|
||||
possiblePaths := []string{
|
||||
"web/static",
|
||||
"./web/static",
|
||||
"/opt/atlas/web/static",
|
||||
}
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
cfg.StaticDir = path
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.StaticDir == "" {
|
||||
cfg.StaticDir = "web/static" // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := parseTemplates(cfg.TemplatesDir)
|
||||
@@ -228,6 +257,12 @@ func parseTemplates(dir string) (*template.Template, error) {
|
||||
|
||||
funcs := template.FuncMap{
|
||||
"nowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
|
||||
"getContentTemplate": func(data map[string]any) string {
|
||||
if ct, ok := data["ContentTemplate"].(string); ok && ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "content"
|
||||
},
|
||||
}
|
||||
|
||||
t := template.New("root").Funcs(funcs)
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
// authMiddleware validates JWT tokens and extracts user info
|
||||
func (a *App) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for public endpoints
|
||||
// Skip auth for public endpoints (includes web UI pages and read-only GET endpoints)
|
||||
if a.isPublicEndpoint(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -101,14 +101,27 @@ func (a *App) requireRole(allowedRoles ...models.Role) func(http.Handler) http.H
|
||||
func (a *App) isPublicEndpoint(path string) bool {
|
||||
publicPaths := []string{
|
||||
"/healthz",
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/logout",
|
||||
"/", // Dashboard (can be made protected later)
|
||||
"/", // Dashboard
|
||||
"/login", // Login page
|
||||
"/storage", // Storage management page
|
||||
"/shares", // Shares page
|
||||
"/iscsi", // iSCSI page
|
||||
"/protection", // Data Protection page
|
||||
"/management", // System Management page
|
||||
"/api/docs", // API documentation
|
||||
"/api/openapi.yaml", // OpenAPI spec
|
||||
}
|
||||
|
||||
for _, publicPath := range publicPaths {
|
||||
if path == publicPath || strings.HasPrefix(path, publicPath+"/") {
|
||||
if path == publicPath {
|
||||
return true
|
||||
}
|
||||
// Also allow paths that start with public paths (for sub-pages)
|
||||
if strings.HasPrefix(path, publicPath+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -118,6 +131,28 @@ func (a *App) isPublicEndpoint(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Make read-only GET endpoints public for web UI (but require auth for mutations)
|
||||
// This allows the UI to display data without login, but operations require auth
|
||||
publicReadOnlyPaths := []string{
|
||||
"/api/v1/dashboard", // Dashboard data
|
||||
"/api/v1/disks", // List disks
|
||||
"/api/v1/pools", // List pools (GET only)
|
||||
"/api/v1/pools/available", // List available pools
|
||||
"/api/v1/datasets", // List datasets (GET only)
|
||||
"/api/v1/zvols", // List ZVOLs (GET only)
|
||||
"/api/v1/shares/smb", // List SMB shares (GET only)
|
||||
"/api/v1/exports/nfs", // List NFS exports (GET only)
|
||||
"/api/v1/iscsi/targets", // List iSCSI targets (GET only)
|
||||
"/api/v1/snapshots", // List snapshots (GET only)
|
||||
"/api/v1/snapshot-policies", // List snapshot policies (GET only)
|
||||
}
|
||||
|
||||
for _, publicPath := range publicReadOnlyPaths {
|
||||
if path == publicPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ func (a *App) handleAPIDocs(w http.ResponseWriter, r *http.Request) {
|
||||
html := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>atlasOS API Documentation</title>
|
||||
<title>AtlasOS API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
|
||||
<style>
|
||||
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
||||
|
||||
@@ -17,6 +17,72 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, "dashboard.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleStorage(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Storage Management",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "storage-content",
|
||||
}
|
||||
a.render(w, "storage.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleShares(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Storage Shares",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "shares-content",
|
||||
}
|
||||
a.render(w, "shares.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleISCSI(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "iSCSI Targets",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "iscsi-content",
|
||||
}
|
||||
a.render(w, "iscsi.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleProtection(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Data Protection",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "protection-content",
|
||||
}
|
||||
a.render(w, "protection.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleManagement(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "System Management",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "management-content",
|
||||
}
|
||||
a.render(w, "management.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Title": "Login",
|
||||
"Build": map[string]string{
|
||||
"version": "v0.1.0-dev",
|
||||
},
|
||||
"ContentTemplate": "login-content",
|
||||
}
|
||||
a.render(w, "login.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := r.Context().Value(requestIDKey).(string)
|
||||
resp := map[string]any{
|
||||
|
||||
@@ -13,6 +13,12 @@ func (a *App) routes() {
|
||||
|
||||
// Web UI
|
||||
a.mux.HandleFunc("/", a.handleDashboard)
|
||||
a.mux.HandleFunc("/login", a.handleLoginPage)
|
||||
a.mux.HandleFunc("/storage", a.handleStorage)
|
||||
a.mux.HandleFunc("/shares", a.handleShares)
|
||||
a.mux.HandleFunc("/iscsi", a.handleISCSI)
|
||||
a.mux.HandleFunc("/protection", a.handleProtection)
|
||||
a.mux.HandleFunc("/management", a.handleManagement)
|
||||
|
||||
// Health & metrics
|
||||
a.mux.HandleFunc("/healthz", a.handleHealthz)
|
||||
@@ -173,6 +179,61 @@ func (a *App) routes() {
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/users/", a.handleUserOpsWithAuth)
|
||||
|
||||
// Service Management (requires authentication, admin-only)
|
||||
a.mux.HandleFunc("/api/v1/services", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleListServices)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/status", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStatus)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/start", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStart)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/stop", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStop)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/restart", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceRestart)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/reload", methodHandler(
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceReload)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil,
|
||||
))
|
||||
a.mux.HandleFunc("/api/v1/services/logs", methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
adminRole := models.RoleAdministrator
|
||||
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceLogs)).ServeHTTP(w, r)
|
||||
},
|
||||
nil, nil, nil, nil,
|
||||
))
|
||||
|
||||
// Audit Logs
|
||||
a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ func (a *App) securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Content Security Policy (CSP)
|
||||
csp := "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self';"
|
||||
// Allow Tailwind CDN, unpkg (for htmx), and jsdelivr for external resources
|
||||
// Note: Tailwind CDN needs connect-src to fetch config and make network requests
|
||||
csp := "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; connect-src 'self' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net;"
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
249
internal/httpapp/service_handlers.go
Normal file
249
internal/httpapp/service_handlers.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ManagedService represents a service with its status
|
||||
type ManagedService struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// getServiceName extracts service name from query parameter or defaults to atlas-api
|
||||
func getServiceName(r *http.Request) string {
|
||||
serviceName := r.URL.Query().Get("service")
|
||||
if serviceName == "" {
|
||||
return "atlas-api"
|
||||
}
|
||||
return serviceName
|
||||
}
|
||||
|
||||
// getAllServices returns list of all managed services
|
||||
func getAllServices() []ManagedService {
|
||||
services := []ManagedService{
|
||||
{Name: "atlas-api", DisplayName: "AtlasOS API"},
|
||||
{Name: "smbd", DisplayName: "SMB/CIFS (Samba)"},
|
||||
{Name: "nfs-server", DisplayName: "NFS Server"},
|
||||
{Name: "target", DisplayName: "iSCSI Target"},
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
// getServiceStatus returns the status of a specific service
|
||||
func getServiceStatus(serviceName string) (string, string, error) {
|
||||
cmd := exec.Command("systemctl", "status", serviceName, "--no-pager", "-l")
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
if err != nil {
|
||||
// systemctl status returns non-zero exit code even when service is running
|
||||
// Check if output contains "active (running)" to determine actual status
|
||||
if strings.Contains(outputStr, "active (running)") {
|
||||
return "running", outputStr, nil
|
||||
}
|
||||
if strings.Contains(outputStr, "inactive (dead)") {
|
||||
return "stopped", outputStr, nil
|
||||
}
|
||||
if strings.Contains(outputStr, "failed") {
|
||||
return "failed", outputStr, nil
|
||||
}
|
||||
// Service might not exist
|
||||
if strings.Contains(outputStr, "could not be found") || strings.Contains(outputStr, "not found") {
|
||||
return "not-found", outputStr, nil
|
||||
}
|
||||
return "unknown", outputStr, err
|
||||
}
|
||||
|
||||
status := "unknown"
|
||||
if strings.Contains(outputStr, "active (running)") {
|
||||
status = "running"
|
||||
} else if strings.Contains(outputStr, "inactive (dead)") {
|
||||
status = "stopped"
|
||||
} else if strings.Contains(outputStr, "failed") {
|
||||
status = "failed"
|
||||
}
|
||||
|
||||
return status, outputStr, nil
|
||||
}
|
||||
|
||||
// handleListServices returns the status of all services
|
||||
func (a *App) handleListServices(w http.ResponseWriter, r *http.Request) {
|
||||
allServices := getAllServices()
|
||||
servicesStatus := make([]ManagedService, 0, len(allServices))
|
||||
|
||||
for _, svc := range allServices {
|
||||
status, output, err := getServiceStatus(svc.Name)
|
||||
if err != nil {
|
||||
log.Printf("error getting status for %s: %v", svc.Name, err)
|
||||
status = "error"
|
||||
}
|
||||
servicesStatus = append(servicesStatus, ManagedService{
|
||||
Name: svc.Name,
|
||||
DisplayName: svc.DisplayName,
|
||||
Status: status,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"services": servicesStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStatus returns the status of a specific service
|
||||
func (a *App) handleServiceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
status, output, err := getServiceStatus(serviceName)
|
||||
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to get service status",
|
||||
"details": output,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"service": serviceName,
|
||||
"status": status,
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStart starts a service
|
||||
func (a *App) handleServiceStart(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
// Special handling for SMB - use smbcontrol for reload, but systemctl for start/stop
|
||||
if serviceName == "smbd" {
|
||||
cmd = exec.Command("systemctl", "start", "smbd")
|
||||
} else {
|
||||
cmd = exec.Command("systemctl", "start", serviceName)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service start error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to start service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service started successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceStop stops a service
|
||||
func (a *App) handleServiceStop(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
cmd := exec.Command("systemctl", "stop", serviceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service stop error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to stop service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service stopped successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceRestart restarts a service
|
||||
func (a *App) handleServiceRestart(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
cmd := exec.Command("systemctl", "restart", serviceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service restart error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to restart service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service restarted successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceReload reloads a service
|
||||
func (a *App) handleServiceReload(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
// Special handling for SMB - use smbcontrol for reload
|
||||
if serviceName == "smbd" {
|
||||
cmd = exec.Command("smbcontrol", "all", "reload-config")
|
||||
} else {
|
||||
cmd = exec.Command("systemctl", "reload", serviceName)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service reload error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to reload service",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "service reloaded successfully",
|
||||
"service": serviceName,
|
||||
})
|
||||
}
|
||||
|
||||
// handleServiceLogs returns the logs of a service
|
||||
func (a *App) handleServiceLogs(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := getServiceName(r)
|
||||
|
||||
// Get number of lines from query parameter (default: 50)
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
lines := "50"
|
||||
if linesStr != "" {
|
||||
if n, err := strconv.Atoi(linesStr); err == nil && n > 0 && n <= 1000 {
|
||||
lines = linesStr
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("journalctl", "-u", serviceName, "-n", lines, "--no-pager")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service logs error for %s: %v", serviceName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"error": "failed to get service logs",
|
||||
"service": serviceName,
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"service": serviceName,
|
||||
"logs": string(output),
|
||||
})
|
||||
}
|
||||
@@ -20,12 +20,35 @@ type Service struct {
|
||||
|
||||
// New creates a new ZFS service
|
||||
func New() *Service {
|
||||
// Find full paths to zfs and zpool commands
|
||||
zfsPath := findCommandPath("zfs")
|
||||
zpoolPath := findCommandPath("zpool")
|
||||
|
||||
return &Service{
|
||||
zfsPath: "zfs",
|
||||
zpoolPath: "zpool",
|
||||
zfsPath: zfsPath,
|
||||
zpoolPath: zpoolPath,
|
||||
}
|
||||
}
|
||||
|
||||
// findCommandPath finds the full path to a command
|
||||
func findCommandPath(cmd string) string {
|
||||
// Try which first
|
||||
if output, err := exec.Command("which", cmd).Output(); err == nil {
|
||||
path := strings.TrimSpace(string(output))
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Try LookPath
|
||||
if path, err := exec.LookPath(cmd); err == nil {
|
||||
return path
|
||||
}
|
||||
|
||||
// Fallback to command name (will use PATH)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// execCommand executes a shell command and returns output
|
||||
// For ZFS operations that require elevated privileges, it uses sudo
|
||||
func (s *Service) execCommand(name string, args ...string) (string, error) {
|
||||
@@ -42,8 +65,9 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if useSudo {
|
||||
// Use sudo for privileged commands
|
||||
sudoArgs := append([]string{name}, args...)
|
||||
// Use sudo -n (non-interactive) for privileged commands
|
||||
// This prevents password prompts and will fail if sudoers is not configured
|
||||
sudoArgs := append([]string{"-n", name}, args...)
|
||||
cmd = exec.Command("sudo", sudoArgs...)
|
||||
} else {
|
||||
cmd = exec.Command(name, args...)
|
||||
@@ -53,7 +77,24 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
err := cmd.Run()
|
||||
if err != nil && useSudo {
|
||||
// If sudo failed, try running the command directly
|
||||
// (user might already have permissions or be root)
|
||||
directCmd := exec.Command(name, args...)
|
||||
var directStdout, directStderr bytes.Buffer
|
||||
directCmd.Stdout = &directStdout
|
||||
directCmd.Stderr = &directStderr
|
||||
|
||||
if directErr := directCmd.Run(); directErr == nil {
|
||||
// Direct execution succeeded, return that result
|
||||
return strings.TrimSpace(directStdout.String()), nil
|
||||
}
|
||||
// Both sudo and direct failed, return the original sudo error
|
||||
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
||||
}
|
||||
|
||||
@@ -64,10 +105,11 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
|
||||
func (s *Service) ListPools() ([]models.Pool, error) {
|
||||
output, err := s.execCommand(s.zpoolPath, "list", "-H", "-o", "name,size,allocated,free,health")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.Pool{}, err
|
||||
}
|
||||
|
||||
var pools []models.Pool
|
||||
pools := []models.Pool{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
@@ -347,10 +389,11 @@ func (s *Service) ListDatasets(pool string) ([]models.Dataset, error) {
|
||||
|
||||
output, err := s.execCommand(s.zfsPath, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.Dataset{}, err
|
||||
}
|
||||
|
||||
var datasets []models.Dataset
|
||||
datasets := []models.Dataset{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
@@ -428,10 +471,11 @@ func (s *Service) ListZVOLs(pool string) ([]models.ZVOL, error) {
|
||||
|
||||
output, err := s.execCommand(s.zfsPath, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.ZVOL{}, err
|
||||
}
|
||||
|
||||
var zvols []models.ZVOL
|
||||
zvols := []models.ZVOL{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
@@ -588,10 +632,11 @@ func (s *Service) ListSnapshots(dataset string) ([]models.Snapshot, error) {
|
||||
|
||||
output, err := s.execCommand(s.zfsPath, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||
return []models.Snapshot{}, err
|
||||
}
|
||||
|
||||
var snapshots []models.Snapshot
|
||||
snapshots := []models.Snapshot{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
|
||||
Reference in New Issue
Block a user