Files
storage-appliance/internal/service/shares/shares.go

226 lines
7.3 KiB
Go

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