diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..c953e1a --- /dev/null +++ b/auth.go @@ -0,0 +1,256 @@ +// NextNVR — MIT License +// Copyright (c) 2026 NextNVR Contributors +// SPDX-License-Identifier: MIT +// +// auth.go — Session-based authentication with bcrypt password hashing. +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// SessionStore holds authenticated sessions in memory. +type SessionStore struct { + mu sync.RWMutex + sessions map[string]*Session +} + +// Session represents an authenticated user session. +type Session struct { + Username string + Role string // "master" or "viewer" + Expires time.Time +} + +// NewSessionStore creates a new in-memory session store. +func NewSessionStore() *SessionStore { + s := &SessionStore{sessions: make(map[string]*Session)} + go s.cleanupLoop() + return s +} + +// Create generates a new session token and stores it. +func (s *SessionStore) Create(username, role string) string { + s.mu.Lock() + defer s.mu.Unlock() + token := generateToken() + s.sessions[token] = &Session{ + Username: username, + Role: role, + Expires: time.Now().Add(24 * time.Hour), + } + return token +} + +// Get returns a session by token, or nil if expired/missing. +func (s *SessionStore) Get(token string) *Session { + s.mu.RLock() + defer s.mu.RUnlock() + sess, ok := s.sessions[token] + if !ok || time.Now().After(sess.Expires) { + return nil + } + return sess +} + +// Delete removes a session (logout). +func (s *SessionStore) Delete(token string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, token) +} + +// cleanupLoop removes expired sessions every hour. +func (s *SessionStore) cleanupLoop() { + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + s.mu.Lock() + now := time.Now() + for token, sess := range s.sessions { + if now.After(sess.Expires) { + delete(s.sessions, token) + } + } + s.mu.Unlock() + } +} + +func generateToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// ── Handlers ── + +// handleLogin processes POST /api/login. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"}) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid request"}) + return + } + + auth := s.appConfig.Auth + + // Check master credentials. + if auth.Master.Username != "" && req.Username == auth.Master.Username { + if err := bcrypt.CompareHashAndPassword([]byte(auth.Master.Password), []byte(req.Password)); err == nil { + token := s.sessions.Create(req.Username, "master") + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": "master"}}) + return + } + } + + // Check viewer credentials. + if auth.Viewer.Enabled && auth.Viewer.Username != "" && req.Username == auth.Viewer.Username { + if err := bcrypt.CompareHashAndPassword([]byte(auth.Viewer.Password), []byte(req.Password)); err == nil { + token := s.sessions.Create(req.Username, "viewer") + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": "viewer"}}) + return + } + } + + jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "invalid credentials"}) +} + +// handleLogout processes POST /api/logout. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("session"); err == nil { + s.sessions.Delete(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "logged out"}) +} + +// handleSession returns the current session info. +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": ""}}) + return + } + sess := s.sessions.Get(cookie.Value) + if sess == nil { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": ""}}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": sess.Role, "username": sess.Username}}) +} + +// ── Middleware ── + +// authRequired redirects to /login if no valid session. +func (s *Server) authRequired(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow login page and API, static assets, and recordings. + path := r.URL.Path + if path == "/login" || path == "/login.html" || + strings.HasPrefix(path, "/api/login") || + strings.HasPrefix(path, "/api/logout") || + strings.HasPrefix(path, "/api/session") || + strings.HasPrefix(path, "/recordings/") || + strings.HasPrefix(path, "/go2rtc/") || + strings.HasPrefix(path, "/stream/") { + next.ServeHTTP(w, r) + return + } + + // If auth is disabled, allow all. + if !s.appConfig.Auth.Enabled { + next.ServeHTTP(w, r) + return + } + + cookie, err := r.Cookie("session") + if err != nil { + // Redirect API calls with 401, page requests to /login. + if strings.HasPrefix(path, "/api/") { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "authentication required"}) + } else { + http.Redirect(w, r, "/login", http.StatusFound) + } + return + } + + sess := s.sessions.Get(cookie.Value) + if sess == nil { + if strings.HasPrefix(path, "/api/") { + jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "session expired"}) + } else { + http.Redirect(w, r, "/login", http.StatusFound) + } + return + } + + // Inject role into request context. + r.Header.Set("X-NextNVR-Role", sess.Role) + next.ServeHTTP(w, r) + }) +} + +// viewerOnly restricts access to live wall only (port 8090). +func (s *Server) viewerOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + // Allow live wall assets and recordings. + if path == "/" || path == "/index.html" || + strings.HasPrefix(path, "/app.js") || + strings.HasPrefix(path, "/style.css") || + strings.HasPrefix(path, "/recordings/") || + strings.HasPrefix(path, "/go2rtc/") || + strings.HasPrefix(path, "/stream/") || + strings.HasPrefix(path, "/api/cameras") || + strings.HasPrefix(path, "/api/status") { + r.Header.Set("X-NextNVR-Role", "viewer") + next.ServeHTTP(w, r) + return + } + // Block everything else (settings, playback API, config). + jsonResponse(w, http.StatusForbidden, APIResponse{Error: "viewer access only"}) + }) +} + +// HashPassword returns a bcrypt hash of the password. +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} diff --git a/config.go b/config.go index c1fca4c..fad71ce 100644 --- a/config.go +++ b/config.go @@ -17,14 +17,16 @@ import ( type Config struct { Server ServerConfig `yaml:"server" json:"server"` Storage StorageConfig `yaml:"storage" json:"storage"` + Auth AuthConfig `yaml:"auth" json:"auth"` Cameras []CameraConfig `yaml:"cameras" json:"cameras"` Go2RTC Go2RTCConfig `yaml:"go2rtc" json:"go2rtc"` } // ServerConfig holds HTTP server settings. type ServerConfig struct { - Port string `yaml:"port" json:"port"` - BindHost string `yaml:"bind_host" json:"bind_host"` + Port string `yaml:"port" json:"port"` + BindHost string `yaml:"bind_host" json:"bind_host"` + ViewerPort string `yaml:"viewer_port" json:"viewer_port"` } // StorageConfig holds recording and retention settings. @@ -56,6 +58,20 @@ type Go2RTCConfig struct { Binary string `yaml:"binary" json:"binary"` } +// AuthConfig holds authentication settings. +type AuthConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Master UserConfig `yaml:"master" json:"master"` + Viewer UserConfig `yaml:"viewer" json:"viewer"` +} + +// UserConfig holds a single user's credentials. +type UserConfig struct { + Username string `yaml:"username" json:"username"` + Password string `yaml:"password" json:"password"` // bcrypt hash + Enabled bool `yaml:"enabled" json:"enabled"` +} + // APIResponse wraps all JSON API responses. type APIResponse struct { Success bool `json:"success"` @@ -75,8 +91,9 @@ type CameraStatus struct { func DefaultConfig() Config { return Config{ Server: ServerConfig{ - Port: ":8080", - BindHost: "0.0.0.0", + Port: ":8080", + BindHost: "0.0.0.0", + ViewerPort: ":8090", }, Storage: StorageConfig{ RecordingsPath: "/mnt/recordings", diff --git a/config.yaml b/config.yaml index 914d984..d4119f2 100644 --- a/config.yaml +++ b/config.yaml @@ -4,12 +4,23 @@ server: port: ":8080" bind_host: "0.0.0.0" + viewer_port: ":8090" storage: recordings_path: "/mnt/recordings" retention_days: 7 cleanup_interval_mins: 60 +auth: + enabled: false + master: + username: "" + password: "" + viewer: + enabled: true + username: "" + password: "" + go2rtc: enabled: false port: ":1984" diff --git a/go.mod b/go.mod index 60ace43..fb895b4 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,8 @@ module github.com/cclohmar/NextNVR -go 1.24.5 +go 1.25.0 -require gopkg.in/yaml.v3 v3.0.1 +require ( + golang.org/x/crypto v0.54.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum index a62c313..576e662 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go2rtc.yaml b/go2rtc.yaml new file mode 100644 index 0000000..d7ca2a8 --- /dev/null +++ b/go2rtc.yaml @@ -0,0 +1,21 @@ +# go2rtc configuration — generated by NextNVR +api: + listen: ":1984" + +streams: + cam_201_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.201:554/Streaming/Channels/102 + cam_201_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.201:554/Streaming/Channels/101 + cam_202_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.202:554/Streaming/Channels/102 + cam_202_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.202:554/Streaming/Channels/101 + cam_203_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.203:554/Streaming/Channels/102 + cam_203_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.203:554/Streaming/Channels/101 + cam_205_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.205:554/Streaming/Channels/102 + cam_205_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.205:554/Streaming/Channels/101 + cam_206_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.206:554/Streaming/Channels/102 + cam_206_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.206:554/Streaming/Channels/101 + cam_207_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.207:554/Streaming/Channels/102 + cam_207_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.207:554/Streaming/Channels/101 + cam_208_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.208:554/Streaming/Channels/102 + cam_208_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.208:554/Streaming/Channels/101 + cam_209_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.209:554/Streaming/Channels/102 + cam_209_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.209:554/Streaming/Channels/101 diff --git a/main.go b/main.go index 6acf9a2..4a5d514 100644 --- a/main.go +++ b/main.go @@ -75,12 +75,22 @@ func main() { go cln.Start() app.cleaner = cln - // Start the HTTP server (blocks). + // Start the HTTP server (main port, with auth). + app.server, _ = NewServer(cfg) errCh := make(chan error, 1) go func() { - errCh <- app.StartServer() + errCh <- app.server.ListenAndServe() }() + // Start viewer server (no auth, live wall only) — after main server is created. + if cfg.Server.ViewerPort != "" { + go func() { + if err := app.server.StartViewerServer(); err != nil { + log.Printf("Viewer server: %v", err) + } + }() + } + select { case sig := <-sigCh: log.Printf("Received signal: %v — shutting down", sig) @@ -137,6 +147,14 @@ func (a *App) StartServer() error { return srv.ListenAndServe() } +// StartViewerServer launches the viewer-only server. +func (a *App) StartViewerServer() error { + if a.server == nil { + return fmt.Errorf("main server not initialized") + } + return a.server.StartViewerServer() +} + // Shutdown performs a graceful shutdown of all services. func (a *App) Shutdown() { if a.recorder != nil { diff --git a/public/app.js b/public/app.js index e24bd54..acd2712 100644 --- a/public/app.js +++ b/public/app.js @@ -16,12 +16,26 @@ document.addEventListener('DOMContentLoaded', async () => { renderPlaybackCameras(); renderCameraCards(); + // Read role from meta tag (set by server). + const role = document.querySelector('meta[name="nextnvr-role"]')?.content || ''; + applyRole(role); + // First-run experience: auto-switch to Settings if no cameras configured. if (cameras.length === 0) { switchTab('settings'); } }); +function applyRole(role) { + if (role === 'viewer') { + // Hide playback and settings tabs. + document.querySelectorAll('.tab[data-tab="playback"], .tab[data-tab="settings"]').forEach(t => t.style.display = 'none'); + // Hide save button if present. + const saveBtn = document.getElementById('btn-save'); + if (saveBtn) saveBtn.style.display = 'none'; + } +} + // ── Tabs ── function setupTabs() { document.querySelectorAll('.tab').forEach(btn => { diff --git a/public/index.html b/public/index.html index 9a7f0fc..a7a9f89 100644 --- a/public/index.html +++ b/public/index.html @@ -3,6 +3,7 @@
+