Add nasctl: Go NAS control plane with React frontend
This commit is contained in:
+11
@@ -174,3 +174,14 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# Go / nasctl
|
||||||
|
bin/
|
||||||
|
.data/
|
||||||
|
*.db
|
||||||
|
dist/deb/
|
||||||
|
dist/*.deb
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
web/node_modules/
|
||||||
|
web/.tsbuild/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# nasctl — Agent Guidance
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # frontend (Vite builds to internal/web/dist) + Go binary → bin/nasctl
|
||||||
|
make build-go # Go only (requires internal/web/dist to exist)
|
||||||
|
make run # dev run: uses .data/, NASCTL_EXEC_SYSTEM=false
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite config (`web/vite.config.ts`) outputs directly to `internal/web/dist` for Go embedding. Do not change `outDir`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
No separate frontend test command; no lint/typecheck Makefile targets.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `cmd/nasctl/main.go` — entrypoint, CLI flags
|
||||||
|
- `internal/web/` — HTTP server, handlers, embed.go (frontend assets)
|
||||||
|
- `internal/db/` — SQLite models and queries
|
||||||
|
- `internal/modules/samba/`, `nfs/`, `users/` — config generators and system executors
|
||||||
|
- `internal/engine/apply.go` — dirty→apply orchestration
|
||||||
|
- `web/` — React frontend source (Vite + Tailwind + TypeScript)
|
||||||
|
|
||||||
|
## Key Runtime Behaviors
|
||||||
|
|
||||||
|
- Changes (shares, exports, users) are staged in SQLite and marked **dirty**; they require `POST /api/apply` to take effect.
|
||||||
|
- `NASCTL_EXEC_SYSTEM=false` (default in dev via `make run`) prevents real system changes: no service reloads, no useradd, no smbpasswd.
|
||||||
|
- In production, set `NASCTL_EXEC_SYSTEM=true` and ensure `NASCTL_ALLOWED_ROOTS` restricts share/export paths.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Dev default | Production default |
|
||||||
|
|---|---|---|
|
||||||
|
| `NASCTL_ADDR` | `:8080` | `:8080` |
|
||||||
|
| `NASCTL_DB` | `./.data/nasctl.db` | `/var/lib/nasctl/nasctl.db` |
|
||||||
|
| `NASCTL_SMB_CONF` | `./.data/smb.conf` | `/etc/samba/smb.conf` |
|
||||||
|
| `NASCTL_EXPORTS` | `./.data/exports` | `/etc/exports` |
|
||||||
|
| `NASCTL_EXEC_SYSTEM` | `false` | `true` |
|
||||||
|
| `NASCTL_ALLOWED_ROOTS` | (empty) | (empty = any absolute path) |
|
||||||
|
|
||||||
|
## Frontend Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm run dev # Vite on :5173, proxies /api to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `web/node_modules` (run `cd web && npm install` first).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
BINARY=nasctl
|
||||||
|
VERSION?=0.1.0
|
||||||
|
GO?=go
|
||||||
|
LDFLAGS=-s -w
|
||||||
|
BUILD_FLAGS=CGO_ENABLED=0
|
||||||
|
|
||||||
|
.PHONY: build build-all frontend run package clean tidy test
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
cd web && npm install && npm run build
|
||||||
|
|
||||||
|
build: frontend
|
||||||
|
$(BUILD_FLAGS) $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/nasctl
|
||||||
|
|
||||||
|
# build-go builds the binary without rebuilding the frontend (dist must exist).
|
||||||
|
build-go:
|
||||||
|
$(BUILD_FLAGS) $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/nasctl
|
||||||
|
|
||||||
|
build-all: frontend
|
||||||
|
$(BUILD_FLAGS) GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/nasctl
|
||||||
|
$(BUILD_FLAGS) GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-arm64 ./cmd/nasctl
|
||||||
|
|
||||||
|
run: build
|
||||||
|
NASCTL_DB=$${NASCTL_DB:-./.data/nasctl.db} \
|
||||||
|
NASCTL_SMB_CONF=$${NASCTL_SMB_CONF:-./.data/smb.conf} \
|
||||||
|
NASCTL_EXPORTS=$${NASCTL_EXPORTS:-./.data/exports} \
|
||||||
|
NASCTL_EXEC_SYSTEM=false \
|
||||||
|
./bin/$(BINARY) --addr :8080
|
||||||
|
|
||||||
|
package: build-all
|
||||||
|
@mkdir -p dist/deb/usr/local/bin dist/deb/etc/systemd/system dist/deb/DEBIAN
|
||||||
|
cp bin/$(BINARY)-linux-amd64 dist/deb/usr/local/bin/$(BINARY)
|
||||||
|
cp systemd/nasctl.service dist/deb/etc/systemd/system/$(BINARY).service
|
||||||
|
printf 'Package: nasctl\nVersion: $(VERSION)\nSection: utils\nPriority: optional\nArchitecture: amd64\nMaintainer: nasctl\nDescription: Monolithic NAS control plane\n' > dist/deb/DEBIAN/control
|
||||||
|
printf '#!/bin/sh\nset -e\ninstall -d /var/lib/nasctl\nsystemctl daemon-reload\nsystemctl enable nasctl.service || true\n' > dist/deb/DEBIAN/postinst
|
||||||
|
chmod 0755 dist/deb/DEBIAN/postinst
|
||||||
|
dpkg-deb --build dist/deb dist/nasctl_$(VERSION)_amd64.deb
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin dist .data web/node_modules web/.tsbuild
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
$(GO) mod tidy
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test ./...
|
||||||
@@ -1,2 +1,97 @@
|
|||||||
# baby-nas
|
# nasctl
|
||||||
|
|
||||||
|
Sistema NAS monolítico en Go, gestionable vía interfaz web, distribuido como un único binario estático.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- Linux (para aplicar configuración Samba/NFS en producción)
|
||||||
|
|
||||||
|
## Requisitos de build
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- Node.js 18+ y npm (solo para compilar el frontend embebido)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # compila frontend (npm) + binario Go con assets embebidos
|
||||||
|
```
|
||||||
|
|
||||||
|
Binario generado en `bin/nasctl`. Si ya tienes `internal/web/dist` compilado, `make build-go` compila solo el binario.
|
||||||
|
|
||||||
|
## Ejecución local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
Levanta en `http://localhost:8080`. Credenciales iniciales por defecto `admin` / `admin` (cámbialas con las variables de entorno o tras el primer login).
|
||||||
|
|
||||||
|
Para desarrollo del frontend con hot-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm run dev # Vite en :5173, proxya /api a :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables de entorno útiles:
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `NASCTL_ADDR` | `:8080` | Dirección HTTP |
|
||||||
|
| `NASCTL_DB` | `/var/lib/nasctl/nasctl.db` | Ruta SQLite |
|
||||||
|
| `NASCTL_SMB_CONF` | `/etc/samba/smb.conf` | Destino de `smb.conf` generado |
|
||||||
|
| `NASCTL_EXPORTS` | `/etc/exports` | Destino de `/etc/exports` generado |
|
||||||
|
| `NASCTL_EXEC_SYSTEM` | `true` | Ejecutar comandos reales del sistema (reload servicios, `useradd`, `smbpasswd`...) |
|
||||||
|
| `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) |
|
||||||
|
|
||||||
|
En desarrollo, `make run` usa `./.data/` y `NASCTL_EXEC_SYSTEM=false` (no toca el sistema real).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Autenticación (cookie de sesión firmada, httpOnly):
|
||||||
|
|
||||||
|
- `POST /api/auth/login` — `{username, password}` → cookie de sesión
|
||||||
|
- `POST /api/auth/logout`
|
||||||
|
- `GET /api/auth/status`
|
||||||
|
|
||||||
|
El resto de endpoints requieren sesión válida:
|
||||||
|
|
||||||
|
- `GET /api/dirty` — módulos con cambios pendientes
|
||||||
|
- `POST /api/apply` — aplica configuración pendiente
|
||||||
|
- `GET /api/apply/log` — historial de aplicaciones
|
||||||
|
- `GET /api/system/status` — uso de disco y estado de servicios
|
||||||
|
- `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
|
||||||
|
|
||||||
|
Todos los cambios marcan el módulo como dirty y NO se aplican al sistema hasta `POST /api/apply`.
|
||||||
|
|
||||||
|
## Flujo dirty → apply
|
||||||
|
|
||||||
|
1. Los cambios se guardan solo en SQLite.
|
||||||
|
2. Se marca el módulo correspondiente como dirty.
|
||||||
|
3. El usuario pulsa "Aplicar cambios" (`POST /api/apply`).
|
||||||
|
4. El motor regenera archivos de configuración desde templates y recarga servicios.
|
||||||
|
|
||||||
|
## Empaquetado
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make package
|
||||||
|
```
|
||||||
|
|
||||||
|
Genera `dist/nasctl_0.1.0_amd64.deb`.
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Implementado: módulos Samba, NFS y usuarios (con flujo dirty→apply), autenticación web
|
||||||
|
(bcrypt + cookie de sesión firmada), validación de paths permitidos, dashboard con estado del
|
||||||
|
sistema y frontend React + Vite + Tailwind embebido en el binario.
|
||||||
|
|
||||||
|
## Próximas mejoras posibles
|
||||||
|
|
||||||
|
- Gestión de admins web desde la UI (cambio de contraseña)
|
||||||
|
- Polkit para delegar acciones puntuales sin correr como root
|
||||||
|
- Métricas e histórico más detallados en el dashboard
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/engine"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/users"
|
||||||
|
"github.com/darroyo/nasctl/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := flag.String("addr", envOrDefault("NASCTL_ADDR", ":8080"), "HTTP listen address")
|
||||||
|
dbPath := flag.String("db", envOrDefault("NASCTL_DB", "/var/lib/nasctl/nasctl.db"), "SQLite database path")
|
||||||
|
smbConfPath := flag.String("smb-conf", envOrDefault("NASCTL_SMB_CONF", "/etc/samba/smb.conf"), "Samba config output path")
|
||||||
|
exportsPath := flag.String("exports", envOrDefault("NASCTL_EXPORTS", "/etc/exports"), "NFS exports output path")
|
||||||
|
execSystem := flag.Bool("exec-system", envOrDefault("NASCTL_EXEC_SYSTEM", "true") == "true", "Execute real system commands (reload services, useradd, etc.)")
|
||||||
|
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)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil {
|
||||||
|
log.Fatalf("create db dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := db.Open(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
if err := database.Migrate(); err != nil {
|
||||||
|
log.Fatalf("migrate db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := web.NewAuthService(database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("init auth: %v", err)
|
||||||
|
}
|
||||||
|
created, err := auth.EnsureAdmin(*adminUser, *adminPass)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("ensure admin: %v", err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
log.Printf("created initial admin user %q — change the password after first login", *adminUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
sambaModule := samba.New(samba.Config{
|
||||||
|
SMBConfPath: *smbConfPath,
|
||||||
|
Reload: *execSystem,
|
||||||
|
})
|
||||||
|
nfsModule := nfs.New(nfs.Config{
|
||||||
|
ExportsPath: *exportsPath,
|
||||||
|
Reload: *execSystem,
|
||||||
|
})
|
||||||
|
usersModule := users.New(users.Config{
|
||||||
|
Execute: *execSystem,
|
||||||
|
CreateHome: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
eng := engine.New(sambaModule, nfsModule, usersModule)
|
||||||
|
srv := web.NewServer(database, eng, web.Options{
|
||||||
|
AllowedRoots: parseRoots(*allowedRoots),
|
||||||
|
Auth: auth,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("nasctl listening on %s (db=%s exec-system=%v)", *addr, *dbPath, *execSystem)
|
||||||
|
if err := http.ListenAndServe(*addr, srv.Handler()); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoots(raw string) []string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
roots := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
roots = append(roots, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module github.com/darroyo/nasctl
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
modernc.org/sqlite v1.35.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
modernc.org/libc v1.61.13 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.8.2 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||||
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||||
|
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||||
|
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||||
|
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||||
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
|
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||||
|
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||||
|
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||||
|
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
|
||||||
|
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Close() error {
|
||||||
|
return d.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Conn() *sql.DB {
|
||||||
|
return d.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) Migrate() error {
|
||||||
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations: %w", err)
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].Name() < entries[j].Name()
|
||||||
|
})
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
if _, err := d.conn.Exec(string(content)); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS system_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
uid INTEGER,
|
||||||
|
gid INTEGER,
|
||||||
|
groups TEXT NOT NULL DEFAULT '[]',
|
||||||
|
smb_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pending_password TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS samba_shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
read_only INTEGER NOT NULL DEFAULT 0,
|
||||||
|
guest_ok INTEGER NOT NULL DEFAULT 0,
|
||||||
|
valid_users TEXT NOT NULL DEFAULT '[]',
|
||||||
|
valid_groups TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nfs_exports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
clients TEXT NOT NULL DEFAULT '[]',
|
||||||
|
options TEXT NOT NULL DEFAULT 'rw,sync,no_root_squash',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deleted_system_users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
marked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dirty_modules (
|
||||||
|
module TEXT PRIMARY KEY,
|
||||||
|
marked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS apply_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
module TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
UID *int64 `json:"uid,omitempty"`
|
||||||
|
GID *int64 `json:"gid,omitempty"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
SMBEnabled bool `json:"smb_enabled"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
// PendingPassword holds a not-yet-applied password. Never serialized.
|
||||||
|
PendingPassword string `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Admin struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SambaShare struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
GuestOK bool `json:"guest_ok"`
|
||||||
|
ValidUsers []string `json:"valid_users"`
|
||||||
|
ValidGroups []string `json:"valid_groups"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NFSExport struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Clients []string `json:"clients"`
|
||||||
|
Options string `json:"options"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirtyModule struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
MarkedAt time.Time `json:"marked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplyLogEntry struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseTime(value string) time.Time {
|
||||||
|
t, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
t, _ = time.Parse("2006-01-02 15:04:05", value)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJSONStrings(values []string) (string, error) {
|
||||||
|
if values == nil {
|
||||||
|
values = []string{}
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONStrings(raw string) ([]string, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSambaShare(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (SambaShare, error) {
|
||||||
|
var share SambaShare
|
||||||
|
var readOnly, guestOK int
|
||||||
|
var validUsers, validGroups, createdAt, updatedAt string
|
||||||
|
if err := row.Scan(
|
||||||
|
&share.ID,
|
||||||
|
&share.Name,
|
||||||
|
&share.Path,
|
||||||
|
&share.Comment,
|
||||||
|
&readOnly,
|
||||||
|
&guestOK,
|
||||||
|
&validUsers,
|
||||||
|
&validGroups,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
share.ReadOnly = readOnly != 0
|
||||||
|
share.GuestOK = guestOK != 0
|
||||||
|
var err error
|
||||||
|
share.ValidUsers, err = decodeJSONStrings(validUsers)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
share.ValidGroups, err = decodeJSONStrings(validGroups)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
share.CreatedAt = parseTime(createdAt)
|
||||||
|
share.UpdatedAt = parseTime(updatedAt)
|
||||||
|
return share, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) MarkDirty(module string) error {
|
||||||
|
_, err := d.conn.Exec(
|
||||||
|
`INSERT INTO dirty_modules (module, marked_at) VALUES (?, datetime('now'))
|
||||||
|
ON CONFLICT(module) DO UPDATE SET marked_at = datetime('now')`,
|
||||||
|
module,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark dirty %q: %w", module, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ClearDirty(module string) error {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM dirty_modules WHERE module = ?`, module)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear dirty %q: %w", module, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) IsDirty(module string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := d.conn.QueryRow(`SELECT COUNT(1) FROM dirty_modules WHERE module = ?`, module).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("is dirty %q: %w", module, err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListDirty() ([]DirtyModule, error) {
|
||||||
|
rows, err := d.conn.Query(`SELECT module, marked_at FROM dirty_modules ORDER BY marked_at ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list dirty: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var modules []DirtyModule
|
||||||
|
for rows.Next() {
|
||||||
|
var module DirtyModule
|
||||||
|
var markedAt string
|
||||||
|
if err := rows.Scan(&module.Module, &markedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan dirty: %w", err)
|
||||||
|
}
|
||||||
|
module.MarkedAt = parseTime(markedAt)
|
||||||
|
modules = append(modules, module)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return modules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) AppendApplyLog(module, message string, success bool) error {
|
||||||
|
successInt := 0
|
||||||
|
if success {
|
||||||
|
successInt = 1
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(
|
||||||
|
`INSERT INTO apply_log (module, message, success, created_at) VALUES (?, ?, ?, datetime('now'))`,
|
||||||
|
module, message, successInt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("append apply log: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListApplyLog(limit int) ([]ApplyLogEntry, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := d.conn.Query(
|
||||||
|
`SELECT id, module, message, success, created_at FROM apply_log ORDER BY id DESC LIMIT ?`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list apply log: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []ApplyLogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry ApplyLogEntry
|
||||||
|
var success int
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&entry.ID, &entry.Module, &entry.Message, &success, &createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan apply log: %w", err)
|
||||||
|
}
|
||||||
|
entry.Success = success != 0
|
||||||
|
entry.CreatedAt = parseTime(createdAt)
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListSambaShares() ([]SambaShare, error) {
|
||||||
|
rows, err := d.conn.Query(`
|
||||||
|
SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at
|
||||||
|
FROM samba_shares ORDER BY name ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list samba shares: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var shares []SambaShare
|
||||||
|
for rows.Next() {
|
||||||
|
share, err := scanSambaShare(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan samba share: %w", err)
|
||||||
|
}
|
||||||
|
shares = append(shares, share)
|
||||||
|
}
|
||||||
|
return shares, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetSambaShare(id int64) (SambaShare, error) {
|
||||||
|
row := d.conn.QueryRow(`
|
||||||
|
SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at
|
||||||
|
FROM samba_shares WHERE id = ?`, id)
|
||||||
|
share, err := scanSambaShare(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return SambaShare{}, fmt.Errorf("samba share not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, fmt.Errorf("get samba share: %w", err)
|
||||||
|
}
|
||||||
|
return share, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CreateSambaShare(share SambaShare) (SambaShare, error) {
|
||||||
|
validUsers, err := encodeJSONStrings(share.ValidUsers)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
validGroups, err := encodeJSONStrings(share.ValidGroups)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
readOnly := 0
|
||||||
|
if share.ReadOnly {
|
||||||
|
readOnly = 1
|
||||||
|
}
|
||||||
|
guestOK := 0
|
||||||
|
if share.GuestOK {
|
||||||
|
guestOK = 1
|
||||||
|
}
|
||||||
|
result, err := d.conn.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 SambaShare{}, fmt.Errorf("create samba share: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, fmt.Errorf("last insert id: %w", err)
|
||||||
|
}
|
||||||
|
return d.GetSambaShare(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateSambaShare(id int64, share SambaShare) (SambaShare, error) {
|
||||||
|
validUsers, err := encodeJSONStrings(share.ValidUsers)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
validGroups, err := encodeJSONStrings(share.ValidGroups)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
readOnly := 0
|
||||||
|
if share.ReadOnly {
|
||||||
|
readOnly = 1
|
||||||
|
}
|
||||||
|
guestOK := 0
|
||||||
|
if share.GuestOK {
|
||||||
|
guestOK = 1
|
||||||
|
}
|
||||||
|
result, err := d.conn.Exec(`
|
||||||
|
UPDATE samba_shares
|
||||||
|
SET name = ?, path = ?, comment = ?, read_only = ?, guest_ok = ?, valid_users = ?, valid_groups = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, fmt.Errorf("update samba share: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return SambaShare{}, fmt.Errorf("samba share not found")
|
||||||
|
}
|
||||||
|
return d.GetSambaShare(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteSambaShare(id int64) error {
|
||||||
|
result, err := d.conn.Exec(`DELETE FROM samba_shares WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete samba share: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("samba share not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanNFSExport(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (NFSExport, error) {
|
||||||
|
var export NFSExport
|
||||||
|
var clients, createdAt, updatedAt string
|
||||||
|
if err := row.Scan(
|
||||||
|
&export.ID,
|
||||||
|
&export.Path,
|
||||||
|
&clients,
|
||||||
|
&export.Options,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return NFSExport{}, err
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
export.Clients, err = decodeJSONStrings(clients)
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, err
|
||||||
|
}
|
||||||
|
export.CreatedAt = parseTime(createdAt)
|
||||||
|
export.UpdatedAt = parseTime(updatedAt)
|
||||||
|
return export, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListNFSExports() ([]NFSExport, error) {
|
||||||
|
rows, err := d.conn.Query(`
|
||||||
|
SELECT id, path, clients, options, created_at, updated_at
|
||||||
|
FROM nfs_exports ORDER BY path ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list nfs exports: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var exports []NFSExport
|
||||||
|
for rows.Next() {
|
||||||
|
export, err := scanNFSExport(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan nfs export: %w", err)
|
||||||
|
}
|
||||||
|
exports = append(exports, export)
|
||||||
|
}
|
||||||
|
return exports, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetNFSExport(id int64) (NFSExport, error) {
|
||||||
|
row := d.conn.QueryRow(`
|
||||||
|
SELECT id, path, clients, options, created_at, updated_at
|
||||||
|
FROM nfs_exports WHERE id = ?`, id)
|
||||||
|
export, err := scanNFSExport(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return NFSExport{}, fmt.Errorf("nfs export not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, fmt.Errorf("get nfs export: %w", err)
|
||||||
|
}
|
||||||
|
return export, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
|
||||||
|
clients, err := encodeJSONStrings(export.Clients)
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, err
|
||||||
|
}
|
||||||
|
result, err := d.conn.Exec(`
|
||||||
|
INSERT INTO nfs_exports (path, clients, options)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
export.Path, clients, export.Options,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, fmt.Errorf("create nfs export: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, fmt.Errorf("last insert id: %w", err)
|
||||||
|
}
|
||||||
|
return d.GetNFSExport(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateNFSExport(id int64, export NFSExport) (NFSExport, error) {
|
||||||
|
clients, err := encodeJSONStrings(export.Clients)
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, err
|
||||||
|
}
|
||||||
|
result, err := d.conn.Exec(`
|
||||||
|
UPDATE nfs_exports
|
||||||
|
SET path = ?, clients = ?, options = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
export.Path, clients, export.Options, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, fmt.Errorf("update nfs export: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return NFSExport{}, err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return NFSExport{}, fmt.Errorf("nfs export not found")
|
||||||
|
}
|
||||||
|
return d.GetNFSExport(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteNFSExport(id int64) error {
|
||||||
|
result, err := d.conn.Exec(`DELETE FROM nfs_exports WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete nfs export: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("nfs export not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanUser(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (User, error) {
|
||||||
|
var user User
|
||||||
|
var uid, gid sql.NullInt64
|
||||||
|
var groups, createdAt, updatedAt string
|
||||||
|
var smbEnabled, disabled int
|
||||||
|
if err := row.Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&uid,
|
||||||
|
&gid,
|
||||||
|
&groups,
|
||||||
|
&smbEnabled,
|
||||||
|
&disabled,
|
||||||
|
&user.PendingPassword,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if uid.Valid {
|
||||||
|
user.UID = &uid.Int64
|
||||||
|
}
|
||||||
|
if gid.Valid {
|
||||||
|
user.GID = &gid.Int64
|
||||||
|
}
|
||||||
|
user.SMBEnabled = smbEnabled != 0
|
||||||
|
user.Disabled = disabled != 0
|
||||||
|
var err error
|
||||||
|
user.Groups, err = decodeJSONStrings(groups)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user.CreatedAt = parseTime(createdAt)
|
||||||
|
user.UpdatedAt = parseTime(updatedAt)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, username, uid, gid, groups, smb_enabled, disabled, pending_password, created_at, updated_at`
|
||||||
|
|
||||||
|
func (d *DB) ListUsers() ([]User, error) {
|
||||||
|
rows, err := d.conn.Query(`SELECT ` + userColumns + ` FROM system_users ORDER BY username ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []User
|
||||||
|
for rows.Next() {
|
||||||
|
user, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan user: %w", err)
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetUser(id int64) (User, error) {
|
||||||
|
row := d.conn.QueryRow(`SELECT `+userColumns+` FROM system_users WHERE id = ?`, id)
|
||||||
|
user, err := scanUser(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return User{}, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("get user: %w", err)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CreateUser(user User) (User, error) {
|
||||||
|
groups, err := encodeJSONStrings(user.Groups)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
result, err := d.conn.Exec(`
|
||||||
|
INSERT INTO system_users (username, groups, smb_enabled, disabled, pending_password)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
user.Username, groups, boolToInt(user.SMBEnabled), boolToInt(user.Disabled), user.PendingPassword,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("last insert id: %w", err)
|
||||||
|
}
|
||||||
|
return d.GetUser(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateUser(id int64, user User) (User, error) {
|
||||||
|
groups, err := encodeJSONStrings(user.Groups)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
result, err := d.conn.Exec(`
|
||||||
|
UPDATE system_users
|
||||||
|
SET groups = ?, smb_enabled = ?, disabled = ?, pending_password = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
groups, boolToInt(user.SMBEnabled), boolToInt(user.Disabled), user.PendingPassword, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return User{}, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return d.GetUser(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetUserPendingPassword(id int64, password string) error {
|
||||||
|
_, err := d.conn.Exec(
|
||||||
|
`UPDATE system_users SET pending_password = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
password, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set pending password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearUserPendingPassword removes the stored password once it has been applied.
|
||||||
|
func (d *DB) ClearUserPendingPassword(id int64) error {
|
||||||
|
_, err := d.conn.Exec(
|
||||||
|
`UPDATE system_users SET pending_password = '' WHERE id = ?`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear pending password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes the user row and records a tombstone so the users module
|
||||||
|
// can run userdel on the next apply.
|
||||||
|
func (d *DB) DeleteUser(id int64) error {
|
||||||
|
user, err := d.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 system_users WHERE id = ?`, id); err != nil {
|
||||||
|
return fmt.Errorf("delete user: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO deleted_system_users (username, marked_at) VALUES (?, datetime('now'))
|
||||||
|
ON CONFLICT(username) DO UPDATE SET marked_at = datetime('now')`,
|
||||||
|
user.Username,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("record deleted user: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListDeletedUsers() ([]string, error) {
|
||||||
|
rows, err := d.conn.Query(`SELECT username FROM deleted_system_users ORDER BY username ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list deleted users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var usernames []string
|
||||||
|
for rows.Next() {
|
||||||
|
var username string
|
||||||
|
if err := rows.Scan(&username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
usernames = append(usernames, username)
|
||||||
|
}
|
||||||
|
return usernames, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ClearDeletedUser(username string) error {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM deleted_system_users WHERE username = ?`, username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear deleted user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetAdminByUsername(username string) (Admin, error) {
|
||||||
|
row := d.conn.QueryRow(
|
||||||
|
`SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE username = ?`,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
var admin Admin
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
err := row.Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &createdAt, &updatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Admin{}, fmt.Errorf("admin not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Admin{}, fmt.Errorf("get admin: %w", err)
|
||||||
|
}
|
||||||
|
admin.CreatedAt = parseTime(createdAt)
|
||||||
|
admin.UpdatedAt = parseTime(updatedAt)
|
||||||
|
return admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CountAdmins() (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(1) FROM admins`).Scan(&count); err != nil {
|
||||||
|
return 0, fmt.Errorf("count admins: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CreateAdmin(username, passwordHash string) (Admin, error) {
|
||||||
|
if _, err := d.conn.Exec(
|
||||||
|
`INSERT INTO admins (username, password_hash) VALUES (?, ?)`,
|
||||||
|
username, passwordHash,
|
||||||
|
); err != nil {
|
||||||
|
return Admin{}, fmt.Errorf("create admin: %w", err)
|
||||||
|
}
|
||||||
|
return d.GetAdminByUsername(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetSetting(key string) (string, bool, error) {
|
||||||
|
var value string
|
||||||
|
err := d.conn.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("get setting %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return value, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetSetting(key, value string) error {
|
||||||
|
_, err := d.conn.Exec(
|
||||||
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
key, value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set setting %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModuleResult struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Applied bool `json:"applied"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplyResult struct {
|
||||||
|
Results []ModuleResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
modules []modules.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(mods ...modules.Module) *Engine {
|
||||||
|
return &Engine{modules: mods}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) DirtyList(ctx context.Context, database *db.DB) ([]db.DirtyModule, error) {
|
||||||
|
return database.ListDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ApplyAll(ctx context.Context, database *db.DB) (ApplyResult, error) {
|
||||||
|
result := ApplyResult{Results: make([]ModuleResult, 0)}
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, mod := range e.modules {
|
||||||
|
dirty, err := mod.IsDirty(ctx, database)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: check dirty: %v", mod.Name(), err))
|
||||||
|
result.Results = append(result.Results, ModuleResult{
|
||||||
|
Module: mod.Name(),
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mod.Apply(ctx, database); err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %s", mod.Name(), msg))
|
||||||
|
_ = database.AppendApplyLog(mod.Name(), msg, false)
|
||||||
|
result.Results = append(result.Results, ModuleResult{
|
||||||
|
Module: mod.Name(),
|
||||||
|
Error: msg,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Results = append(result.Results, ModuleResult{
|
||||||
|
Module: mod.Name(),
|
||||||
|
Applied: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return result, fmt.Errorf("%s", strings.Join(errors, "; "))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package modules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module represents a configurable subsystem managed by nasctl.
|
||||||
|
type Module interface {
|
||||||
|
Name() string
|
||||||
|
IsDirty(ctx context.Context, database *db.DB) (bool, error)
|
||||||
|
Apply(ctx context.Context, database *db.DB) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by nasctl. Do not edit manually.
|
||||||
|
{{range .Exports}}
|
||||||
|
{{.Path}} {{.ClientSpec}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package nfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ModuleName = "nfs"
|
||||||
|
|
||||||
|
//go:embed exports.tmpl
|
||||||
|
var exportsTemplate embed.FS
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ExportsPath string
|
||||||
|
Reload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateExport struct {
|
||||||
|
Path string
|
||||||
|
ClientSpec string
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
|
Exports []templateExport
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Module {
|
||||||
|
if cfg.ExportsPath == "" {
|
||||||
|
cfg.ExportsPath = "/etc/exports"
|
||||||
|
}
|
||||||
|
return &Module{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Name() string {
|
||||||
|
return ModuleName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) {
|
||||||
|
return database.IsDirty(ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
||||||
|
exports, err := database.ListNFSExports()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := m.renderConfig(exports)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeAtomic(m.cfg.ExportsPath, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.cfg.Reload {
|
||||||
|
stdout, stderr, err := system.Run(ctx, "exportfs", "-ra")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exportfs -ra: %w (stdout=%q stderr=%q)", err, stdout, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("applied %d nfs export(s) to %s", len(exports), m.cfg.ExportsPath)
|
||||||
|
if err := database.AppendApplyLog(ModuleName, message, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return database.ClearDirty(ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientSpec builds the "client(opts) client(opts)" segment of an exports line.
|
||||||
|
// If no clients are configured, it defaults to "*(opts)".
|
||||||
|
func clientSpec(clients []string, options string) string {
|
||||||
|
opts := strings.TrimSpace(options)
|
||||||
|
if opts == "" {
|
||||||
|
opts = "ro"
|
||||||
|
}
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return fmt.Sprintf("*(%s)", opts)
|
||||||
|
}
|
||||||
|
specs := make([]string, 0, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
client = strings.TrimSpace(client)
|
||||||
|
if client == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
specs = append(specs, fmt.Sprintf("%s(%s)", client, opts))
|
||||||
|
}
|
||||||
|
if len(specs) == 0 {
|
||||||
|
return fmt.Sprintf("*(%s)", opts)
|
||||||
|
}
|
||||||
|
return strings.Join(specs, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) {
|
||||||
|
tmplContent, err := exportsTemplate.ReadFile("exports.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read exports template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("exports").Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse exports template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{Exports: make([]templateExport, 0, len(exports))}
|
||||||
|
for _, export := range exports {
|
||||||
|
data.Exports = append(data.Exports, templateExport{
|
||||||
|
Path: export.Path,
|
||||||
|
ClientSpec: clientSpec(export.Clients, export.Options),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("execute exports template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAtomic(path string, content []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(dir, ".exports.*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tmp.Write(content); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("write temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Chmod(0o644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("chmod temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("close temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("rename temp config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package samba
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ModuleName = "samba"
|
||||||
|
|
||||||
|
//go:embed smb.conf.tmpl
|
||||||
|
var smbConfTemplate embed.FS
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SMBConfPath string
|
||||||
|
Reload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateShare struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Comment string
|
||||||
|
ReadOnly bool
|
||||||
|
GuestOK bool
|
||||||
|
ValidUsers []string
|
||||||
|
ValidGroups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
|
Shares []templateShare
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Module {
|
||||||
|
if cfg.SMBConfPath == "" {
|
||||||
|
cfg.SMBConfPath = "/etc/samba/smb.conf"
|
||||||
|
}
|
||||||
|
return &Module{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Name() string {
|
||||||
|
return ModuleName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) {
|
||||||
|
return database.IsDirty(ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
||||||
|
shares, err := database.ListSambaShares()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := m.renderConfig(shares)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeAtomic(m.cfg.SMBConfPath, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.cfg.Reload {
|
||||||
|
stdout, stderr, err := system.Run(ctx, "systemctl", "reload", "smbd")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reload smbd: %w (stdout=%q stderr=%q)", err, stdout, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("applied %d samba share(s) to %s", len(shares), m.cfg.SMBConfPath)
|
||||||
|
if err := database.AppendApplyLog(ModuleName, message, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return database.ClearDirty(ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) renderConfig(shares []db.SambaShare) ([]byte, error) {
|
||||||
|
tmplContent, err := smbConfTemplate.ReadFile("smb.conf.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read smb.conf template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"join": strings.Join,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("smb.conf").Funcs(funcMap).Parse(string(tmplContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse smb.conf template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{Shares: make([]templateShare, 0, len(shares))}
|
||||||
|
for _, share := range shares {
|
||||||
|
data.Shares = append(data.Shares, templateShare{
|
||||||
|
Name: share.Name,
|
||||||
|
Path: share.Path,
|
||||||
|
Comment: share.Comment,
|
||||||
|
ReadOnly: share.ReadOnly,
|
||||||
|
GuestOK: share.GuestOK,
|
||||||
|
ValidUsers: share.ValidUsers,
|
||||||
|
ValidGroups: share.ValidGroups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("execute smb.conf template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAtomic(path string, content []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(dir, ".smb.conf.*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tmp.Write(content); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("write temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Chmod(0o644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("chmod temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("close temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("rename temp config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by nasctl. Do not edit manually.
|
||||||
|
|
||||||
|
[global]
|
||||||
|
workgroup = WORKGROUP
|
||||||
|
server string = nasctl
|
||||||
|
security = user
|
||||||
|
map to guest = Bad User
|
||||||
|
dns proxy = no
|
||||||
|
|
||||||
|
{{range .Shares}}
|
||||||
|
[{{.Name}}]
|
||||||
|
path = {{.Path}}
|
||||||
|
comment = {{.Comment}}
|
||||||
|
browseable = yes
|
||||||
|
read only = {{if .ReadOnly}}yes{{else}}no{{end}}
|
||||||
|
guest ok = {{if .GuestOK}}yes{{else}}no{{end}}
|
||||||
|
{{- if .ValidUsers}}
|
||||||
|
valid users = {{join .ValidUsers ","}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .ValidGroups}}
|
||||||
|
valid groups = {{join .ValidGroups ","}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ModuleName = "users"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// Execute controls whether system commands actually run. Disabled in dev.
|
||||||
|
Execute bool
|
||||||
|
// DefaultShell used when creating users.
|
||||||
|
DefaultShell string
|
||||||
|
// CreateHome creates a home directory for new users.
|
||||||
|
CreateHome bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Module {
|
||||||
|
if cfg.DefaultShell == "" {
|
||||||
|
cfg.DefaultShell = "/usr/sbin/nologin"
|
||||||
|
}
|
||||||
|
return &Module{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Name() string {
|
||||||
|
return ModuleName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) {
|
||||||
|
return database.IsDirty(ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
||||||
|
deleted, err := database.ListDeletedUsers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, username := range deleted {
|
||||||
|
if err := m.deleteUser(ctx, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := database.ClearDeletedUser(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usersList, err := database.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range usersList {
|
||||||
|
if err := m.reconcileUser(ctx, database, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("reconciled %d user(s), %d deletion(s)", len(usersList), len(deleted))
|
||||||
|
if err := database.AppendApplyLog(ModuleName, message, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return database.ClearDirty(ModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) reconcileUser(ctx context.Context, database *db.DB, user db.User) error {
|
||||||
|
if err := system.ValidateUsername(user.Username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
exists := m.cfg.Execute && system.UserExists(ctx, user.Username)
|
||||||
|
|
||||||
|
if !m.cfg.Execute {
|
||||||
|
// In dev mode we do not touch the system; just clear pending secrets
|
||||||
|
// so they are not retained indefinitely.
|
||||||
|
if user.PendingPassword != "" {
|
||||||
|
return database.ClearUserPendingPassword(user.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := sanitizeGroups(user.Groups)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
args := []string{"-s", m.cfg.DefaultShell}
|
||||||
|
if m.cfg.CreateHome {
|
||||||
|
args = append(args, "-m")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-M")
|
||||||
|
}
|
||||||
|
if len(groups) > 0 {
|
||||||
|
args = append(args, "-G", strings.Join(groups, ","))
|
||||||
|
}
|
||||||
|
args = append(args, user.Username)
|
||||||
|
if err := m.run(ctx, "useradd", args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args := []string{"-G", strings.Join(groups, ",")}
|
||||||
|
args = append(args, user.Username)
|
||||||
|
if err := m.run(ctx, "usermod", args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Disabled {
|
||||||
|
if err := m.run(ctx, "usermod", "-L", user.Username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unlock; ignore error when there is nothing to unlock.
|
||||||
|
_ = m.run(ctx, "usermod", "-U", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.applySMB(ctx, database, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) applySMB(ctx context.Context, database *db.DB, user db.User) error {
|
||||||
|
if user.SMBEnabled {
|
||||||
|
if user.PendingPassword != "" {
|
||||||
|
// smbpasswd -a -s reads the new password twice from stdin.
|
||||||
|
input := user.PendingPassword + "\n" + user.PendingPassword + "\n"
|
||||||
|
stdout, stderr, err := system.RunWithInput(ctx, input, "smbpasswd", "-a", "-s", user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smbpasswd -a for %s: %w (stdout=%q stderr=%q)", user.Username, err, stdout, stderr)
|
||||||
|
}
|
||||||
|
if err := database.ClearUserPendingPassword(user.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure the smb account is enabled.
|
||||||
|
_ = m.run(ctx, "smbpasswd", "-e", user.Username)
|
||||||
|
} else {
|
||||||
|
// Disable SMB access; ignore error when the account is not present.
|
||||||
|
_ = m.run(ctx, "smbpasswd", "-x", user.Username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) deleteUser(ctx context.Context, username string) error {
|
||||||
|
if err := system.ValidateUsername(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !m.cfg.Execute {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Remove SMB entry first (ignore if absent), then the system user.
|
||||||
|
_ = m.run(ctx, "smbpasswd", "-x", username)
|
||||||
|
if system.UserExists(ctx, username) {
|
||||||
|
if err := m.run(ctx, "userdel", username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) run(ctx context.Context, name string, args ...string) error {
|
||||||
|
stdout, stderr, err := system.Run(ctx, name, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s %s: %w (stdout=%q stderr=%q)", name, strings.Join(args, " "), err, stdout, stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeGroups(groups []string) []string {
|
||||||
|
out := make([]string, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
g = strings.TrimSpace(g)
|
||||||
|
if g != "" {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`)
|
||||||
|
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
|
nfsOptionPattern = regexp.MustCompile(`^[a-z_]+$`)
|
||||||
|
nfsClientPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:/\-\*]+$`)
|
||||||
|
allowedNFSOptions = map[string]bool{
|
||||||
|
"rw": true, "ro": true, "sync": true, "async": true,
|
||||||
|
"root_squash": true, "no_root_squash": true, "all_squash": true,
|
||||||
|
"no_all_squash": true, "subtree_check": true, "no_subtree_check": true,
|
||||||
|
"secure": true, "insecure": true, "wdelay": true, "no_wdelay": true,
|
||||||
|
"hide": true, "nohide": true, "crossmnt": true, "fsid": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes a command with explicit arguments (never via shell).
|
||||||
|
func Run(ctx context.Context, name string, args ...string) (stdout, stderr string, err error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
var outBuf, errBuf strings.Builder
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
err = cmd.Run()
|
||||||
|
return outBuf.String(), errBuf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithInput executes a command feeding stdin from the provided string.
|
||||||
|
// Used for secrets (e.g. passwords) so they never appear in the argument list.
|
||||||
|
func RunWithInput(ctx context.Context, stdin, name string, args ...string) (stdout, stderr string, err error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
|
var outBuf, errBuf strings.Builder
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
err = cmd.Run()
|
||||||
|
return outBuf.String(), errBuf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserExists reports whether a system user is present (via `id -u`).
|
||||||
|
func UserExists(ctx context.Context, username string) bool {
|
||||||
|
_, _, err := Run(ctx, "id", "-u", username)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUsername checks Linux username constraints.
|
||||||
|
func ValidateUsername(username string) error {
|
||||||
|
if username == "" || len(username) > 32 {
|
||||||
|
return fmt.Errorf("invalid username length")
|
||||||
|
}
|
||||||
|
if !usernamePattern.MatchString(username) {
|
||||||
|
return fmt.Errorf("invalid username: %q", username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePath ensures path is absolute and does not contain traversal.
|
||||||
|
func ValidatePath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("path is required")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return fmt.Errorf("path must be absolute: %q", path)
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
if strings.Contains(clean, "..") {
|
||||||
|
return fmt.Errorf("path must not contain .. segments: %q", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateShareName checks Samba share name constraints.
|
||||||
|
func ValidateShareName(name string) error {
|
||||||
|
if name == "" || len(name) > 80 {
|
||||||
|
return fmt.Errorf("invalid share name length")
|
||||||
|
}
|
||||||
|
if !shareNamePattern.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid share name: %q", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePathAllowed validates a path and, if allowedRoots is non-empty,
|
||||||
|
// ensures the path is contained within one of the configured roots.
|
||||||
|
func ValidatePathAllowed(path string, allowedRoots []string) error {
|
||||||
|
if err := ValidatePath(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(allowedRoots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
for _, root := range allowedRoots {
|
||||||
|
root = filepath.Clean(root)
|
||||||
|
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("path %q is not within an allowed directory", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateNFSClient checks an NFS client/network specifier.
|
||||||
|
func ValidateNFSClient(client string) error {
|
||||||
|
if client == "" || len(client) > 255 {
|
||||||
|
return fmt.Errorf("invalid nfs client length")
|
||||||
|
}
|
||||||
|
if !nfsClientPattern.MatchString(client) {
|
||||||
|
return fmt.Errorf("invalid nfs client: %q", client)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateNFSOptions checks a comma-separated list of export options against
|
||||||
|
// a whitelist, rejecting anything that could break the exports file.
|
||||||
|
func ValidateNFSOptions(options string) error {
|
||||||
|
for _, opt := range strings.Split(options, ",") {
|
||||||
|
opt = strings.TrimSpace(opt)
|
||||||
|
if opt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := opt
|
||||||
|
if idx := strings.IndexByte(opt, '='); idx >= 0 {
|
||||||
|
key = opt[:idx]
|
||||||
|
}
|
||||||
|
if !nfsOptionPattern.MatchString(key) || !allowedNFSOptions[key] {
|
||||||
|
return fmt.Errorf("invalid nfs option: %q", opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "nasctl_session"
|
||||||
|
sessionTTL = 12 * time.Hour
|
||||||
|
signingKeySetting = "session_signing_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
db *db.DB
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(database *db.DB) (*AuthService, error) {
|
||||||
|
secret, err := loadOrCreateSecret(database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &AuthService{db: database, secret: secret}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateSecret(database *db.DB) ([]byte, error) {
|
||||||
|
value, ok, err := database.GetSetting(signingKeySetting)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return hex.DecodeString(value)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate signing key: %w", err)
|
||||||
|
}
|
||||||
|
if err := database.SetSetting(signingKeySetting, hex.EncodeToString(buf)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAdmin creates an initial admin if none exists.
|
||||||
|
func (a *AuthService) EnsureAdmin(username, password string) (bool, error) {
|
||||||
|
count, err := a.db.CountAdmins()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.CreateAdmin(username, string(hash)); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) Authenticate(username, password string) bool {
|
||||||
|
admin, err := a.db.GetAdminByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
// Run a dummy hash comparison to reduce timing side-channels.
|
||||||
|
_ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) issueToken(username string) string {
|
||||||
|
expiry := time.Now().Add(sessionTTL).Unix()
|
||||||
|
payload := fmt.Sprintf("%s|%d", username, expiry)
|
||||||
|
sig := a.sign(payload)
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) sign(payload string) string {
|
||||||
|
mac := hmac.New(sha256.New, a.secret)
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) verifyToken(token string) (string, bool) {
|
||||||
|
parts := strings.SplitN(token, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
payload := string(payloadBytes)
|
||||||
|
expected := a.sign(payload)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(parts[1])) != 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
segs := strings.SplitN(payload, "|", 2)
|
||||||
|
if len(segs) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
expiry, err := strconv.ParseInt(segs[1], 10, 64)
|
||||||
|
if err != nil || time.Now().Unix() > expiry {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return segs[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) setSessionCookie(w http.ResponseWriter, username string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: a.issueToken(username),
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(sessionTTL),
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthService) currentUser(r *http.Request) (string, bool) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return a.verifyToken(cookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth wraps API handlers that must be authenticated.
|
||||||
|
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.Auth == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := s.Auth.currentUser(r); !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
DB *db.DB
|
||||||
|
Engine *engine.Engine
|
||||||
|
AllowedRoots []string
|
||||||
|
Auth *AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
AllowedRoots []string
|
||||||
|
Auth *AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
||||||
|
return &Server{
|
||||||
|
DB: database,
|
||||||
|
Engine: eng,
|
||||||
|
AllowedRoots: opts.AllowedRoots,
|
||||||
|
Auth: opts.Auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return NewRouter(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed dist/*
|
||||||
|
var Dist embed.FS
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleListDirty(w http.ResponseWriter, r *http.Request) {
|
||||||
|
modules, err := s.Engine.DirtyList(r.Context(), s.DB)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"modules": modules})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result, err := s.Engine.ApplyAll(r.Context(), s.DB)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"results": result.Results,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleApplyLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := s.DB.ListApplyLog(100)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.Auth == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "auth not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Auth.Authenticate(req.Username, req.Password) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Auth.setSessionCookie(w, req.Username)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"username": req.Username})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.Auth != nil {
|
||||||
|
s.Auth.clearSessionCookie(w)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.Auth == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "username": ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, ok := s.Auth.currentUser(r)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"authenticated": ok,
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type nfsExportRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Clients []string `json:"clients"`
|
||||||
|
Options string `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req nfsExportRequest) validate(allowedRoots []string) error {
|
||||||
|
if err := system.ValidatePathAllowed(req.Path, allowedRoots); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, client := range req.Clients {
|
||||||
|
if err := system.ValidateNFSClient(client); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Options) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return system.ValidateNFSOptions(req.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req nfsExportRequest) toModel() db.NFSExport {
|
||||||
|
options := strings.TrimSpace(req.Options)
|
||||||
|
if options == "" {
|
||||||
|
options = "rw,sync,no_root_squash"
|
||||||
|
}
|
||||||
|
return db.NFSExport{
|
||||||
|
Path: req.Path,
|
||||||
|
Clients: req.Clients,
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
|
||||||
|
exports, err := s.DB.ListNFSExports()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"exports": exports})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetNFSExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
export, err := s.DB.GetNFSExport(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, export)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := decodeNFSExportRequest(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.validate(s.AllowedRoots); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
export, err := s.DB.CreateNFSExport(req.toModel())
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, export)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := decodeNFSExportRequest(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.validate(s.AllowedRoots); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
export, err := s.DB.UpdateNFSExport(id, req.toModel())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(nfs.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, export)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.DeleteNFSExport(id); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(nfs.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeNFSExportRequest(body io.ReadCloser) (nfsExportRequest, error) {
|
||||||
|
defer body.Close()
|
||||||
|
var req nfsExportRequest
|
||||||
|
if err := json.NewDecoder(body).Decode(&req); err != nil {
|
||||||
|
return nfsExportRequest{}, err
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
type sambaShareRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
GuestOK bool `json:"guest_ok"`
|
||||||
|
ValidUsers []string `json:"valid_users"`
|
||||||
|
ValidGroups []string `json:"valid_groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req sambaShareRequest) validate(allowedRoots []string) error {
|
||||||
|
if err := system.ValidateShareName(req.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := system.ValidatePathAllowed(req.Path, allowedRoots); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range req.ValidUsers {
|
||||||
|
if err := system.ValidateUsername(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req sambaShareRequest) toModel() db.SambaShare {
|
||||||
|
return db.SambaShare{
|
||||||
|
Name: req.Name,
|
||||||
|
Path: req.Path,
|
||||||
|
Comment: req.Comment,
|
||||||
|
ReadOnly: req.ReadOnly,
|
||||||
|
GuestOK: req.GuestOK,
|
||||||
|
ValidUsers: req.ValidUsers,
|
||||||
|
ValidGroups: req.ValidGroups,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListSambaShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
shares, err := s.DB.ListSambaShares()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"shares": shares})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetSambaShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
share, err := s.DB.GetSambaShare(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, share)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateSambaShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := decodeSambaShareRequest(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.validate(s.AllowedRoots); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := s.DB.CreateSambaShare(req.toModel())
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, share)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateSambaShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := decodeSambaShareRequest(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.validate(s.AllowedRoots); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := s.DB.UpdateSambaShare(id, req.toModel())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(samba.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, share)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteSambaShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.DeleteSambaShare(id); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(samba.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSambaShareRequest(body io.ReadCloser) (sambaShareRequest, error) {
|
||||||
|
defer body.Close()
|
||||||
|
var req sambaShareRequest
|
||||||
|
if err := json.NewDecoder(body).Decode(&req); err != nil {
|
||||||
|
return sambaShareRequest{}, err
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseID(raw string) (int64, error) {
|
||||||
|
id, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type diskUsage struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
FreeBytes uint64 `json:"free_bytes"`
|
||||||
|
UsedBytes uint64 `json:"used_bytes"`
|
||||||
|
UsedPercent float64 `json:"used_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := map[string]any{
|
||||||
|
"disks": collectDiskUsage(),
|
||||||
|
"services": collectServiceStatus(r),
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDiskUsage() []diskUsage {
|
||||||
|
paths := []string{"/"}
|
||||||
|
var usages []diskUsage
|
||||||
|
for _, path := range paths {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total := stat.Blocks * uint64(stat.Bsize)
|
||||||
|
free := stat.Bavail * uint64(stat.Bsize)
|
||||||
|
used := total - free
|
||||||
|
var pct float64
|
||||||
|
if total > 0 {
|
||||||
|
pct = float64(used) / float64(total) * 100
|
||||||
|
}
|
||||||
|
usages = append(usages, diskUsage{
|
||||||
|
Path: path,
|
||||||
|
TotalBytes: total,
|
||||||
|
FreeBytes: free,
|
||||||
|
UsedBytes: used,
|
||||||
|
UsedPercent: pct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return usages
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectServiceStatus(r *http.Request) []serviceStatus {
|
||||||
|
services := []string{"smbd", "nfs-server"}
|
||||||
|
var statuses []serviceStatus
|
||||||
|
for _, name := range services {
|
||||||
|
stdout, _, err := system.Run(r.Context(), "systemctl", "is-active", name)
|
||||||
|
state := strings.TrimSpace(stdout)
|
||||||
|
if state == "" && err != nil {
|
||||||
|
state = "unknown"
|
||||||
|
}
|
||||||
|
statuses = append(statuses, serviceStatus{
|
||||||
|
Name: name,
|
||||||
|
Active: state == "active",
|
||||||
|
State: state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/users"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
SMBEnabled bool `json:"smb_enabled"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
// Password is write-only; it is never returned or logged.
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req userRequest) validate() error {
|
||||||
|
if err := system.ValidateUsername(req.Username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, group := range req.Groups {
|
||||||
|
if err := system.ValidateUsername(group); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req userRequest) toModel() db.User {
|
||||||
|
return db.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Groups: req.Groups,
|
||||||
|
SMBEnabled: req.SMBEnabled,
|
||||||
|
Disabled: req.Disabled,
|
||||||
|
PendingPassword: req.Password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := s.DB.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.DB.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, err := decodeUserRequest(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.validate(); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.DB.CreateUser(req.toModel())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := decodeUserRequest(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := req.validate(); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.DB.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model := req.toModel()
|
||||||
|
// Preserve a previously stored pending password if none is supplied now.
|
||||||
|
if model.PendingPassword == "" {
|
||||||
|
model.PendingPassword = existing.PendingPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.DB.UpdateUser(id, model)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.DeleteUser(id); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeUserRequest(body io.ReadCloser) (userRequest, error) {
|
||||||
|
defer body.Close()
|
||||||
|
var req userRequest
|
||||||
|
if err := json.NewDecoder(body).Decode(&req); err != nil {
|
||||||
|
return userRequest{}, err
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRouter(s *Server) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
// Public auth endpoints.
|
||||||
|
api.Post("/auth/login", s.handleLogin)
|
||||||
|
api.Post("/auth/logout", s.handleLogout)
|
||||||
|
api.Get("/auth/status", s.handleAuthStatus)
|
||||||
|
|
||||||
|
// Everything below requires authentication.
|
||||||
|
api.Group(func(protected chi.Router) {
|
||||||
|
protected.Use(s.RequireAuth)
|
||||||
|
|
||||||
|
protected.Get("/dirty", s.handleListDirty)
|
||||||
|
protected.Post("/apply", s.handleApply)
|
||||||
|
protected.Get("/apply/log", s.handleApplyLog)
|
||||||
|
protected.Get("/system/status", s.handleSystemStatus)
|
||||||
|
|
||||||
|
protected.Route("/samba/shares", func(shares chi.Router) {
|
||||||
|
shares.Get("/", s.handleListSambaShares)
|
||||||
|
shares.Post("/", s.handleCreateSambaShare)
|
||||||
|
shares.Route("/{id}", func(item chi.Router) {
|
||||||
|
item.Get("/", s.handleGetSambaShare)
|
||||||
|
item.Put("/", s.handleUpdateSambaShare)
|
||||||
|
item.Delete("/", s.handleDeleteSambaShare)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
protected.Route("/nfs/exports", func(exports chi.Router) {
|
||||||
|
exports.Get("/", s.handleListNFSExports)
|
||||||
|
exports.Post("/", s.handleCreateNFSExport)
|
||||||
|
exports.Route("/{id}", func(item chi.Router) {
|
||||||
|
item.Get("/", s.handleGetNFSExport)
|
||||||
|
item.Put("/", s.handleUpdateNFSExport)
|
||||||
|
item.Delete("/", s.handleDeleteNFSExport)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
protected.Route("/users", func(users chi.Router) {
|
||||||
|
users.Get("/", s.handleListUsers)
|
||||||
|
users.Post("/", s.handleCreateUser)
|
||||||
|
users.Route("/{id}", func(item chi.Router) {
|
||||||
|
item.Get("/", s.handleGetUser)
|
||||||
|
item.Put("/", s.handleUpdateUser)
|
||||||
|
item.Delete("/", s.handleDeleteUser)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.NotFound(s.handleStatic)
|
||||||
|
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Dist.ReadFile("dist/" + path)
|
||||||
|
if err != nil {
|
||||||
|
if path != "index.html" {
|
||||||
|
data, err = Dist.ReadFile("dist/index.html")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, ".html") {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
} else if strings.HasSuffix(path, ".js") {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
} else if strings.HasSuffix(path, ".css") {
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
install -d /var/lib/nasctl
|
||||||
|
install -m 0755 /usr/local/bin/nasctl /usr/local/bin/nasctl
|
||||||
|
install -m 0644 /etc/systemd/system/nasctl.service /etc/systemd/system/nasctl.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nasctl.service
|
||||||
|
|
||||||
|
echo "nasctl installed. Start with: systemctl start nasctl"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=nasctl NAS control plane
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/nasctl --addr :8080 --db /var/lib/nasctl/nasctl.db
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>nasctl</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2538
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "nasctl-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { DirtyProvider } from "./DirtyContext";
|
||||||
|
import Layout from "./components/Layout";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import Users from "./pages/Users";
|
||||||
|
import Samba from "./pages/Samba";
|
||||||
|
import Nfs from "./pages/Nfs";
|
||||||
|
import Log from "./pages/Log";
|
||||||
|
|
||||||
|
type AuthState = { loading: boolean; authenticated: boolean; username: string };
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [auth, setAuth] = useState<AuthState>({ loading: true, authenticated: false, username: "" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.status()
|
||||||
|
.then((res) => setAuth({ loading: false, authenticated: res.authenticated, username: res.username }))
|
||||||
|
.catch(() => setAuth({ loading: false, authenticated: false, username: "" }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (auth.loading) {
|
||||||
|
return <div className="flex min-h-screen items-center justify-center text-slate-400">Cargando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.authenticated) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={<Login onLoggedIn={(username) => setAuth({ loading: false, authenticated: true, username })} />}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DirtyProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<Layout
|
||||||
|
username={auth.username}
|
||||||
|
onLogout={() => setAuth({ loading: false, authenticated: false, username: "" })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/users" element={<Users />} />
|
||||||
|
<Route path="/samba" element={<Samba />} />
|
||||||
|
<Route path="/nfs" element={<Nfs />} />
|
||||||
|
<Route path="/log" element={<Log />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</DirtyProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { createContext, useContext, useCallback, useEffect, useState, ReactNode } from "react";
|
||||||
|
import { api, DirtyModule } from "./api";
|
||||||
|
|
||||||
|
interface DirtyContextValue {
|
||||||
|
modules: DirtyModule[];
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
applying: boolean;
|
||||||
|
apply: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirtyContext = createContext<DirtyContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function DirtyProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [modules, setModules] = useState<DirtyModule[]>([]);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.dirty();
|
||||||
|
setModules(res.modules ?? []);
|
||||||
|
} catch {
|
||||||
|
// ignore transient errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const apply = useCallback(async () => {
|
||||||
|
setApplying(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.apply();
|
||||||
|
const failed = res.results.filter((r) => r.error);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
setError(failed.map((f) => `${f.module}: ${f.error}`).join("; "));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "apply failed");
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DirtyContext.Provider value={{ modules, refresh, applying, apply, error }}>
|
||||||
|
{children}
|
||||||
|
</DirtyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDirty() {
|
||||||
|
const ctx = useContext(DirtyContext);
|
||||||
|
if (!ctx) throw new Error("useDirty must be used within DirtyProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
export interface SambaShare {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
comment: string;
|
||||||
|
read_only: boolean;
|
||||||
|
guest_ok: boolean;
|
||||||
|
valid_users: string[];
|
||||||
|
valid_groups: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NFSExport {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
clients: string[];
|
||||||
|
options: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
groups: string[];
|
||||||
|
smb_enabled: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirtyModule {
|
||||||
|
module: string;
|
||||||
|
marked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyLogEntry {
|
||||||
|
id: number;
|
||||||
|
module: string;
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiskUsage {
|
||||||
|
path: string;
|
||||||
|
total_bytes: number;
|
||||||
|
free_bytes: number;
|
||||||
|
used_bytes: number;
|
||||||
|
used_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatus {
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
disks: DiskUsage[];
|
||||||
|
services: ServiceStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(res.status, data.error || res.statusText);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// auth
|
||||||
|
status: () => request<{ authenticated: boolean; username: string }>("GET", "/auth/status"),
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
request<{ username: string }>("POST", "/auth/login", { username, password }),
|
||||||
|
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),
|
||||||
|
|
||||||
|
// dirty / apply
|
||||||
|
dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"),
|
||||||
|
apply: () => request<{ results: { module: string; applied: boolean; error?: string }[] }>("POST", "/apply"),
|
||||||
|
applyLog: () => request<{ entries: ApplyLogEntry[] }>("GET", "/apply/log"),
|
||||||
|
systemStatus: () => request<SystemStatus>("GET", "/system/status"),
|
||||||
|
|
||||||
|
// samba
|
||||||
|
listShares: () => request<{ shares: SambaShare[] | null }>("GET", "/samba/shares/"),
|
||||||
|
createShare: (s: Partial<SambaShare>) => request<SambaShare>("POST", "/samba/shares/", s),
|
||||||
|
updateShare: (id: number, s: Partial<SambaShare>) => request<SambaShare>("PUT", `/samba/shares/${id}/`, s),
|
||||||
|
deleteShare: (id: number) => request<void>("DELETE", `/samba/shares/${id}/`),
|
||||||
|
|
||||||
|
// nfs
|
||||||
|
listExports: () => request<{ exports: NFSExport[] | null }>("GET", "/nfs/exports/"),
|
||||||
|
createExport: (e: Partial<NFSExport>) => request<NFSExport>("POST", "/nfs/exports/", e),
|
||||||
|
updateExport: (id: number, e: Partial<NFSExport>) => request<NFSExport>("PUT", `/nfs/exports/${id}/`, e),
|
||||||
|
deleteExport: (id: number) => request<void>("DELETE", `/nfs/exports/${id}/`),
|
||||||
|
|
||||||
|
// users
|
||||||
|
listUsers: () => request<{ users: User[] | null }>("GET", "/users/"),
|
||||||
|
createUser: (u: Partial<User> & { password?: string }) => request<User>("POST", "/users/", u),
|
||||||
|
updateUser: (id: number, u: Partial<User> & { password?: string }) => request<User>("PUT", `/users/${id}/`, u),
|
||||||
|
deleteUser: (id: number) => request<void>("DELETE", `/users/${id}/`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useDirty } from "../DirtyContext";
|
||||||
|
|
||||||
|
export default function DirtyBanner() {
|
||||||
|
const { modules, apply, applying, error } = useDirty();
|
||||||
|
if (modules.length === 0 && !error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-10">
|
||||||
|
{modules.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-amber-700/50 bg-amber-500/15 px-6 py-3 text-amber-200">
|
||||||
|
<span className="text-sm">
|
||||||
|
Tienes cambios sin aplicar:{" "}
|
||||||
|
<strong className="font-semibold">
|
||||||
|
{modules.map((m) => m.module).join(", ")}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<button className="btn-primary" onClick={apply} disabled={applying}>
|
||||||
|
{applying ? "Aplicando..." : "Aplicar cambios"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="border-b border-red-700/50 bg-red-500/15 px-6 py-3 text-sm text-red-200">
|
||||||
|
Error al aplicar: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import DirtyBanner from "./DirtyBanner";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "Dashboard", end: true },
|
||||||
|
{ to: "/users", label: "Usuarios" },
|
||||||
|
{ to: "/samba", label: "SMB / Samba" },
|
||||||
|
{ to: "/nfs", label: "NFS" },
|
||||||
|
{ to: "/log", label: "Historial" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await api.logout();
|
||||||
|
onLogout();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<aside className="flex w-60 flex-col border-r border-slate-800 bg-slate-900/80">
|
||||||
|
<div className="px-6 py-5 text-xl font-bold tracking-tight text-white">
|
||||||
|
nas<span className="text-brand-500">ctl</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-1 px-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive ? "bg-brand-600 text-white" : "text-slate-300 hover:bg-slate-800"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="border-t border-slate-800 px-4 py-4 text-sm text-slate-400">
|
||||||
|
<div className="mb-2 truncate">Sesión: <span className="text-slate-200">{username}</span></div>
|
||||||
|
<button className="btn-ghost w-full" onClick={handleLogout}>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1">
|
||||||
|
<DirtyBanner />
|
||||||
|
<div className="p-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-lg border border-slate-700 bg-slate-900 p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
<button className="text-slate-400 hover:text-slate-200" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-slate-950 text-slate-100 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-brand-600 text-white hover:bg-brand-700;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn bg-slate-800 text-slate-200 hover:bg-slate-700;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply btn bg-red-600 text-white hover:bg-red-700;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply mb-1 block text-sm font-medium text-slate-300;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-lg border border-slate-800 bg-slate-900/60 p-5 shadow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, formatBytes, SystemStatus } from "../api";
|
||||||
|
import { useDirty } from "../DirtyContext";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
|
const { modules } = useDirty();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.systemStatus().then(setStatus).catch(() => setStatus(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Uso de disco
|
||||||
|
</h2>
|
||||||
|
{status?.disks?.length ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{status.disks.map((d) => (
|
||||||
|
<div key={d.path}>
|
||||||
|
<div className="mb-1 flex justify-between text-sm">
|
||||||
|
<span className="text-slate-300">{d.path}</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded bg-slate-800">
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand-500"
|
||||||
|
style={{ width: `${Math.min(d.used_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Sin datos de disco.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Servicios
|
||||||
|
</h2>
|
||||||
|
{status?.services?.length ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{status.services.map((s) => (
|
||||||
|
<li key={s.name} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-slate-300">{s.name}</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||||
|
s.active ? "bg-emerald-500/20 text-emerald-300" : "bg-slate-700 text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.state}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Sin datos de servicios.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Cambios pendientes
|
||||||
|
</h2>
|
||||||
|
{modules.length > 0 ? (
|
||||||
|
<p className="text-sm text-amber-300">
|
||||||
|
Módulos con cambios sin aplicar: {modules.map((m) => m.module).join(", ")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-emerald-300">Todo aplicado. No hay cambios pendientes.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, ApplyLogEntry } from "../api";
|
||||||
|
|
||||||
|
export default function Log() {
|
||||||
|
const [entries, setEntries] = useState<ApplyLogEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.applyLog().then((res) => setEntries(res.entries ?? [])).catch(() => setEntries([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Historial de aplicaciones</h1>
|
||||||
|
<div className="card overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-slate-800 text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Fecha</th>
|
||||||
|
<th className="px-4 py-3">Módulo</th>
|
||||||
|
<th className="px-4 py-3">Resultado</th>
|
||||||
|
<th className="px-4 py-3">Mensaje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b border-slate-800/60">
|
||||||
|
<td className="px-4 py-3 text-slate-400">{new Date(e.created_at).toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-100">{e.module}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{e.success ? (
|
||||||
|
<span className="text-emerald-300">OK</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-300">Error</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{e.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
Sin registros todavía.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) => void }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.login(username, password);
|
||||||
|
onLoggedIn(res.username);
|
||||||
|
} catch {
|
||||||
|
setError("Credenciales inválidas");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<form onSubmit={handleSubmit} className="card w-full max-w-sm">
|
||||||
|
<div className="mb-6 text-center text-2xl font-bold text-white">
|
||||||
|
nas<span className="text-brand-500">ctl</span>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="label">Usuario</label>
|
||||||
|
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="label">Contraseña</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary w-full" disabled={loading}>
|
||||||
|
{loading ? "Entrando..." : "Iniciar sesión"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { api, NFSExport } from "../api";
|
||||||
|
import { useDirty } from "../DirtyContext";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
|
||||||
|
const empty: Partial<NFSExport> = {
|
||||||
|
path: "",
|
||||||
|
clients: [],
|
||||||
|
options: "rw,sync,no_root_squash",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Nfs() {
|
||||||
|
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||||
|
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { refresh } = useDirty();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await api.listExports();
|
||||||
|
setExports(res.exports ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (editing.id) {
|
||||||
|
await api.updateExport(editing.id, editing);
|
||||||
|
} else {
|
||||||
|
await api.createExport(editing);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al guardar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm("¿Eliminar este export?")) return;
|
||||||
|
await api.deleteExport(id);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Exports NFS</h1>
|
||||||
|
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||||
|
Nuevo export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-slate-800 text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Path</th>
|
||||||
|
<th className="px-4 py-3">Clientes</th>
|
||||||
|
<th className="px-4 py-3">Opciones</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{exports.map((x) => (
|
||||||
|
<tr key={x.id} className="border-b border-slate-800/60">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{x.clients.join(", ") || "*"}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{x.options}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...x })}>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(x.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{exports.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
No hay exports configurados.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => setEditing(null)}>
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="label">Path (absoluto)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.path ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Clientes / redes (separados por coma)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="192.168.1.0/24, 10.0.0.5"
|
||||||
|
value={(editing.clients ?? []).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
clients: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Opciones</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.options ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, options: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Ej: rw,sync,no_root_squash · ro,async,root_squash
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { api, SambaShare } from "../api";
|
||||||
|
import { useDirty } from "../DirtyContext";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
|
||||||
|
const empty: Partial<SambaShare> = {
|
||||||
|
name: "",
|
||||||
|
path: "",
|
||||||
|
comment: "",
|
||||||
|
read_only: false,
|
||||||
|
guest_ok: false,
|
||||||
|
valid_users: [],
|
||||||
|
valid_groups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Samba() {
|
||||||
|
const [shares, setShares] = useState<SambaShare[]>([]);
|
||||||
|
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { refresh } = useDirty();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await api.listShares();
|
||||||
|
setShares(res.shares ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (editing.id) {
|
||||||
|
await api.updateShare(editing.id, editing);
|
||||||
|
} else {
|
||||||
|
await api.createShare(editing);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al guardar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm("¿Eliminar este share?")) return;
|
||||||
|
await api.deleteShare(id);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1>
|
||||||
|
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||||
|
Nuevo share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-slate-800 text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Nombre</th>
|
||||||
|
<th className="px-4 py-3">Path</th>
|
||||||
|
<th className="px-4 py-3">Modo</th>
|
||||||
|
<th className="px-4 py-3">Invitado</th>
|
||||||
|
<th className="px-4 py-3">Usuarios</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shares.map((s) => (
|
||||||
|
<tr key={s.id} className="border-b border-slate-800/60">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-100">{s.name}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{s.path}</td>
|
||||||
|
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td>
|
||||||
|
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{s.valid_users.join(", ") || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...s })}>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(s.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{shares.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
No hay shares configurados.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<Modal title={editing.id ? "Editar share" : "Nuevo share"} onClose={() => setEditing(null)}>
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="label">Nombre</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.name ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Path (absoluto)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.path ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Comentario</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.comment ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Usuarios válidos (separados por coma)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(editing.valid_users ?? []).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
valid_users: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.read_only ?? false}
|
||||||
|
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Solo lectura
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.guest_ok ?? false}
|
||||||
|
onChange={(e) => setEditing({ ...editing, guest_ok: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Permitir invitado
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { api, User } from "../api";
|
||||||
|
import { useDirty } from "../DirtyContext";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
|
||||||
|
type EditUser = Partial<User> & { password?: string };
|
||||||
|
|
||||||
|
const empty: EditUser = {
|
||||||
|
username: "",
|
||||||
|
groups: [],
|
||||||
|
smb_enabled: false,
|
||||||
|
disabled: false,
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [editing, setEditing] = useState<EditUser | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { refresh } = useDirty();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await api.listUsers();
|
||||||
|
setUsers(res.users ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (editing.id) {
|
||||||
|
await api.updateUser(editing.id, editing);
|
||||||
|
} else {
|
||||||
|
await api.createUser(editing);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al guardar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm("¿Eliminar este usuario? Se ejecutará userdel al aplicar.")) return;
|
||||||
|
await api.deleteUser(id);
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Usuarios del sistema</h1>
|
||||||
|
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||||
|
Nuevo usuario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-slate-800 text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Usuario</th>
|
||||||
|
<th className="px-4 py-3">Grupos</th>
|
||||||
|
<th className="px-4 py-3">SMB</th>
|
||||||
|
<th className="px-4 py-3">Estado</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-slate-800/60">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-100">{u.username}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300">{u.groups.join(", ") || "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{u.smb_enabled ? (
|
||||||
|
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300">
|
||||||
|
Habilitado
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-300">
|
||||||
|
Deshabilitado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{u.disabled ? "Bloqueado" : "Activo"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...u, password: "" })}>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button className="btn-danger" onClick={() => remove(u.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
No hay usuarios configurados.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<Modal title={editing.id ? "Editar usuario" : "Nuevo usuario"} onClose={() => setEditing(null)}>
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="label">Nombre de usuario</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.username ?? ""}
|
||||||
|
disabled={!!editing.id}
|
||||||
|
onChange={(e) => setEditing({ ...editing, username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Grupos (separados por coma)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(editing.groups ?? []).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
groups: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
Contraseña {editing.id && <span className="text-slate-500">(dejar vacío para no cambiar)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={editing.password ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.smb_enabled ?? false}
|
||||||
|
onChange={(e) => setEditing({ ...editing, smb_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Acceso SMB
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.disabled ?? false}
|
||||||
|
onChange={(e) => setEditing({ ...editing, disabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Cuenta bloqueada
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
500: "#3b82f6",
|
||||||
|
600: "#2563eb",
|
||||||
|
700: "#1d4ed8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "./.tsbuild"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/dirtycontext.tsx","./src/api.ts","./src/main.tsx","./src/components/dirtybanner.tsx","./src/components/layout.tsx","./src/components/modal.tsx","./src/pages/dashboard.tsx","./src/pages/log.tsx","./src/pages/login.tsx","./src/pages/nfs.tsx","./src/pages/samba.tsx","./src/pages/users.tsx"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Build output goes into internal/web/dist so it is embedded into the Go binary.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: "../internal/web/dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user