add installer alpha version

This commit is contained in:
2025-12-15 16:38:20 +07:00
parent 732e5aca11
commit b4ef76f0d0
23 changed files with 4279 additions and 136 deletions

View File

@@ -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"})

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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; }

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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)

View 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),
})
}