feat: import existing smb.conf and /etc/exports on first boot
Adds auto-detection of pre-existing Samba shares and NFS exports when nasctl is installed on a host that already has these configs. New package internal/importer parses smb.conf (INI-style) and /etc/exports (line-based) and imports them into SQLite. Imported shares/exports are marked dirty so the user must review and apply manually before any file is overwritten. Backup: before the first Apply, each module backs up the original config to <path>.nasctl.bak.<timestamp> (one time only). New CLI flag --import-on-boot / NASCTL_IMPORT_ON_BOOT env var (default false, opt-in). New API endpoints: GET /api/import/status POST /api/import/samba POST /api/import/nfs New DB methods ReplaceSambaShares/ReplaceNFSExports (transactional replace-all), guarded by import.samba.done / import.nfs.done settings flags.
This commit is contained in:
@@ -43,6 +43,7 @@ No separate frontend test command; no lint/typecheck Makefile targets.
|
||||
| `NASCTL_EXPORTS` | `./.data/exports` | `/etc/exports` |
|
||||
| `NASCTL_EXEC_SYSTEM` | `false` | `true` |
|
||||
| `NASCTL_ALLOWED_ROOTS` | (empty) | (empty = any absolute path) |
|
||||
| `NASCTL_IMPORT_ON_BOOT` | `false` | `true` to import existing `smb.conf` and `/etc/exports` on first boot |
|
||||
|
||||
## Frontend Dev
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ Variables de entorno útiles:
|
||||
| `NASCTL_ALLOWED_ROOTS` | (vacío) | Directorios raíz permitidos para paths de shares/exports, separados por coma |
|
||||
| `NASCTL_ADMIN_USER` | `admin` | Usuario admin inicial (solo si no existe ninguno) |
|
||||
| `NASCTL_ADMIN_PASSWORD` | `admin` | Contraseña admin inicial (solo si no existe ninguno) |
|
||||
| `NASCTL_IMPORT_ON_BOOT` | `false` | `true` para importar `smb.conf` y `/etc/exports` existentes al primer arranque |
|
||||
|
||||
En desarrollo, `make run` usa `./.data/` y `NASCTL_EXEC_SYSTEM=false` (no toca el sistema real).
|
||||
|
||||
@@ -66,6 +67,9 @@ El resto de endpoints requieren sesión válida:
|
||||
- `GET/POST/PUT/DELETE /api/samba/shares` — CRUD de shares Samba
|
||||
- `GET/POST/PUT/DELETE /api/nfs/exports` — CRUD de exports NFS
|
||||
- `GET/POST/PUT/DELETE /api/users` — CRUD de usuarios del sistema
|
||||
- `GET /api/import/status` — estado del import (done, counts)
|
||||
- `POST /api/import/samba` — re-importa shares desde `smb.conf`
|
||||
- `POST /api/import/nfs` — re-importa exports desde `/etc/exports`
|
||||
|
||||
Todos los cambios marcan el módulo como dirty y NO se aplican al sistema hasta `POST /api/apply`.
|
||||
|
||||
@@ -76,6 +80,27 @@ Todos los cambios marcan el módulo como dirty y NO se aplican al sistema hasta
|
||||
3. El usuario pulsa "Aplicar cambios" (`POST /api/apply`).
|
||||
4. El motor regenera archivos de configuración desde templates y recarga servicios.
|
||||
|
||||
## Importar configuración existente
|
||||
|
||||
Si el host ya tiene `smb.conf` o `/etc/exports` configurados, nasctl puede importarlos al primer arranque:
|
||||
|
||||
```bash
|
||||
NASCTL_IMPORT_ON_BOOT=true ./nasctl
|
||||
```
|
||||
|
||||
El import:
|
||||
1. Detecta shares en `smb.conf` y exports en `/etc/exports`.
|
||||
2. Los importa a SQLite (marca el módulo como dirty).
|
||||
3. **No sobreescribe** los archivos automáticamente — el usuario revisa y pulsa Apply.
|
||||
4. Antes del primer Apply, hace backup de los archivos originales en `<path>.nasctl.bak.<timestamp>`.
|
||||
5. Los flags `import.samba.done`/`import.nfs.done` en la base de datos evitan re-importar en cada arranque.
|
||||
|
||||
Para re-escanear manualmente después de editar los archivos en disco:
|
||||
|
||||
- `POST /api/import/samba` — reimporta shares desde `smb.conf`
|
||||
- `POST /api/import/nfs` — reimporta exports desde `/etc/exports`
|
||||
- `GET /api/import/status` — estado del import
|
||||
|
||||
## Empaquetado
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/engine"
|
||||
"github.com/darroyo/nasctl/internal/importer"
|
||||
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||
"github.com/darroyo/nasctl/internal/modules/users"
|
||||
@@ -25,6 +26,7 @@ func main() {
|
||||
allowedRoots := flag.String("allowed-roots", envOrDefault("NASCTL_ALLOWED_ROOTS", ""), "Comma-separated allowed root dirs for shares/exports (empty = any absolute path)")
|
||||
adminUser := flag.String("admin-user", envOrDefault("NASCTL_ADMIN_USER", "admin"), "Initial admin username (only used if no admin exists)")
|
||||
adminPass := flag.String("admin-pass", envOrDefault("NASCTL_ADMIN_PASSWORD", "admin"), "Initial admin password (only used if no admin exists)")
|
||||
importOnBoot := flag.Bool("import-on-boot", envOrDefault("NASCTL_IMPORT_ON_BOOT", "false") == "true", "Import existing smb.conf and /etc/exports on first boot")
|
||||
flag.Parse()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil {
|
||||
@@ -53,6 +55,16 @@ func main() {
|
||||
log.Printf("created initial admin user %q — change the password after first login", *adminUser)
|
||||
}
|
||||
|
||||
if *importOnBoot {
|
||||
result := importer.ImportOnBoot(nil, database, *smbConfPath, *exportsPath)
|
||||
if result.SambaImported > 0 {
|
||||
log.Printf("[importer] imported %d existing samba shares — review and apply", result.SambaImported)
|
||||
}
|
||||
if result.NFSImported > 0 {
|
||||
log.Printf("[importer] imported %d existing nfs exports — review and apply", result.NFSImported)
|
||||
}
|
||||
}
|
||||
|
||||
sambaModule := samba.New(samba.Config{
|
||||
SMBConfPath: *smbConfPath,
|
||||
Reload: *execSystem,
|
||||
@@ -70,6 +82,8 @@ func main() {
|
||||
srv := web.NewServer(database, eng, web.Options{
|
||||
AllowedRoots: parseRoots(*allowedRoots),
|
||||
Auth: auth,
|
||||
SMBConfPath: *smbConfPath,
|
||||
ExportsPath: *exportsPath,
|
||||
})
|
||||
|
||||
log.Printf("nasctl listening on %s (db=%s exec-system=%v)", *addr, *dbPath, *execSystem)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (d *DB) ReplaceSambaShares(shares []SambaShare) error {
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM samba_shares`); err != nil {
|
||||
return fmt.Errorf("clear samba_shares: %w", err)
|
||||
}
|
||||
|
||||
for _, share := range shares {
|
||||
validUsers, err := encodeJSONStrings(share.ValidUsers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
validGroups, err := encodeJSONStrings(share.ValidGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readOnly := 0
|
||||
if share.ReadOnly {
|
||||
readOnly = 1
|
||||
}
|
||||
guestOK := 0
|
||||
if share.GuestOK {
|
||||
guestOK = 1
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert samba share %s: %w", share.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) ReplaceNFSExports(exports []NFSExport) error {
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM nfs_exports`); err != nil {
|
||||
return fmt.Errorf("clear nfs_exports: %w", err)
|
||||
}
|
||||
|
||||
for _, exp := range exports {
|
||||
clients, err := encodeJSONStrings(exp.Clients)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO nfs_exports (path, clients, options)
|
||||
VALUES (?, ?, ?)`,
|
||||
exp.Path, clients, exp.Options,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert nfs export %s: %w", exp.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
SambaImported int
|
||||
NFSImported int
|
||||
SambaSkipped bool
|
||||
NFSSkipped bool
|
||||
SambaError string
|
||||
NFSError string
|
||||
}
|
||||
|
||||
type ImporterDB interface {
|
||||
ListSambaShares() ([]db.SambaShare, error)
|
||||
ListNFSExports() ([]db.NFSExport, error)
|
||||
ReplaceSambaShares(shares []db.SambaShare) error
|
||||
ReplaceNFSExports(exports []db.NFSExport) error
|
||||
GetSetting(key string) (string, bool, error)
|
||||
SetSetting(key, value string) error
|
||||
MarkDirty(module string) error
|
||||
}
|
||||
|
||||
func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath string) ImportResult {
|
||||
result := ImportResult{}
|
||||
|
||||
sambaDone, _, _ := database.GetSetting("import.samba.done")
|
||||
nfsDone, _, _ := database.GetSetting("import.nfs.done")
|
||||
|
||||
if sambaDone == "true" && nfsDone == "true" {
|
||||
return result
|
||||
}
|
||||
|
||||
if sambaDone != "true" {
|
||||
sr := importSamba(ctx, database, smbPath)
|
||||
result.SambaImported = sr.count
|
||||
result.SambaSkipped = sr.skipped
|
||||
result.SambaError = sr.err
|
||||
if sr.err == "" {
|
||||
_ = database.SetSetting("import.samba.done", "true")
|
||||
}
|
||||
}
|
||||
|
||||
if nfsDone != "true" {
|
||||
nr := importNFS(ctx, database, exportsPath)
|
||||
result.NFSImported = nr.count
|
||||
result.NFSSkipped = nr.skipped
|
||||
result.NFSError = nr.err
|
||||
if nr.err == "" {
|
||||
_ = database.SetSetting("import.nfs.done", "true")
|
||||
}
|
||||
}
|
||||
|
||||
if result.SambaImported > 0 || result.NFSImported > 0 {
|
||||
log.Printf("[importer] imported %d samba shares, %d nfs exports", result.SambaImported, result.NFSImported)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ResetImportFlags(ctx context.Context, database ImporterDB) error {
|
||||
_ = database.SetSetting("import.samba.done", "")
|
||||
_ = database.SetSetting("import.nfs.done", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
type importStep struct {
|
||||
count int
|
||||
skipped bool
|
||||
err string
|
||||
}
|
||||
|
||||
func importSamba(ctx context.Context, database ImporterDB, path string) importStep {
|
||||
shares, err := ImportSambaShares(path)
|
||||
if err != nil {
|
||||
return importStep{err: err.Error()}
|
||||
}
|
||||
if shares == nil {
|
||||
return importStep{skipped: true}
|
||||
}
|
||||
|
||||
existing, err := database.ListSambaShares()
|
||||
if err != nil {
|
||||
return importStep{err: err.Error()}
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return importStep{skipped: true}
|
||||
}
|
||||
|
||||
if err := database.ReplaceSambaShares(shares); err != nil {
|
||||
return importStep{err: err.Error()}
|
||||
}
|
||||
|
||||
if err := database.MarkDirty(samba.ModuleName); err != nil {
|
||||
return importStep{count: len(shares), err: fmt.Sprintf("imported but could not mark dirty: %v", err)}
|
||||
}
|
||||
|
||||
return importStep{count: len(shares)}
|
||||
}
|
||||
|
||||
func importNFS(ctx context.Context, database ImporterDB, path string) importStep {
|
||||
exports, err := ImportNFSExports(path)
|
||||
if err != nil {
|
||||
return importStep{err: err.Error()}
|
||||
}
|
||||
if exports == nil {
|
||||
return importStep{skipped: true}
|
||||
}
|
||||
|
||||
existing, err := database.ListNFSExports()
|
||||
if err != nil {
|
||||
return importStep{err: err.Error()}
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return importStep{skipped: true}
|
||||
}
|
||||
|
||||
if err := database.ReplaceNFSExports(exports); err != nil {
|
||||
return importStep{err: err.Error()}
|
||||
}
|
||||
|
||||
if err := database.MarkDirty(nfs.ModuleName); err != nil {
|
||||
return importStep{count: len(exports), err: fmt.Sprintf("imported but could not mark dirty: %v", err)}
|
||||
}
|
||||
|
||||
return importStep{count: len(exports)}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/system"
|
||||
)
|
||||
|
||||
var nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:\-/\*@]+$`)
|
||||
|
||||
func ImportNFSExports(path string) ([]db.NFSExport, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read exports: %w", err)
|
||||
}
|
||||
|
||||
return parseExports(data)
|
||||
}
|
||||
|
||||
func parseExports(data []byte) ([]db.NFSExport, error) {
|
||||
var exports []db.NFSExport
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
export, ok := parseExportLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := system.ValidatePath(export.Path); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
validClients := make([]string, 0, len(export.Clients))
|
||||
for _, c := range export.Clients {
|
||||
if err := system.ValidateNFSClient(c); err != nil {
|
||||
continue
|
||||
}
|
||||
validClients = append(validClients, c)
|
||||
}
|
||||
if len(validClients) == 0 && len(export.Clients) > 0 {
|
||||
continue
|
||||
}
|
||||
export.Clients = validClients
|
||||
|
||||
if export.Options != "" {
|
||||
if err := system.ValidateNFSOptions(export.Options); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
exports = append(exports, export)
|
||||
}
|
||||
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
func parseExportLine(line string) (db.NFSExport, bool) {
|
||||
parenDepth := 0
|
||||
spaceIdx := -1
|
||||
|
||||
for i, ch := range line {
|
||||
switch ch {
|
||||
case '(':
|
||||
parenDepth++
|
||||
case ')':
|
||||
parenDepth--
|
||||
case ' ':
|
||||
if parenDepth == 0 && spaceIdx < 0 {
|
||||
spaceIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if spaceIdx < 0 {
|
||||
return db.NFSExport{}, false
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(line[:spaceIdx])
|
||||
if path == "" {
|
||||
return db.NFSExport{}, false
|
||||
}
|
||||
|
||||
rest := strings.TrimSpace(line[spaceIdx:])
|
||||
if rest == "" {
|
||||
return db.NFSExport{}, false
|
||||
}
|
||||
|
||||
var clients []string
|
||||
var options string
|
||||
|
||||
parts := splitExportsClients(rest)
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
open := strings.IndexByte(part, '(')
|
||||
close := strings.LastIndexByte(part, ')')
|
||||
|
||||
var client, opts string
|
||||
if open >= 0 && close > open {
|
||||
client = strings.TrimSpace(part[:open])
|
||||
opts = strings.TrimSpace(part[open+1 : close])
|
||||
} else {
|
||||
client = part
|
||||
opts = ""
|
||||
}
|
||||
|
||||
if !nfsClientPattern.MatchString(client) {
|
||||
continue
|
||||
}
|
||||
|
||||
clients = append(clients, client)
|
||||
if opts != "" && options == "" {
|
||||
options = opts
|
||||
}
|
||||
}
|
||||
|
||||
if len(clients) == 0 {
|
||||
return db.NFSExport{}, false
|
||||
}
|
||||
|
||||
if options == "" {
|
||||
options = "rw,sync,no_root_squash"
|
||||
}
|
||||
|
||||
return db.NFSExport{
|
||||
Path: path,
|
||||
Clients: clients,
|
||||
Options: options,
|
||||
}, true
|
||||
}
|
||||
|
||||
func splitExportsClients(s string) []string {
|
||||
var result []string
|
||||
var current []byte
|
||||
parenDepth := 0
|
||||
|
||||
for _, ch := range []byte(s) {
|
||||
switch ch {
|
||||
case '(':
|
||||
parenDepth++
|
||||
current = append(current, ch)
|
||||
case ')':
|
||||
parenDepth--
|
||||
current = append(current, ch)
|
||||
case ' ':
|
||||
if parenDepth == 0 {
|
||||
if len(current) > 0 {
|
||||
result = append(result, string(current))
|
||||
current = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
current = append(current, ch)
|
||||
default:
|
||||
current = append(current, ch)
|
||||
}
|
||||
}
|
||||
|
||||
if len(current) > 0 {
|
||||
result = append(result, string(current))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseExports(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic export",
|
||||
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync,no_subtree_check)
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple clients same path",
|
||||
input: `/srv/nfs/shared *(ro,sync) 192.168.1.0/24(rw,sync,no_root_squash)
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple exports",
|
||||
input: `# This is a comment
|
||||
/srv/nfs/data 192.168.1.0/24(rw,sync)
|
||||
/srv/nfs/public *(ro,sync)
|
||||
|
||||
# another comment
|
||||
/srv/nfs/backup 10.0.0.0/8(ro,sync,no_subtree_check)
|
||||
`,
|
||||
wantLen: 3,
|
||||
},
|
||||
{
|
||||
name: "wildcard client",
|
||||
input: `/srv/nfs/public *(ro,sync,no_root_squash)
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "wildcard client with parentheses",
|
||||
input: `/srv/nfs/shared *(rw,sync,no_root_squash)
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "only comments",
|
||||
input: `# comment 1
|
||||
# comment 2
|
||||
`,
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "multiple spaces between entries",
|
||||
input: `/srv/nfs/shared 192.168.1.0/24(rw,sync) 10.0.0.0/8(ro)
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseExports([]byte(tt.input))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseExports() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if len(got) != tt.wantLen {
|
||||
t.Errorf("parseExports() got %d exports, want %d", len(got), tt.wantLen)
|
||||
for i, e := range got {
|
||||
t.Logf(" export[%d]: path=%q clients=%v options=%q", i, e.Path, e.Clients, e.Options)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExportLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
wantPath string
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "single client with options",
|
||||
line: `/srv/nfs/shared 192.168.1.100(rw,sync,no_subtree_check)`,
|
||||
wantPath: `/srv/nfs/shared`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "wildcard with default options",
|
||||
line: `/srv/nfs/public *(ro)`,
|
||||
wantPath: `/srv/nfs/public`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple clients",
|
||||
line: `/srv/nfs/shared 192.168.1.0/24(rw) 10.0.0.0/8(ro)`,
|
||||
wantPath: `/srv/nfs/shared`,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "netgroup",
|
||||
line: `/srv/nfs/shared @admins(rw,sync)`,
|
||||
wantPath: `/srv/nfs/shared`,
|
||||
wantCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseExportLine(tt.line)
|
||||
if !ok {
|
||||
t.Errorf("parseExportLine(%q) returned false", tt.line)
|
||||
return
|
||||
}
|
||||
if got.Path != tt.wantPath {
|
||||
t.Errorf("parseExportLine(%q) path = %q, want %q", tt.line, got.Path, tt.wantPath)
|
||||
}
|
||||
if len(got.Clients) != tt.wantCount {
|
||||
t.Errorf("parseExportLine(%q) clients = %v, want %d", tt.line, got.Clients, tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitExportsClients(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{`192.168.1.0/24(rw,sync) 10.0.0.0/8(ro)`, []string{`192.168.1.0/24(rw,sync)`, `10.0.0.0/8(ro)`}},
|
||||
{`*(ro)`, []string{`*(ro)`}},
|
||||
{`192.168.1.100(rw)`, []string{`192.168.1.100(rw)`}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := splitExportsClients(tt.input)
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("splitExportsClients(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/system"
|
||||
)
|
||||
|
||||
var (
|
||||
sambaIgnoredSections = map[string]bool{
|
||||
"global": true,
|
||||
"printers": true,
|
||||
"homes": true,
|
||||
}
|
||||
)
|
||||
|
||||
type shareBuilder struct {
|
||||
Name string
|
||||
Path string
|
||||
Comment string
|
||||
ReadOnly bool
|
||||
GuestOK bool
|
||||
ValidUsers []string
|
||||
ValidGroups []string
|
||||
}
|
||||
|
||||
func ImportSambaShares(path string) ([]db.SambaShare, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read smb.conf: %w", err)
|
||||
}
|
||||
|
||||
return parseSambaConf(data)
|
||||
}
|
||||
|
||||
func parseSambaConf(data []byte) ([]db.SambaShare, error) {
|
||||
var shares []db.SambaShare
|
||||
var current *shareBuilder
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
var lineNum int
|
||||
var continuation string
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
raw := scanner.Text()
|
||||
|
||||
if continuation != "" {
|
||||
continuation += "\n" + raw
|
||||
if strings.HasSuffix(raw, "\\") {
|
||||
continuation = strings.TrimSuffix(continuation, "\\")
|
||||
continue
|
||||
}
|
||||
raw = continuation
|
||||
continuation = ""
|
||||
} else if strings.HasSuffix(strings.TrimSpace(raw), "\\") {
|
||||
continuation = strings.TrimSuffix(strings.TrimSpace(raw), "\\")
|
||||
continue
|
||||
}
|
||||
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
if raw == "" || startsWith(raw, '#', ';') {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
if current != nil && current.Name != "" && current.Path != "" {
|
||||
if share := currentToShare(current); share.Name != "" {
|
||||
shares = append(shares, share)
|
||||
}
|
||||
}
|
||||
sectionName := parseSectionName(raw)
|
||||
if sectionName == "" || sambaIgnoredSections[strings.ToLower(sectionName)] {
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
current = &shareBuilder{Name: sectionName}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key, value := parseKeyValue(raw)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(key) {
|
||||
case "path":
|
||||
current.Path = value
|
||||
case "comment":
|
||||
current.Comment = value
|
||||
case "read only":
|
||||
current.ReadOnly = parseBool(value)
|
||||
case "writable":
|
||||
current.ReadOnly = !parseBool(value)
|
||||
case "guest ok":
|
||||
current.GuestOK = parseBool(value)
|
||||
case "valid users":
|
||||
current.ValidUsers = parseCommaList(value)
|
||||
case "valid groups":
|
||||
current.ValidGroups = parseCommaList(value)
|
||||
case "include", "copy", "inherit owner", "inherit permissions", "nt acl support",
|
||||
"printable", "print mode", "lppause command", "lpresume command",
|
||||
"queuepause command", "queueresume command":
|
||||
}
|
||||
}
|
||||
|
||||
if current != nil && current.Name != "" && current.Path != "" {
|
||||
if share := currentToShare(current); share.Name != "" {
|
||||
shares = append(shares, share)
|
||||
}
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func startsWith(s string, chars ...byte) bool {
|
||||
for _, c := range chars {
|
||||
if len(s) > 0 && s[0] == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseSectionName(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(raw, "[") || !strings.HasSuffix(raw, "]") {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(raw, "[]")
|
||||
}
|
||||
|
||||
func parseKeyValue(raw string) (key, value string) {
|
||||
eq := strings.IndexByte(raw, '=')
|
||||
if eq < 0 {
|
||||
return "", ""
|
||||
}
|
||||
key = strings.TrimSpace(raw[:eq])
|
||||
value = strings.TrimSpace(raw[eq+1:])
|
||||
value = strings.Trim(value, "\"")
|
||||
|
||||
value = strings.TrimPrefix(value, "\"")
|
||||
value = strings.TrimSuffix(value, "\"")
|
||||
|
||||
return key, value
|
||||
}
|
||||
|
||||
func parseBool(s string) bool {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
return s == "yes" || s == "true" || s == "1" || s == "on"
|
||||
}
|
||||
|
||||
func parseCommaList(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
var out []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func currentToShare(b *shareBuilder) db.SambaShare {
|
||||
if b.Name == "" || b.Path == "" {
|
||||
return db.SambaShare{}
|
||||
}
|
||||
if err := system.ValidateShareName(b.Name); err != nil {
|
||||
return db.SambaShare{}
|
||||
}
|
||||
if err := system.ValidatePath(b.Path); err != nil {
|
||||
return db.SambaShare{}
|
||||
}
|
||||
for _, u := range b.ValidUsers {
|
||||
if err := system.ValidateUsername(u); err != nil {
|
||||
return db.SambaShare{}
|
||||
}
|
||||
}
|
||||
return db.SambaShare{
|
||||
Name: b.Name,
|
||||
Path: b.Path,
|
||||
Comment: b.Comment,
|
||||
ReadOnly: b.ReadOnly,
|
||||
GuestOK: b.GuestOK,
|
||||
ValidUsers: b.ValidUsers,
|
||||
ValidGroups: b.ValidGroups,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSambaConf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic share",
|
||||
input: `[global]
|
||||
workgroup = WORKGROUP
|
||||
security = user
|
||||
|
||||
[share1]
|
||||
path = /srv/samba/share1
|
||||
comment = Test Share
|
||||
read only = no
|
||||
guest ok = no
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple shares with valid users and groups",
|
||||
input: `[global]
|
||||
server string = Test Server
|
||||
|
||||
[public]
|
||||
path = /srv/samba/public
|
||||
comment = Public Files
|
||||
read only = yes
|
||||
guest ok = yes
|
||||
|
||||
[data]
|
||||
path = /srv/samba/data
|
||||
comment = Data Share
|
||||
read only = no
|
||||
writable = yes
|
||||
guest ok = no
|
||||
valid users = alice, bob
|
||||
valid groups = staff
|
||||
`,
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "ignores global section",
|
||||
input: `[global]
|
||||
workgroup = WORKGROUP
|
||||
security = user
|
||||
server string = test
|
||||
|
||||
[global]
|
||||
printing = cups
|
||||
|
||||
[myshare]
|
||||
path = /srv/samba/myshare
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "ignores printers section",
|
||||
input: `[printers]
|
||||
comment = All Printers
|
||||
path = /var/spool/samba
|
||||
printable = yes
|
||||
guest ok = yes
|
||||
|
||||
[global]
|
||||
workgroup = WORKGROUP
|
||||
|
||||
[myshare]
|
||||
path = /srv/samba/myshare
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "handles comments with semicolon",
|
||||
input: `; This is a comment
|
||||
[global]
|
||||
workgroup = WORKGROUP
|
||||
|
||||
[myshare]
|
||||
path = /srv/samba/myshare
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "handles continuation lines",
|
||||
input: `[myshare]
|
||||
path = /srv/samba/\
|
||||
myshare
|
||||
comment = continued\
|
||||
line
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "handles quoted values",
|
||||
input: `[myshare]
|
||||
path = "/srv/samba/myshare"
|
||||
comment = "Test comment"
|
||||
`,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "skip share without path",
|
||||
input: `[nopath]
|
||||
comment = No path here
|
||||
`,
|
||||
wantLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseSambaConf([]byte(tt.input))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseSambaConf() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if len(got) != tt.wantLen {
|
||||
t.Errorf("parseSambaConf() got %d shares, want %d", len(got), tt.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBool(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"yes", true},
|
||||
{"no", false},
|
||||
{"true", true},
|
||||
{"false", false},
|
||||
{"1", true},
|
||||
{"0", false},
|
||||
{"on", true},
|
||||
{"off", false},
|
||||
{"YES", true},
|
||||
{"NO", false},
|
||||
{"True", true},
|
||||
{"False", false},
|
||||
{"", false},
|
||||
{"maybe", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := parseBool(tt.input); got != tt.expected {
|
||||
t.Errorf("parseBool(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/system"
|
||||
@@ -53,6 +54,10 @@ func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) {
|
||||
}
|
||||
|
||||
func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
||||
if err := m.maybeBackup(database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exports, err := database.ListNFSExports()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -131,6 +136,31 @@ func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (m *Module) maybeBackup(database *db.DB) error {
|
||||
done, ok, err := database.GetSetting("backup.nfs.done")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && done == "true" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(m.cfg.ExportsPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return nil
|
||||
}
|
||||
src, err := os.ReadFile(m.cfg.ExportsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
backupPath := m.cfg.ExportsPath + ".nasctl.bak." + time.Now().Format("20060102T150405")
|
||||
if err := os.WriteFile(backupPath, src, 0o644); err != nil {
|
||||
return fmt.Errorf("backup exports: %w", err)
|
||||
}
|
||||
_ = database.SetSetting("backup.nfs.done", "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(path string, content []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/system"
|
||||
@@ -58,6 +59,10 @@ func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) {
|
||||
}
|
||||
|
||||
func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
||||
if err := m.maybeBackup(database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shares, err := database.ListSambaShares()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -121,6 +126,31 @@ func (m *Module) renderConfig(shares []db.SambaShare) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (m *Module) maybeBackup(database *db.DB) error {
|
||||
done, ok, err := database.GetSetting("backup.samba.done")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok && done == "true" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(m.cfg.SMBConfPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return nil
|
||||
}
|
||||
src, err := os.ReadFile(m.cfg.SMBConfPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
backupPath := m.cfg.SMBConfPath + ".nasctl.bak." + time.Now().Format("20060102T150405")
|
||||
if err := os.WriteFile(backupPath, src, 0o644); err != nil {
|
||||
return fmt.Errorf("backup smb.conf: %w", err)
|
||||
}
|
||||
_ = database.SetSetting("backup.samba.done", "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(path string, content []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
|
||||
@@ -174,11 +174,15 @@ type Server struct {
|
||||
Engine *engine.Engine
|
||||
AllowedRoots []string
|
||||
Auth *AuthService
|
||||
SMBConfPath string
|
||||
ExportsPath string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
AllowedRoots []string
|
||||
Auth *AuthService
|
||||
SMBConfPath string
|
||||
ExportsPath string
|
||||
}
|
||||
|
||||
func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
||||
@@ -187,6 +191,8 @@ func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
||||
Engine: eng,
|
||||
AllowedRoots: opts.AllowedRoots,
|
||||
Auth: opts.Auth,
|
||||
SMBConfPath: opts.SMBConfPath,
|
||||
ExportsPath: opts.ExportsPath,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/darroyo/nasctl/internal/db"
|
||||
"github.com/darroyo/nasctl/internal/importer"
|
||||
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||
)
|
||||
|
||||
type importStatus struct {
|
||||
Samba moduleImportStatus `json:"samba"`
|
||||
NFS moduleImportStatus `json:"nfs"`
|
||||
}
|
||||
|
||||
type moduleImportStatus struct {
|
||||
Done bool `json:"done"`
|
||||
LastImportAt string `json:"last_import_at,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) {
|
||||
sambaDone, _, _ := s.DB.GetSetting("import.samba.done")
|
||||
nfsDone, _, _ := s.DB.GetSetting("import.nfs.done")
|
||||
|
||||
shares, _ := s.DB.ListSambaShares()
|
||||
exports, _ := s.DB.ListNFSExports()
|
||||
|
||||
status := importStatus{
|
||||
Samba: moduleImportStatus{
|
||||
Done: sambaDone == "true",
|
||||
Count: len(shares),
|
||||
},
|
||||
NFS: moduleImportStatus{
|
||||
Done: nfsDone == "true",
|
||||
Count: len(exports),
|
||||
},
|
||||
}
|
||||
|
||||
if ts, ok, _ := s.DB.GetSetting("import.samba.at"); ok {
|
||||
status.Samba.LastImportAt = ts
|
||||
}
|
||||
if ts, ok, _ := s.DB.GetSetting("import.nfs.at"); ok {
|
||||
status.NFS.LastImportAt = ts
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportSamba(w http.ResponseWriter, r *http.Request) {
|
||||
if err := importer.ResetImportFlags(r.Context(), s.DB); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := importer.ImportSambaShares(s.SMBConfPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(shares) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"imported": 0, "message": "no shares found in smb.conf"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.ReplaceSambaShares(shares); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.MarkDirty(samba.ModuleName); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.DB.SetSetting("import.samba.done", "true")
|
||||
_ = s.DB.SetSetting("import.samba.at", time.Now().Format(time.RFC3339))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"imported": len(shares)})
|
||||
}
|
||||
|
||||
func (s *Server) handleImportNFS(w http.ResponseWriter, r *http.Request) {
|
||||
if err := importer.ResetImportFlags(r.Context(), s.DB); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
exports, err := importer.ImportNFSExports(s.ExportsPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(exports) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"imported": 0, "message": "no exports found in exports file"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.ReplaceNFSExports(exports); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.MarkDirty(nfs.ModuleName); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.DB.SetSetting("import.nfs.done", "true")
|
||||
_ = s.DB.SetSetting("import.nfs.at", time.Now().Format(time.RFC3339))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"imported": len(exports)})
|
||||
}
|
||||
|
||||
type dbExporter interface {
|
||||
ReplaceSambaShares(shares []db.SambaShare) error
|
||||
ReplaceNFSExports(exports []db.NFSExport) error
|
||||
MarkDirty(module string) error
|
||||
}
|
||||
|
||||
var _ dbExporter = (*db.DB)(nil)
|
||||
@@ -30,6 +30,10 @@ func NewRouter(s *Server) chi.Router {
|
||||
protected.Get("/apply/log", s.handleApplyLog)
|
||||
protected.Get("/system/status", s.handleSystemStatus)
|
||||
|
||||
protected.Get("/import/status", s.handleImportStatus)
|
||||
protected.Post("/import/samba", s.handleImportSamba)
|
||||
protected.Post("/import/nfs", s.handleImportNFS)
|
||||
|
||||
protected.Route("/samba/shares", func(shares chi.Router) {
|
||||
shares.Get("/", s.handleListSambaShares)
|
||||
shares.Post("/", s.handleCreateSambaShare)
|
||||
|
||||
Reference in New Issue
Block a user