226 lines
7.3 KiB
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)
|
|
}
|