Files
atlas/internal/httpapp/api_handlers.go

1477 lines
43 KiB
Go

package httpapp
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/auth"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/errors"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/storage"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/validation"
)
// pathParam is now in router_helpers.go
// Disk Handlers
func (a *App) handleListDisks(w http.ResponseWriter, r *http.Request) {
disks, err := a.zfs.ListDisks()
if err != nil {
log.Printf("list disks error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, disks)
}
// ZFS Pool Handlers
func (a *App) handleListPools(w http.ResponseWriter, r *http.Request) {
pools, err := a.zfs.ListPools()
if err != nil {
log.Printf("list pools error: %v", err)
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)
}
func (a *App) handleCreatePool(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
VDEVs []string `json:"vdevs"`
Options map[string]string `json:"options,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, errors.ErrBadRequest("invalid request body"))
return
}
// Validate pool name
if err := validation.ValidateZFSName(req.Name); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
if len(req.VDEVs) == 0 {
writeError(w, errors.ErrValidation("at least one vdev is required"))
return
}
if req.Options == nil {
req.Options = make(map[string]string)
}
if err := a.zfs.CreatePool(req.Name, req.VDEVs, req.Options); err != nil {
log.Printf("create pool error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
pool, err := a.zfs.GetPool(req.Name)
if err != nil {
writeJSON(w, http.StatusCreated, map[string]string{"message": "pool created", "name": req.Name})
return
}
writeJSON(w, http.StatusCreated, pool)
}
func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "pool name required"})
return
}
pool, err := a.zfs.GetPool(name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, pool)
}
func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if err := a.zfs.DestroyPool(name); err != nil {
log.Printf("destroy pool error: %v", err)
writeError(w, errors.ErrInternal("failed to destroy pool").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "pool destroyed", "name": name})
}
func (a *App) handleImportPool(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Options map[string]string `json:"options,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, errors.ErrBadRequest("invalid request body").WithDetails(err.Error()))
return
}
if req.Name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if err := a.zfs.ImportPool(req.Name, req.Options); err != nil {
log.Printf("import pool error: %v", err)
writeError(w, errors.ErrInternal("failed to import pool").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "pool imported", "name": req.Name})
}
func (a *App) handleExportPool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
var req struct {
Force bool `json:"force,omitempty"`
}
// Force is optional, decode if body exists
_ = json.NewDecoder(r.Body).Decode(&req)
if err := a.zfs.ExportPool(name, req.Force); err != nil {
log.Printf("export pool error: %v", err)
writeError(w, errors.ErrInternal("failed to export pool").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "pool exported", "name": name})
}
func (a *App) handleListAvailablePools(w http.ResponseWriter, r *http.Request) {
pools, err := a.zfs.ListAvailablePools()
if err != nil {
log.Printf("list available pools error: %v", err)
writeError(w, errors.ErrInternal("failed to list available pools").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"pools": pools,
})
}
func (a *App) handleScrubPool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
if err := a.zfs.ScrubPool(name); err != nil {
log.Printf("scrub pool error: %v", err)
writeError(w, errors.ErrInternal("failed to start scrub").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "scrub started", "pool": name})
}
func (a *App) handleGetScrubStatus(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
status, err := a.zfs.GetScrubStatus(name)
if err != nil {
log.Printf("get scrub status error: %v", err)
writeError(w, errors.ErrInternal("failed to get scrub status").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, status)
}
// Dataset Handlers
func (a *App) handleListDatasets(w http.ResponseWriter, r *http.Request) {
pool := r.URL.Query().Get("pool")
datasets, err := a.zfs.ListDatasets(pool)
if err != nil {
log.Printf("list datasets error: %v", err)
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)
}
func (a *App) handleCreateDataset(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Options map[string]string `json:"options,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset name is required"})
return
}
if req.Options == nil {
req.Options = make(map[string]string)
}
if err := a.zfs.CreateDataset(req.Name, req.Options); err != nil {
log.Printf("create dataset error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"message": "dataset created", "name": req.Name})
}
func (a *App) handleGetDataset(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/datasets/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset name required"})
return
}
datasets, err := a.zfs.ListDatasets("")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
for _, ds := range datasets {
if ds.Name == name {
writeJSON(w, http.StatusOK, ds)
return
}
}
writeJSON(w, http.StatusNotFound, map[string]string{"error": "dataset not found"})
}
func (a *App) handleUpdateDataset(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/datasets/")
// TODO: Implement dataset property updates
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "name": name})
}
func (a *App) handleDeleteDataset(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/datasets/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset name required"})
return
}
recursive := r.URL.Query().Get("recursive") == "true"
if err := a.zfs.DestroyDataset(name, recursive); err != nil {
log.Printf("destroy dataset error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "dataset destroyed", "name": name})
}
// ZVOL Handlers
func (a *App) handleListZVOLs(w http.ResponseWriter, r *http.Request) {
pool := r.URL.Query().Get("pool")
zvols, err := a.zfs.ListZVOLs(pool)
if err != nil {
log.Printf("list zvols error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, zvols)
}
func (a *App) handleCreateZVOL(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Size string `json:"size"` // human-readable format (e.g., "10G")
Options map[string]string `json:"options,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, errors.ErrBadRequest("invalid request body"))
return
}
// Validate ZVOL name
if err := validation.ValidateZFSName(req.Name); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Validate size format
if err := validation.ValidateSize(req.Size); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Parse size to bytes
sizeBytes, err := a.parseSizeString(req.Size)
if err != nil {
writeError(w, errors.ErrValidation(fmt.Sprintf("invalid size: %v", err)))
return
}
if req.Options == nil {
req.Options = make(map[string]string)
}
if err := a.zfs.CreateZVOL(req.Name, sizeBytes, req.Options); err != nil {
log.Printf("create zvol error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"message": "zvol created", "name": req.Name})
}
func (a *App) handleGetZVOL(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/zvols/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol name required"})
return
}
zvols, err := a.zfs.ListZVOLs("")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
for _, zvol := range zvols {
if zvol.Name == name {
writeJSON(w, http.StatusOK, zvol)
return
}
}
writeJSON(w, http.StatusNotFound, map[string]string{"error": "zvol not found"})
}
func (a *App) handleDeleteZVOL(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/zvols/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol name required"})
return
}
if err := a.zfs.DestroyZVOL(name); err != nil {
log.Printf("destroy zvol error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "zvol destroyed", "name": name})
}
// Snapshot Handlers
func (a *App) handleListSnapshots(w http.ResponseWriter, r *http.Request) {
dataset := r.URL.Query().Get("dataset")
snapshots, err := a.zfs.ListSnapshots(dataset)
if err != nil {
log.Printf("list snapshots error: %v", err)
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)
}
func (a *App) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) {
var req struct {
Dataset string `json:"dataset"`
Name string `json:"name"`
Recursive bool `json:"recursive,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate dataset name
if err := validation.ValidateZFSName(req.Dataset); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Validate snapshot name (can contain @ but we'll validate the base name)
snapshotBaseName := strings.ReplaceAll(req.Name, "@", "")
if err := validation.ValidateZFSName(snapshotBaseName); err != nil {
writeError(w, errors.ErrValidation("invalid snapshot name"))
return
}
if err := a.zfs.CreateSnapshot(req.Dataset, req.Name, req.Recursive); err != nil {
log.Printf("create snapshot error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
fullSnapshotName := fmt.Sprintf("%s@%s", req.Dataset, req.Name)
snap, err := a.zfs.GetSnapshot(fullSnapshotName)
if err != nil {
writeJSON(w, http.StatusCreated, map[string]string{"message": "snapshot created", "name": fullSnapshotName})
return
}
writeJSON(w, http.StatusCreated, snap)
}
func (a *App) handleGetSnapshot(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/snapshots/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshot name required"})
return
}
snap, err := a.zfs.GetSnapshot(name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, snap)
}
func (a *App) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/snapshots/")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshot name required"})
return
}
recursive := r.URL.Query().Get("recursive") == "true"
if err := a.zfs.DestroySnapshot(name, recursive); err != nil {
log.Printf("destroy snapshot error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "snapshot destroyed", "name": name})
}
// Snapshot Policy Handlers
func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request) {
dataset := r.URL.Query().Get("dataset")
var policies []models.SnapshotPolicy
if dataset != "" {
policies = a.snapshotPolicy.ListForDataset(dataset)
} else {
policies = a.snapshotPolicy.List()
}
// Ensure we always return an array, not null
if policies == nil {
policies = []models.SnapshotPolicy{}
}
writeJSON(w, http.StatusOK, policies)
}
func (a *App) handleCreateSnapshotPolicy(w http.ResponseWriter, r *http.Request) {
var policy models.SnapshotPolicy
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if policy.Dataset == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset is required"})
return
}
a.snapshotPolicy.Set(&policy)
writeJSON(w, http.StatusCreated, policy)
}
func (a *App) handleGetSnapshotPolicy(w http.ResponseWriter, r *http.Request) {
dataset := pathParam(r, "/api/v1/snapshot-policies/")
if dataset == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset name required"})
return
}
policy, err := a.snapshotPolicy.Get(dataset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if policy == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "policy not found"})
return
}
writeJSON(w, http.StatusOK, policy)
}
func (a *App) handleUpdateSnapshotPolicy(w http.ResponseWriter, r *http.Request) {
dataset := pathParam(r, "/api/v1/snapshot-policies/")
if dataset == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset name required"})
return
}
var policy models.SnapshotPolicy
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Ensure dataset matches URL parameter
policy.Dataset = dataset
a.snapshotPolicy.Set(&policy)
writeJSON(w, http.StatusOK, policy)
}
func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request) {
dataset := pathParam(r, "/api/v1/snapshot-policies/")
if dataset == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset name required"})
return
}
if err := a.snapshotPolicy.Delete(dataset); err != nil {
log.Printf("delete snapshot policy error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "policy deleted", "dataset": dataset})
}
// SMB Share Handlers
func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) {
shares := a.smbStore.List()
writeJSON(w, http.StatusOK, shares)
}
func (a *App) handleCreateSMBShare(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Dataset string `json:"dataset"`
Description string `json:"description"`
ReadOnly bool `json:"read_only"`
GuestOK bool `json:"guest_ok"`
ValidUsers []string `json:"valid_users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate share name
if err := validation.ValidateShareName(req.Name); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Validate dataset name
if err := validation.ValidateZFSName(req.Dataset); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Sanitize path if provided
if req.Path != "" {
req.Path = validation.SanitizePath(req.Path)
if err := validation.ValidatePath(req.Path); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
}
// Validate dataset exists
datasets, err := a.zfs.ListDatasets("")
if err != nil {
log.Printf("list datasets error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate dataset"})
return
}
datasetExists := false
for _, ds := range datasets {
if ds.Name == req.Dataset {
datasetExists = true
if req.Path == "" {
req.Path = ds.Mountpoint
}
break
}
}
if !datasetExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset not found"})
return
}
share, err := a.smbStore.Create(req.Name, req.Path, req.Dataset, req.Description, req.ReadOnly, req.GuestOK, req.ValidUsers)
if err != nil {
if err == storage.ErrSMBShareExists {
writeError(w, errors.ErrConflict("share name already exists"))
return
}
log.Printf("create SMB share error: %v", err)
writeError(w, errors.ErrInternal("failed to create SMB share").WithDetails(err.Error()))
return
}
// Apply configuration to Samba service (with graceful degradation)
shares := a.smbStore.List()
if err := a.smbService.ApplyConfiguration(shares); err != nil {
// Log but don't fail the request - desired state is stored
// Service configuration can be retried later
if svcErr := a.handleServiceError("SMB", err); svcErr != nil {
log.Printf("SMB service configuration failed (non-fatal): %v", err)
}
}
writeJSON(w, http.StatusCreated, share)
}
func (a *App) handleGetSMBShare(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/shares/smb/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "share id required"})
return
}
share, err := a.smbStore.Get(id)
if err != nil {
if err == storage.ErrSMBShareNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, share)
}
func (a *App) handleUpdateSMBShare(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/shares/smb/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "share id required"})
return
}
var req struct {
Description string `json:"description"`
ReadOnly bool `json:"read_only"`
GuestOK bool `json:"guest_ok"`
ValidUsers []string `json:"valid_users"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.smbStore.Update(id, req.Description, req.ReadOnly, req.GuestOK, req.Enabled, req.ValidUsers); err != nil {
if err == storage.ErrSMBShareNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("update SMB share error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
share, _ := a.smbStore.Get(id)
// Apply configuration to Samba service
shares := a.smbStore.List()
if err := a.smbService.ApplyConfiguration(shares); err != nil {
log.Printf("apply SMB configuration error: %v", err)
}
writeJSON(w, http.StatusOK, share)
}
func (a *App) handleDeleteSMBShare(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/shares/smb/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "share id required"})
return
}
if err := a.smbStore.Delete(id); err != nil {
if err == storage.ErrSMBShareNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("delete SMB share error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "share deleted", "id": id})
}
// NFS Export Handlers
func (a *App) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
exports := a.nfsStore.List()
writeJSON(w, http.StatusOK, exports)
}
func (a *App) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Dataset string `json:"dataset"`
Clients []string `json:"clients"`
ReadOnly bool `json:"read_only"`
RootSquash bool `json:"root_squash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate dataset name
if err := validation.ValidateZFSName(req.Dataset); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Validate and sanitize path if provided
if req.Path != "" {
req.Path = validation.SanitizePath(req.Path)
if err := validation.ValidatePath(req.Path); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
}
// Validate clients
for i, client := range req.Clients {
if err := validation.ValidateCIDR(client); err != nil {
writeError(w, errors.ErrValidation(fmt.Sprintf("client[%d]: %s", i, err.Error())))
return
}
}
// Validate dataset exists
datasets, err := a.zfs.ListDatasets("")
if err != nil {
log.Printf("list datasets error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate dataset"})
return
}
datasetExists := false
for _, ds := range datasets {
if ds.Name == req.Dataset {
datasetExists = true
if req.Path == "" {
req.Path = ds.Mountpoint
}
break
}
}
if !datasetExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset not found"})
return
}
// Default clients to "*" (all) if not specified
if req.Clients == nil || len(req.Clients) == 0 {
req.Clients = []string{"*"}
}
export, err := a.nfsStore.Create(req.Path, req.Dataset, req.Clients, req.ReadOnly, req.RootSquash)
if err != nil {
if err == storage.ErrNFSExportExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "export for this path already exists"})
return
}
log.Printf("create NFS export error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Apply configuration to NFS service
exports := a.nfsStore.List()
if err := a.nfsService.ApplyConfiguration(exports); err != nil {
log.Printf("apply NFS configuration error: %v", err)
}
writeJSON(w, http.StatusCreated, export)
}
func (a *App) handleGetNFSExport(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/exports/nfs/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "export id required"})
return
}
export, err := a.nfsStore.Get(id)
if err != nil {
if err == storage.ErrNFSExportNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, export)
}
func (a *App) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/exports/nfs/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "export id required"})
return
}
var req struct {
Clients []string `json:"clients"`
ReadOnly bool `json:"read_only"`
RootSquash bool `json:"root_squash"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.nfsStore.Update(id, req.Clients, req.ReadOnly, req.RootSquash, req.Enabled); err != nil {
if err == storage.ErrNFSExportNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("update NFS export error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
export, _ := a.nfsStore.Get(id)
// Apply configuration to NFS service
exports := a.nfsStore.List()
if err := a.nfsService.ApplyConfiguration(exports); err != nil {
log.Printf("apply NFS configuration error: %v", err)
}
writeJSON(w, http.StatusOK, export)
}
func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/exports/nfs/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "export id required"})
return
}
if err := a.nfsStore.Delete(id); err != nil {
if err == storage.ErrNFSExportNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("delete NFS export error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Apply configuration to NFS service
exports := a.nfsStore.List()
if err := a.nfsService.ApplyConfiguration(exports); err != nil {
log.Printf("apply NFS configuration error: %v", err)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "export deleted", "id": id})
}
// iSCSI Handlers
func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
targets := a.iscsiStore.List()
writeJSON(w, http.StatusOK, targets)
}
func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) {
var req struct {
IQN string `json:"iqn"`
Initiators []string `json:"initiators"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate IQN format
if err := validation.ValidateIQN(req.IQN); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
target, err := a.iscsiStore.Create(req.IQN, req.Initiators)
if err != nil {
if err == storage.ErrISCSITargetExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "target with this IQN already exists"})
return
}
log.Printf("create iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
}
writeJSON(w, http.StatusCreated, target)
}
func (a *App) handleGetISCSITarget(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
if id == "" {
writeError(w, errors.ErrBadRequest("target id required"))
return
}
target, err := a.iscsiStore.Get(id)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeError(w, errors.ErrNotFound("iSCSI target"))
return
}
writeError(w, errors.ErrInternal("failed to get iSCSI target").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, target)
}
func (a *App) handleGetISCSIConnectionInstructions(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
if id == "" {
writeError(w, errors.ErrBadRequest("target id required"))
return
}
target, err := a.iscsiStore.Get(id)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeError(w, errors.ErrNotFound("iSCSI target"))
return
}
writeError(w, errors.ErrInternal("failed to get iSCSI target").WithDetails(err.Error()))
return
}
// Get portal IP (with fallback)
portalIP, err := a.iscsiService.GetPortalIP()
if err != nil {
log.Printf("get portal IP error: %v", err)
portalIP = "127.0.0.1" // Fallback
}
// Get portal port from query parameter or use default
portalPort := 3260
if portStr := r.URL.Query().Get("port"); portStr != "" {
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port < 65536 {
portalPort = port
}
}
// Generate connection instructions
instructions := a.iscsiService.GetConnectionInstructions(*target, portalIP, portalPort)
writeJSON(w, http.StatusOK, instructions)
}
func (a *App) handleUpdateISCSITarget(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
var req struct {
Initiators []string `json:"initiators"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.iscsiStore.Update(id, req.Initiators, req.Enabled); err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("update iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
target, _ := a.iscsiStore.Get(id)
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
}
writeJSON(w, http.StatusOK, target)
}
func (a *App) handleDeleteISCSITarget(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
if err := a.iscsiStore.Delete(id); err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("delete iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "target deleted", "id": id})
}
func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
// Extract target ID from path like /api/v1/iscsi/targets/{id}/luns
path := strings.TrimPrefix(r.URL.Path, "/api/v1/iscsi/targets/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
id := parts[0]
var req struct {
ZVOL string `json:"zvol"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.ZVOL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol is required"})
return
}
// Validate ZVOL exists
zvols, err := a.zfs.ListZVOLs("")
if err != nil {
log.Printf("list zvols error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate zvol"})
return
}
var zvolSize uint64
zvolExists := false
for _, zvol := range zvols {
if zvol.Name == req.ZVOL {
zvolExists = true
zvolSize = zvol.Size
break
}
}
if !zvolExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
return
}
lun, err := a.iscsiStore.AddLUN(id, req.ZVOL, zvolSize)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
return
}
if err == storage.ErrLUNExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "zvol already mapped to this target"})
return
}
log.Printf("add LUN error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
}
writeJSON(w, http.StatusCreated, lun)
}
func (a *App) handleRemoveLUN(w http.ResponseWriter, r *http.Request) {
// Extract target ID from path like /api/v1/iscsi/targets/{id}/luns/remove
path := strings.TrimPrefix(r.URL.Path, "/api/v1/iscsi/targets/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
id := parts[0]
var req struct {
LUNID int `json:"lun_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.iscsiStore.RemoveLUN(id, req.LUNID); err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
return
}
if err == storage.ErrLUNNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "LUN not found"})
return
}
log.Printf("remove LUN error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "LUN removed", "target_id": id, "lun_id": strconv.Itoa(req.LUNID)})
}
// Job Handlers
func (a *App) handleListJobs(w http.ResponseWriter, r *http.Request) {
status := models.JobStatus(r.URL.Query().Get("status"))
jobs := a.jobManager.List(status)
writeJSON(w, http.StatusOK, jobs)
}
func (a *App) handleGetJob(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/jobs/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "job id required"})
return
}
job, err := a.jobManager.Get(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, job)
}
func (a *App) handleCancelJob(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/jobs/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "job id required"})
return
}
if err := a.jobManager.Cancel(id); err != nil {
log.Printf("cancel job error: %v", err)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "job cancelled", "id": id})
}
// Auth Handlers
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate username (login is less strict - just check not empty)
if req.Username == "" {
writeError(w, errors.ErrValidation("username is required"))
return
}
if req.Password == "" {
writeError(w, errors.ErrValidation("password is required"))
return
}
user, err := a.userStore.Authenticate(req.Username, req.Password)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
token, err := a.authService.GenerateToken(user.ID, string(user.Role))
if err != nil {
log.Printf("generate token error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
"expires_in": 86400, // 24 hours in seconds
})
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
// JWT is stateless, so logout is just client-side token removal
// In a stateful system, you'd invalidate the token here
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
// User Handlers
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
// Only administrators can list users
users := a.userStore.List()
writeJSON(w, http.StatusOK, users)
}
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role models.Role `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate username
if err := validation.ValidateUsername(req.Username); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Validate password
if err := validation.ValidatePassword(req.Password); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
// Validate email if provided
if req.Email != "" {
if err := validation.ValidateEmail(req.Email); err != nil {
writeError(w, errors.ErrValidation(err.Error()))
return
}
}
if req.Role == "" {
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"})
return
}
user, err := a.userStore.Create(req.Username, req.Email, req.Password, req.Role)
if err != nil {
if err == auth.ErrUserExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "username already exists"})
return
}
log.Printf("create user error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, user)
}
func (a *App) handleGetUser(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/users/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user id required"})
return
}
user, err := a.userStore.GetByID(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, user)
}
func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/users/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user id required"})
return
}
var req struct {
Email string `json:"email"`
Role models.Role `json:"role"`
Active bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
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"})
return
}
// Use existing role if not provided
if req.Role == "" {
existingUser, err := a.userStore.GetByID(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
req.Role = existingUser.Role
}
if err := a.userStore.Update(id, req.Email, req.Role, req.Active); err != nil {
log.Printf("update user error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
user, _ := a.userStore.GetByID(id)
writeJSON(w, http.StatusOK, user)
}
func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/users/")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user id required"})
return
}
// Prevent deleting yourself
currentUser, ok := getUserFromContext(r)
if ok && currentUser.ID == id {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot delete your own account"})
return
}
if err := a.userStore.Delete(id); err != nil {
log.Printf("delete user error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "user deleted", "id": id})
}
// Audit Log Handlers
func (a *App) handleListAuditLogs(w http.ResponseWriter, r *http.Request) {
// Get query parameters
actor := r.URL.Query().Get("actor")
action := r.URL.Query().Get("action")
resource := r.URL.Query().Get("resource")
limitStr := r.URL.Query().Get("limit")
limit := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
// Default limit to 100 if not specified
if limit == 0 {
limit = 100
}
logs := a.auditStore.List(actor, action, resource, limit)
writeJSON(w, http.StatusOK, logs)
}