Add nasctl: Go NAS control plane with React frontend

This commit is contained in:
2026-07-05 17:37:19 -04:00
parent 359fd5a160
commit 4f0754ecc5
56 changed files with 6725 additions and 1 deletions
+11
View File
@@ -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/
+53
View File
@@ -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).
+46
View File
@@ -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 ./...
+96 -1
View File
@@ -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
+101
View File
@@ -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
}
+22
View File
@@ -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
)
+51
View File
@@ -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=
+62
View File
@@ -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
}
+65
View File
@@ -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'))
);
+60
View File
@@ -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"`
}
+286
View File
@@ -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
}
+124
View File
@@ -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
}
+263
View File
@@ -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
}
+73
View File
@@ -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
}
+14
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
# Generated by nasctl. Do not edit manually.
{{range .Exports}}
{{.Path}} {{.ClientSpec}}
{{- end}}
+168
View File
@@ -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
}
+158
View File
@@ -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
}
+24
View File
@@ -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}}
+185
View File
@@ -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
}
+138
View File
@@ -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
}
+195
View File
@@ -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)
}
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist/*
var Dist embed.FS
+35
View File
@@ -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})
}
+49
View File
@@ -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,
})
}
+148
View File
@@ -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
}
+169
View File
@@ -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
}
+75
View File
@@ -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
}
+156
View File
@@ -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
}
+105
View File
@@ -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)
}
+10
View File
@@ -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"
+12
View File
@@ -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
+12
View File
@@ -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>
+2538
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+62
View File
@@ -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>
);
}
+60
View File
@@ -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
View File
@@ -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]}`;
}
+29
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+33
View File
@@ -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;
}
}
+13
View File
@@ -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>
);
+85
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}
+17
View File
@@ -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: [],
};
+21
View File
@@ -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" }]
}
+13
View File
@@ -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
+1
View File
@@ -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"}
+16
View File
@@ -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",
},
},
});