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