Add RBAC support with roles, permissions, and session management. Implement middleware for authentication and CSRF protection. Enhance audit logging with additional fields. Update HTTP handlers and routes for new features.
This commit is contained in:
@@ -12,9 +12,45 @@ type DiskService interface {
|
||||
|
||||
type ZFSService interface {
|
||||
ListPools(ctx context.Context) ([]domain.Pool, error)
|
||||
CreatePool(ctx context.Context, name string, vdevs []string) (string, error)
|
||||
// CreatePool is a higher level operation handled by StorageService with jobs
|
||||
// CreatePool(ctx context.Context, name string, vdevs []string) (string, error)
|
||||
GetPoolStatus(ctx context.Context, pool string) (domain.PoolHealth, error)
|
||||
ListDatasets(ctx context.Context, pool string) ([]domain.Dataset, error)
|
||||
CreateDataset(ctx context.Context, name string, props map[string]string) error
|
||||
Snapshot(ctx context.Context, dataset, snapName string) error
|
||||
ScrubStart(ctx context.Context, pool string) error
|
||||
ScrubStatus(ctx context.Context, pool string) (string, error)
|
||||
}
|
||||
|
||||
type JobRunner interface {
|
||||
Enqueue(ctx context.Context, j domain.Job) (string, error)
|
||||
}
|
||||
|
||||
type SharesService interface {
|
||||
ListNFS(ctx context.Context) ([]domain.Share, error)
|
||||
CreateNFS(ctx context.Context, user, role, name, path string, opts map[string]string) (string, error)
|
||||
DeleteNFS(ctx context.Context, user, role, id string) error
|
||||
NFSStatus(ctx context.Context) (string, error)
|
||||
ListSMB(ctx context.Context) ([]domain.Share, error)
|
||||
CreateSMB(ctx context.Context, user, role, name, path string, readOnly bool, allowedUsers []string) (string, error)
|
||||
DeleteSMB(ctx context.Context, user, role, id string) error
|
||||
}
|
||||
|
||||
type ObjectService interface {
|
||||
SetSettings(ctx context.Context, user, role string, s map[string]any) error
|
||||
GetSettings(ctx context.Context) (map[string]any, error)
|
||||
ListBuckets(ctx context.Context) ([]string, error)
|
||||
CreateBucket(ctx context.Context, user, role, name string) (string, error)
|
||||
}
|
||||
|
||||
type ISCSIService interface {
|
||||
ListTargets(ctx context.Context) ([]map[string]any, error)
|
||||
CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error)
|
||||
CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error)
|
||||
DeleteLUN(ctx context.Context, user, role, id string, force bool) error
|
||||
UnmapLUN(ctx context.Context, user, role, id string) error
|
||||
AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error)
|
||||
AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error)
|
||||
ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error)
|
||||
GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error)
|
||||
}
|
||||
|
||||
228
internal/service/iscsi/iscsi.go
Normal file
228
internal/service/iscsi/iscsi.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package iscsi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/example/storage-appliance/internal/audit"
|
||||
"github.com/example/storage-appliance/internal/infra/iscsi"
|
||||
"github.com/example/storage-appliance/internal/infra/zfs"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
type ISCSIService struct {
|
||||
DB *sql.DB
|
||||
ZFS *zfs.Adapter
|
||||
ISCSI *iscsi.Adapter
|
||||
Audit audit.AuditLogger
|
||||
}
|
||||
|
||||
func NewISCSIService(db *sql.DB, z *zfs.Adapter, i *iscsi.Adapter, a audit.AuditLogger) *ISCSIService {
|
||||
return &ISCSIService{DB: db, ZFS: z, ISCSI: i, Audit: a}
|
||||
}
|
||||
|
||||
func (s *ISCSIService) ListTargets(ctx context.Context) ([]map[string]any, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, iqn, name, created_at FROM iscsi_targets`)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
res := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, iqn, name string
|
||||
var created time.Time
|
||||
if err := rows.Scan(&id, &iqn, &name, &created); err != nil { return nil, err }
|
||||
res = append(res, map[string]any{"id": id, "iqn": iqn, "name": name, "created_at": created})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error) {
|
||||
if role != "admin" { return "", ErrForbidden }
|
||||
if iqn == "" || !strings.HasPrefix(iqn, "iqn.") { return "", errors.New("invalid IQN") }
|
||||
id := uuid.New().String()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_targets (id, iqn, name) VALUES (?, ?, ?)`, id, iqn, name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.ISCSI != nil {
|
||||
if err := s.ISCSI.CreateTarget(ctx, iqn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
|
||||
}
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.target.create", ResourceType: "iscsi_target", ResourceID: id, Success: true}) }
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CreateLUN creates a zvol and maps it as LUN for the IQN. lunName is the zvol path e.g. pool/dataset/vol
|
||||
func (s *ISCSIService) CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error) {
|
||||
if role != "admin" && role != "operator" { return "", ErrForbidden }
|
||||
// fetch target IQN
|
||||
var iqn string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// build zvol name
|
||||
zvol := lunName // expect fully qualified dataset, e.g., pool/iscsi/target/lun0
|
||||
// create zvol via zfs adapter
|
||||
props := map[string]string{}
|
||||
if blocksize > 0 {
|
||||
// convert bytes to K unit if divisible
|
||||
// For simplicity, just set volblocksize as "8K" or "512"; attempt simple conversion
|
||||
props["volblocksize"] = fmt.Sprintf("%d", blocksize)
|
||||
}
|
||||
if s.ZFS != nil {
|
||||
if _, err := s.ZFS.ListDatasets(ctx, ""); err == nil { // no-op to check connectivity
|
||||
}
|
||||
if err := s.ZFS.CreateZVol(ctx, zvol, size, props); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// backstore name and device path
|
||||
bsName := "bs-" + uuid.New().String()
|
||||
devpath := "/dev/zvol/" + zvol
|
||||
if s.ISCSI != nil {
|
||||
if err := s.ISCSI.CreateBackstore(ctx, bsName, devpath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// determine LUN ID as next available for target
|
||||
var maxLun sql.NullInt64
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT MAX(lun_id) FROM iscsi_luns WHERE target_id = ?`, targetID).Scan(&maxLun); err != nil && err != sql.ErrNoRows { return "", err }
|
||||
nextLun := 0
|
||||
if maxLun.Valid { nextLun = int(maxLun.Int64) + 1 }
|
||||
if s.ISCSI != nil {
|
||||
if err := s.ISCSI.CreateLUN(ctx, iqn, bsName, nextLun); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
|
||||
}
|
||||
id := uuid.New().String()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_luns (id, target_id, lun_id, zvol, size, blocksize, mapped) VALUES (?, ?, ?, ?, ?, ?, 1)`, id, targetID, nextLun, zvol, sizeToInt(size), blocksize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.create", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) }
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func sizeToInt(s string) int {
|
||||
// naive conversion: strip trailing G/M/K
|
||||
// This function can be improved; for now return 0
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *ISCSIService) ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, lun_id, zvol, size, blocksize, mapped, created_at FROM iscsi_luns WHERE target_id = ?`, targetID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
res := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, zvol string
|
||||
var lunID int
|
||||
var size int
|
||||
var blocksize int
|
||||
var mapped int
|
||||
var created time.Time
|
||||
if err := rows.Scan(&id, &lunID, &zvol, &size, &blocksize, &mapped, &created); err != nil { return nil, err }
|
||||
res = append(res, map[string]any{"id": id, "lun_id": lunID, "zvol": zvol, "size": size, "blocksize": blocksize, "mapped": mapped == 1, "created_at": created})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error) {
|
||||
var iqn string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return nil, err }
|
||||
portals := []map[string]any{}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, address, port FROM iscsi_portals WHERE target_id = ?`, targetID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, address string
|
||||
var port int
|
||||
if err := rows.Scan(&id, &address, &port); err != nil { return nil, err }
|
||||
portals = append(portals, map[string]any{"id": id, "address": address, "port": port})
|
||||
}
|
||||
inits := []map[string]any{}
|
||||
rows2, err := s.DB.QueryContext(ctx, `SELECT id, initiator_iqn FROM iscsi_initiators WHERE target_id = ?`, targetID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var id, iqnStr string
|
||||
if err := rows2.Scan(&id, &iqnStr); err != nil { return nil, err }
|
||||
inits = append(inits, map[string]any{"id": id, "iqn": iqnStr})
|
||||
}
|
||||
res := map[string]any{"iqn": iqn, "portals": portals, "initiators": inits}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) DeleteLUN(ctx context.Context, user, role, id string, force bool) error {
|
||||
if role != "admin" { return ErrForbidden }
|
||||
// check LUN
|
||||
var mappedInt int
|
||||
var targetID string
|
||||
var lunID int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT target_id, lun_id, mapped FROM iscsi_luns WHERE id = ?`, id).Scan(&targetID, &lunID, &mappedInt); err != nil { return err }
|
||||
if mappedInt == 1 && !force { return errors.New("LUN is mapped; unmap (drain) before deletion or specify force") }
|
||||
// delete via adapter
|
||||
if s.ISCSI != nil {
|
||||
var iqn string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return err }
|
||||
if err := s.ISCSI.DeleteLUN(ctx, iqn, lunID); err != nil { return err }
|
||||
if err := s.ISCSI.Save(ctx); err != nil { return err }
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `DELETE FROM iscsi_luns WHERE id = ?`, id); err != nil { return err }
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.delete", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) }
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmapLUN removes LUN mapping from target, sets mapped to false in DB
|
||||
func (s *ISCSIService) UnmapLUN(ctx context.Context, user, role, id string) error {
|
||||
if role != "admin" && role != "operator" { return ErrForbidden }
|
||||
var targetID string
|
||||
var lunID int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT target_id, lun_id FROM iscsi_luns WHERE id = ?`, id).Scan(&targetID, &lunID); err != nil { return err }
|
||||
if s.ISCSI != nil {
|
||||
var iqn string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return err }
|
||||
if err := s.ISCSI.DeleteLUN(ctx, iqn, lunID); err != nil { return err }
|
||||
if err := s.ISCSI.Save(ctx); err != nil { return err }
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `UPDATE iscsi_luns SET mapped = 0 WHERE id = ?`, id); err != nil { return err }
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.unmap", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error) {
|
||||
if role != "admin" && role != "operator" { return "", ErrForbidden }
|
||||
// verify target exists and fetch IQN
|
||||
var iqn string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err }
|
||||
id := uuid.New().String()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_portals (id, target_id, address, port) VALUES (?, ?, ?, ?)`, id, targetID, address, port); err != nil { return "", err }
|
||||
if s.ISCSI != nil {
|
||||
if err := s.ISCSI.AddPortal(ctx, iqn, address, port); err != nil { return "", err }
|
||||
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
|
||||
}
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.portal.add", ResourceType: "iscsi_portal", ResourceID: id, Success: true}) }
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error) {
|
||||
if role != "admin" && role != "operator" { return "", ErrForbidden }
|
||||
var iqn string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err }
|
||||
id := uuid.New().String()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_initiators (id, target_id, initiator_iqn) VALUES (?, ?, ?)`, id, targetID, initiatorIQN); err != nil { return "", err }
|
||||
if s.ISCSI != nil {
|
||||
if err := s.ISCSI.AddACL(ctx, iqn, initiatorIQN); err != nil { return "", err }
|
||||
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
|
||||
}
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.initiator.add", ResourceType: "iscsi_initiator", ResourceID: id, Success: true}) }
|
||||
return id, nil
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ service.DiskService = (*MockDiskService)(nil)
|
||||
_ service.ZFSService = (*MockZFSService)(nil)
|
||||
_ service.JobRunner = (*MockJobRunner)(nil)
|
||||
_ service.DiskService = (*MockDiskService)(nil)
|
||||
_ service.ZFSService = (*MockZFSService)(nil)
|
||||
_ service.JobRunner = (*MockJobRunner)(nil)
|
||||
_ service.SharesService = (*MockSharesService)(nil)
|
||||
_ service.ISCSIService = (*MockISCSIService)(nil)
|
||||
)
|
||||
|
||||
type MockDiskService struct{}
|
||||
@@ -32,8 +34,32 @@ func (m *MockZFSService) ListPools(ctx context.Context) ([]domain.Pool, error) {
|
||||
}
|
||||
|
||||
func (m *MockZFSService) CreatePool(ctx context.Context, name string, vdevs []string) (string, error) {
|
||||
// spawn instant job id for mock
|
||||
return "job-" + uuid.New().String(), nil
|
||||
// not implemented on adapter-level mock
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockZFSService) GetPoolStatus(ctx context.Context, pool string) (domain.PoolHealth, error) {
|
||||
return domain.PoolHealth{Pool: pool, Status: "ONLINE", Detail: "mocked"}, nil
|
||||
}
|
||||
|
||||
func (m *MockZFSService) ListDatasets(ctx context.Context, pool string) ([]domain.Dataset, error) {
|
||||
return []domain.Dataset{{Name: pool + "/dataset1", Pool: pool, Type: "filesystem"}}, nil
|
||||
}
|
||||
|
||||
func (m *MockZFSService) CreateDataset(ctx context.Context, name string, props map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockZFSService) Snapshot(ctx context.Context, dataset, snapName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockZFSService) ScrubStart(ctx context.Context, pool string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockZFSService) ScrubStatus(ctx context.Context, pool string) (string, error) {
|
||||
return "none", nil
|
||||
}
|
||||
|
||||
type MockJobRunner struct{}
|
||||
@@ -45,3 +71,67 @@ func (m *MockJobRunner) Enqueue(ctx context.Context, j domain.Job) (string, erro
|
||||
}()
|
||||
return uuid.New().String(), nil
|
||||
}
|
||||
|
||||
type MockSharesService struct{}
|
||||
|
||||
func (m *MockSharesService) ListNFS(ctx context.Context) ([]domain.Share, error) {
|
||||
return []domain.Share{{ID: domain.UUID(uuid.New().String()), Name: "data", Path: "tank/ds", Type: "nfs"}}, nil
|
||||
}
|
||||
|
||||
func (m *MockSharesService) CreateNFS(ctx context.Context, user, role, name, path string, opts map[string]string) (string, error) {
|
||||
return "share-" + uuid.New().String(), nil
|
||||
}
|
||||
|
||||
func (m *MockSharesService) DeleteNFS(ctx context.Context, user, role, id string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *MockSharesService) NFSStatus(ctx context.Context) (string, error) {
|
||||
return "active", nil
|
||||
}
|
||||
func (m *MockSharesService) ListSMB(ctx context.Context) ([]domain.Share, error) {
|
||||
return []domain.Share{{ID: domain.UUID(uuid.New().String()), Name: "smb1", Path: "tank/ds", Type: "smb", Config: map[string]string{"read_only": "false"}}}, nil
|
||||
}
|
||||
func (m *MockSharesService) CreateSMB(ctx context.Context, user, role, name, path string, readOnly bool, allowedUsers []string) (string, error) {
|
||||
return "smb-" + uuid.New().String(), nil
|
||||
}
|
||||
func (m *MockSharesService) DeleteSMB(ctx context.Context, user, role, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MockISCSIService struct{}
|
||||
|
||||
func (m *MockISCSIService) ListTargets(ctx context.Context) ([]map[string]any, error) {
|
||||
return []map[string]any{{"id": "t-1", "iqn": "iqn.2025-12.org.example:target1", "name": "test"}}, nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error) {
|
||||
return "t-" + uuid.New().String(), nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error) {
|
||||
return "lun-" + uuid.New().String(), nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) DeleteLUN(ctx context.Context, user, role, id string, force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error) {
|
||||
return []map[string]any{{"id": "lun-1", "lun_id": 0, "zvol": "tank/ds/vol1", "size": 10737418240}}, nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) UnmapLUN(ctx context.Context, user, role, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error) {
|
||||
return "portal-" + uuid.New().String(), nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error) {
|
||||
return "init-" + uuid.New().String(), nil
|
||||
}
|
||||
|
||||
func (m *MockISCSIService) GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error) {
|
||||
return map[string]any{"iqn": "iqn.2025-12.org.example:target1", "portals": []map[string]any{{"id": "p-1", "address": "10.0.0.1", "port": 3260}}, "initiators": []map[string]any{{"id": "i-1", "iqn": "iqn.1993-08.org.debian:01"}}}, nil
|
||||
}
|
||||
|
||||
159
internal/service/objectstore/objectstore.go
Normal file
159
internal/service/objectstore/objectstore.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/example/storage-appliance/internal/audit"
|
||||
"github.com/example/storage-appliance/internal/infra/minio"
|
||||
"github.com/example/storage-appliance/internal/infra/crypto"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
ID string
|
||||
Name string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
DataPath string
|
||||
Port int
|
||||
TLS bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ObjectService struct {
|
||||
DB *sql.DB
|
||||
Minio *minio.Adapter
|
||||
Audit audit.AuditLogger
|
||||
// encryption key for secret storage
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewObjectService(db *sql.DB, m *minio.Adapter, a audit.AuditLogger, key []byte) *ObjectService {
|
||||
return &ObjectService{DB: db, Minio: m, Audit: a, Key: key}
|
||||
}
|
||||
|
||||
func (s *ObjectService) SetSettings(ctx context.Context, user, role string, stMap map[string]any) error {
|
||||
if role != "admin" {
|
||||
return ErrForbidden
|
||||
}
|
||||
// convert map to Settings struct for local use
|
||||
st := Settings{}
|
||||
if v, ok := stMap["access_key"].(string); ok { st.AccessKey = v }
|
||||
if v, ok := stMap["secret_key"].(string); ok { st.SecretKey = v }
|
||||
if v, ok := stMap["data_path"].(string); ok { st.DataPath = v }
|
||||
if v, ok := stMap["name"].(string); ok { st.Name = v }
|
||||
if v, ok := stMap["port"].(int); ok { st.Port = v }
|
||||
if v, ok := stMap["tls"].(bool); ok { st.TLS = v }
|
||||
|
||||
// encrypt access key and secret key
|
||||
if len(s.Key) != 32 {
|
||||
return errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
encAccess, err := crypto.Encrypt(s.Key, st.AccessKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encSecret, err := crypto.Encrypt(s.Key, st.SecretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// upsert into DB (single row)
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT OR REPLACE INTO object_storage (id, name, access_key, secret_key, data_path, port, tls) VALUES ('minio', ?, ?, ?, ?, ?, ?)` , st.Name, encAccess, encSecret, st.DataPath, st.Port, boolToInt(st.TLS)); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "object.settings.update", ResourceType: "object_storage", ResourceID: "minio", Success: true})
|
||||
}
|
||||
if s.Minio != nil {
|
||||
// write env file
|
||||
settings := minio.Settings{AccessKey: st.AccessKey, SecretKey: st.SecretKey, DataPath: st.DataPath, Port: st.Port, TLS: st.TLS}
|
||||
if err := s.Minio.WriteEnv(ctx, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Minio.Reload(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectService) GetSettings(ctx context.Context) (map[string]any, error) {
|
||||
var st Settings
|
||||
row := s.DB.QueryRowContext(ctx, `SELECT name, access_key, secret_key, data_path, port, tls, created_at FROM object_storage WHERE id = 'minio'`)
|
||||
var encAccess, encSecret string
|
||||
var tlsInt int
|
||||
if err := row.Scan(&st.Name, &encAccess, &encSecret, &st.DataPath, &st.Port, &tlsInt, &st.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.TLS = tlsInt == 1
|
||||
if len(s.Key) == 32 {
|
||||
if A, err := crypto.Decrypt(s.Key, encAccess); err == nil { st.AccessKey = A }
|
||||
if S, err := crypto.Decrypt(s.Key, encSecret); err == nil { st.SecretKey = S }
|
||||
}
|
||||
res := map[string]any{"name": st.Name, "access_key": st.AccessKey, "secret_key": st.SecretKey, "data_path": st.DataPath, "port": st.Port, "tls": st.TLS, "created_at": st.CreatedAt}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int { if b { return 1 }; return 0 }
|
||||
|
||||
// ListBuckets via minio adapter or fallback to DB
|
||||
func (s *ObjectService) ListBuckets(ctx context.Context) ([]string, error) {
|
||||
if s.Minio != nil {
|
||||
// ensure mc alias is configured
|
||||
stMap, err := s.GetSettings(ctx)
|
||||
if err != nil { return nil, err }
|
||||
alias := "appliance"
|
||||
mSet := minio.Settings{}
|
||||
if v, ok := stMap["access_key"].(string); ok { mSet.AccessKey = v }
|
||||
if v, ok := stMap["secret_key"].(string); ok { mSet.SecretKey = v }
|
||||
if v, ok := stMap["data_path"].(string); ok { mSet.DataPath = v }
|
||||
if v, ok := stMap["port"].(int); ok { mSet.Port = v }
|
||||
if v, ok := stMap["tls"].(bool); ok { mSet.TLS = v }
|
||||
s.Minio.ConfigureMC(ctx, alias, mSet)
|
||||
return s.Minio.ListBuckets(ctx, alias)
|
||||
}
|
||||
// fallback to DB persisted buckets
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT name FROM buckets`)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var res []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil { return nil, err }
|
||||
res = append(res, name)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ObjectService) CreateBucket(ctx context.Context, user, role, name string) (string, error) {
|
||||
if role != "admin" && role != "operator" { return "", ErrForbidden }
|
||||
// attempt via minio adapter
|
||||
if s.Minio != nil {
|
||||
stMap, err := s.GetSettings(ctx)
|
||||
if err != nil { return "", err }
|
||||
alias := "appliance"
|
||||
mSet := minio.Settings{}
|
||||
if v, ok := stMap["access_key"].(string); ok { mSet.AccessKey = v }
|
||||
if v, ok := stMap["secret_key"].(string); ok { mSet.SecretKey = v }
|
||||
if v, ok := stMap["data_path"].(string); ok { mSet.DataPath = v }
|
||||
if v, ok := stMap["port"].(int); ok { mSet.Port = v }
|
||||
if v, ok := stMap["tls"].(bool); ok { mSet.TLS = v }
|
||||
if err := s.Minio.ConfigureMC(ctx, alias, mSet); err != nil { return "", err }
|
||||
if err := s.Minio.CreateBucket(ctx, alias, name); err != nil { return "", err }
|
||||
// persist
|
||||
id := fmt.Sprintf("bucket-%d", time.Now().UnixNano())
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO buckets (id, name) VALUES (?, ?)`, id, name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "object.bucket.create", ResourceType: "bucket", ResourceID: name, Success: true}) }
|
||||
return id, nil
|
||||
}
|
||||
return "", errors.New("no minio adapter configured")
|
||||
}
|
||||
225
internal/service/shares/shares.go
Normal file
225
internal/service/shares/shares.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"strings"
|
||||
|
||||
"github.com/example/storage-appliance/internal/audit"
|
||||
"github.com/example/storage-appliance/internal/domain"
|
||||
"github.com/example/storage-appliance/internal/infra/nfs"
|
||||
"github.com/example/storage-appliance/internal/infra/samba"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
type SharesService struct {
|
||||
DB *sql.DB
|
||||
NFS *nfs.Adapter
|
||||
Samba *samba.Adapter
|
||||
Audit audit.AuditLogger
|
||||
}
|
||||
|
||||
func NewSharesService(db *sql.DB, n *nfs.Adapter, s *samba.Adapter, a audit.AuditLogger) *SharesService {
|
||||
return &SharesService{DB: db, NFS: n, Samba: s, Audit: a}
|
||||
}
|
||||
|
||||
func (s *SharesService) ListNFS(ctx context.Context) ([]domain.Share, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, path, type, options FROM shares WHERE type = 'nfs'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var res []domain.Share
|
||||
for rows.Next() {
|
||||
var id, name, path, typ, options string
|
||||
if err := rows.Scan(&id, &name, &path, &typ, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var optMap map[string]string
|
||||
if options != "" {
|
||||
_ = json.Unmarshal([]byte(options), &optMap)
|
||||
}
|
||||
res = append(res, domain.Share{ID: domain.UUID(id), Name: name, Path: path, Type: typ})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// CreateNFS stores a new NFS export, re-renders /etc/exports and applies it
|
||||
func (s *SharesService) CreateNFS(ctx context.Context, user, role, name, path string, opts map[string]string) (string, error) {
|
||||
if role != "admin" && role != "operator" {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
// Verify path exists and is a dataset: check dataset table for matching name
|
||||
var count int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(1) FROM datasets WHERE name = ?`, path).Scan(&count); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count == 0 {
|
||||
return "", fmt.Errorf("path not a known dataset: %s", path)
|
||||
}
|
||||
// Prevent exporting system paths: disallow leading '/' entries; require dataset name like pool/ds
|
||||
if path == "/" || path == "/etc" || path == "/bin" || path == "/usr" {
|
||||
return "", fmt.Errorf("can't export system path: %s", path)
|
||||
}
|
||||
// store options as JSON
|
||||
optJSON, _ := json.Marshal(opts)
|
||||
id := uuid.New().String()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO shares (id, name, path, type, options) VALUES (?, ?, ?, 'nfs', ?)`, id, name, path, string(optJSON)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "nfs.create", ResourceType: "share", ResourceID: name, Success: true, Details: map[string]any{"path": path}})
|
||||
}
|
||||
// re-render exports
|
||||
shares, err := s.ListNFS(ctx)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if s.NFS != nil {
|
||||
if err := s.NFS.RenderExports(ctx, shares); err != nil {
|
||||
return id, err
|
||||
}
|
||||
if err := s.NFS.Apply(ctx); err != nil {
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// SMB functions
|
||||
func (s *SharesService) ListSMB(ctx context.Context) ([]domain.Share, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, path, type, options FROM shares WHERE type = 'smb'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var res []domain.Share
|
||||
for rows.Next() {
|
||||
var id, name, path, typ, options string
|
||||
if err := rows.Scan(&id, &name, &path, &typ, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config map[string]string
|
||||
if options != "" {
|
||||
_ = json.Unmarshal([]byte(options), &config)
|
||||
}
|
||||
res = append(res, domain.Share{ID: domain.UUID(id), Name: name, Path: path, Type: typ, Config: config})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *SharesService) CreateSMB(ctx context.Context, user, role, name, path string, readOnly bool, allowedUsers []string) (string, error) {
|
||||
if role != "admin" && role != "operator" {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
// Verify dataset
|
||||
var count int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(1) FROM datasets WHERE name = ?`, path).Scan(&count); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count == 0 {
|
||||
return "", fmt.Errorf("path not a known dataset: %s", path)
|
||||
}
|
||||
// disallow system paths by basic checks
|
||||
if path == "/" || path == "/etc" || path == "/bin" || path == "/usr" {
|
||||
return "", fmt.Errorf("can't export system path: %s", path)
|
||||
}
|
||||
// store options as JSON (read_only, allowed_users)
|
||||
cfg := map[string]string{}
|
||||
if readOnly {
|
||||
cfg["read_only"] = "true"
|
||||
} else {
|
||||
cfg["read_only"] = "false"
|
||||
}
|
||||
if len(allowedUsers) > 0 {
|
||||
cfg["allowed_users"] = strings.Join(allowedUsers, " ")
|
||||
}
|
||||
optJSON, _ := json.Marshal(cfg)
|
||||
id := uuid.New().String()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO shares (id, name, path, type, options) VALUES (?, ?, ?, 'smb', ?)`, id, name, path, string(optJSON)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "smb.create", ResourceType: "share", ResourceID: name, Success: true, Details: map[string]any{"path": path, "read_only": readOnly}})
|
||||
}
|
||||
// re-render smb conf and reload
|
||||
shares, err := s.ListSMB(ctx)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if s.Samba != nil {
|
||||
if err := s.Samba.RenderConf(ctx, shares); err != nil {
|
||||
return id, err
|
||||
}
|
||||
if err := s.Samba.Reload(ctx); err != nil {
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *SharesService) DeleteSMB(ctx context.Context, user, role, id string) error {
|
||||
if role != "admin" && role != "operator" {
|
||||
return ErrForbidden
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `DELETE FROM shares WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "smb.delete", ResourceType: "share", ResourceID: id, Success: true})
|
||||
}
|
||||
shares, err := s.ListSMB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Samba != nil {
|
||||
if err := s.Samba.RenderConf(ctx, shares); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Samba.Reload(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SharesService) DeleteNFS(ctx context.Context, user, role, id string) error {
|
||||
if role != "admin" && role != "operator" {
|
||||
return ErrForbidden
|
||||
}
|
||||
// verify exists
|
||||
if _, err := s.DB.ExecContext(ctx, `DELETE FROM shares WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "nfs.delete", ResourceType: "share", ResourceID: id, Success: true})
|
||||
}
|
||||
// re-render exports
|
||||
shares, err := s.ListNFS(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.NFS != nil {
|
||||
if err := s.NFS.RenderExports(ctx, shares); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.NFS.Apply(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SharesService) NFSStatus(ctx context.Context) (string, error) {
|
||||
if s.NFS == nil {
|
||||
return "unavailable", nil
|
||||
}
|
||||
return s.NFS.Status(ctx)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/example/storage-appliance/internal/audit"
|
||||
"github.com/example/storage-appliance/internal/domain"
|
||||
@@ -49,6 +48,7 @@ func (s *StorageService) CreatePool(ctx context.Context, user string, role strin
|
||||
}
|
||||
// Create a job to build a pool. For skeleton, we just create a job entry with type create-pool
|
||||
j := domain.Job{Type: "create-pool", Status: "queued", Owner: domain.UUID(user)}
|
||||
j.Details = map[string]any{"name": name, "vdevs": vdevs}
|
||||
id, err := s.JobRunner.Enqueue(ctx, j)
|
||||
// Store details in audit
|
||||
if s.Audit != nil {
|
||||
@@ -64,6 +64,7 @@ func (s *StorageService) Snapshot(ctx context.Context, user, role, dataset, snap
|
||||
}
|
||||
// call zfs snapshot, but do as job; enqueue
|
||||
j := domain.Job{Type: "snapshot", Status: "queued", Owner: domain.UUID(user)}
|
||||
j.Details = map[string]any{"dataset": dataset, "snap_name": snapName}
|
||||
id, err := s.JobRunner.Enqueue(ctx, j)
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "dataset.snapshot.request", ResourceType: "snapshot", ResourceID: fmt.Sprintf("%s@%s", dataset, snapName), Success: err == nil, Details: map[string]any{"dataset": dataset}})
|
||||
@@ -76,6 +77,7 @@ func (s *StorageService) ScrubStart(ctx context.Context, user, role, pool string
|
||||
return "", ErrForbidden
|
||||
}
|
||||
j := domain.Job{Type: "scrub", Status: "queued", Owner: domain.UUID(user)}
|
||||
j.Details = map[string]any{"pool": pool}
|
||||
id, err := s.JobRunner.Enqueue(ctx, j)
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "pool.scrub.request", ResourceType: "pool", ResourceID: pool, Success: err == nil})
|
||||
@@ -93,7 +95,11 @@ func (s *StorageService) CreateDataset(ctx context.Context, user, role, name str
|
||||
if role != "admin" && role != "operator" {
|
||||
return ErrForbidden
|
||||
}
|
||||
return s.ZFS.CreateDataset(ctx, name, props)
|
||||
err := s.ZFS.CreateDataset(ctx, name, props)
|
||||
if s.Audit != nil {
|
||||
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "dataset.create", ResourceType: "dataset", ResourceID: name, Success: err == nil, Details: map[string]any{"props": props}})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPoolStatus calls the adapter
|
||||
|
||||
Reference in New Issue
Block a user