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