From b854421db0136eadc17446f980b5892f1caead68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Fri, 13 Feb 2026 17:08:36 -0700
Subject: [PATCH 1/8] feat(auth): implement session management and login
throttling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
internal/auth/auth.go | 217 ++++++++++++++++++++
internal/config/config.go | 14 +-
internal/daemon/{program.go => daemon.go} | 6 +
internal/server/models.go | 14 +-
internal/server/ratelimit.go | 47 +++++
internal/server/server.go | 233 +++++++++++++++++-----
6 files changed, 474 insertions(+), 57 deletions(-)
create mode 100644 internal/auth/auth.go
rename internal/daemon/{program.go => daemon.go} (95%)
create mode 100644 internal/server/ratelimit.go
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..466c467
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,217 @@
+// Package auth provides session management, password validation, and brute-force protection.
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/adcondev/scale-daemon/internal/config"
+)
+
+const (
+ // SessionCookieName is the name of the HTTP cookie used for session tokens.
+ SessionCookieName = "sd_session"
+ // SessionDuration is how long a session token is valid for.
+ SessionDuration = 15 * time.Minute
+ // MaxLoginAttempts is the number of failed login attempts before an IP is locked out.
+ MaxLoginAttempts = 5
+ // LockoutDuration is how long an IP is locked out after MaxLoginAttempts.
+ LockoutDuration = 5 * time.Minute
+ // CleanupInterval is how often the cleanup goroutine runs to remove expired sessions and lockouts.
+ CleanupInterval = 5 * time.Minute
+)
+
+type failInfo struct {
+ count int
+ lockedUntil time.Time
+}
+
+// Manager handles session lifecycle, password validation, and login throttling.
+// It is safe for concurrent use.
+type Manager struct {
+ sessions map[string]time.Time
+ failedLogins map[string]failInfo
+ mu sync.RWMutex
+}
+
+// NewManager creates an auth manager. The cleanup goroutine is bound to ctx
+// and will exit cleanly when the context is canceled during service shutdown.
+func NewManager(ctx context.Context) *Manager {
+ m := &Manager{
+ sessions: make(map[string]time.Time),
+ failedLogins: make(map[string]failInfo),
+ }
+ go m.cleanupLoop(ctx)
+ return m
+}
+
+// Enabled returns true if a password hash was injected at build time.
+// TODO: When false, all auth checks should be bypassed (dev mode).
+func (m *Manager) Enabled() bool {
+ return config.PasswordHashB64 != ""
+}
+
+// ValidatePassword decodes the base64 hash and compares with bcrypt.
+func (m *Manager) ValidatePassword(password string) bool {
+ if !m.Enabled() {
+ log.Println("[!] AUTH DISABLED: No password hash configured (dev mode)")
+ return true
+ }
+
+ // Decode base64 back to raw bcrypt hash
+ hashBytes, err := base64.StdEncoding.DecodeString(config.PasswordHashB64)
+ if err != nil {
+ log.Printf("[X] Failed to decode password hash from base64: %v", err)
+ return false
+ }
+
+ return bcrypt.CompareHashAndPassword(hashBytes, []byte(password)) == nil
+}
+
+// CreateSession generates a cryptographically random session token.
+func (m *Manager) CreateSession() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ // crypto/rand failure is catastrophic; fall back to timestamp-based token
+ log.Printf("[!] crypto/rand failed: %v", err)
+ return hex.EncodeToString([]byte(time.Now().String()))
+ }
+ token := hex.EncodeToString(b)
+
+ m.mu.Lock()
+ m.sessions[token] = time.Now().Add(SessionDuration)
+ m.mu.Unlock()
+
+ return token
+}
+
+// ValidateSession checks if a token exists and has not expired.
+func (m *Manager) ValidateSession(token string) bool {
+ if token == "" {
+ return false
+ }
+
+ m.mu.RLock()
+ expiry, exists := m.sessions[token]
+ m.mu.RUnlock()
+
+ if !exists {
+ return false
+ }
+
+ if time.Now().After(expiry) {
+ m.mu.Lock()
+ delete(m.sessions, token)
+ m.mu.Unlock()
+ return false
+ }
+
+ return true
+}
+
+// IsLockedOut returns true if the given IP has exceeded MaxLoginAttempts.
+func (m *Manager) IsLockedOut(ip string) bool {
+ m.mu.RLock()
+ info, exists := m.failedLogins[ip]
+ m.mu.RUnlock()
+
+ if !exists {
+ return false
+ }
+ return info.count >= MaxLoginAttempts && time.Now().Before(info.lockedUntil)
+}
+
+// RecordFailedLogin increments the failure counter for an IP.
+// After MaxLoginAttempts, the IP is locked out for LockoutDuration.
+func (m *Manager) RecordFailedLogin(ip string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ info := m.failedLogins[ip]
+ info.count++
+ if info.count >= MaxLoginAttempts {
+ info.lockedUntil = time.Now().Add(LockoutDuration)
+ log.Printf("[AUDIT] IP %s locked out for %v after %d failed login attempts",
+ ip, LockoutDuration, info.count)
+ }
+ m.failedLogins[ip] = info
+}
+
+// ClearFailedLogins resets the counter on successful login.
+func (m *Manager) ClearFailedLogins(ip string) {
+ m.mu.Lock()
+ delete(m.failedLogins, ip)
+ m.mu.Unlock()
+}
+
+// SetSessionCookie writes a secure, HttpOnly session cookie.
+func (m *Manager) SetSessionCookie(w http.ResponseWriter) string {
+ token := m.CreateSession()
+ http.SetCookie(w, &http.Cookie{
+ Name: SessionCookieName,
+ Value: token,
+ Path: "/",
+ MaxAge: int(SessionDuration.Seconds()),
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+ return token
+}
+
+// ClearSessionCookie removes the session cookie from the browser.
+func (m *Manager) ClearSessionCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: SessionCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+}
+
+// GetSessionFromRequest extracts and validates the session from cookies.
+func (m *Manager) GetSessionFromRequest(r *http.Request) bool {
+ cookie, err := r.Cookie(SessionCookieName)
+ if err != nil {
+ return false
+ }
+ return m.ValidateSession(cookie.Value)
+}
+
+// cleanupLoop periodically removes expired sessions and stale lockout entries.
+// It exits when ctx is cancelled (service shutdown).
+func (m *Manager) cleanupLoop(ctx context.Context) {
+ ticker := time.NewTicker(CleanupInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Println("[i] Auth cleanup goroutine stopped")
+ return
+ case <-ticker.C:
+ m.mu.Lock()
+ now := time.Now()
+ for k, v := range m.sessions {
+ if now.After(v) {
+ delete(m.sessions, k)
+ }
+ }
+ for k, v := range m.failedLogins {
+ if v.count >= MaxLoginAttempts && now.After(v.lockedUntil) {
+ delete(m.failedLogins, k)
+ }
+ }
+ m.mu.Unlock()
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 3d8b908..9e3dd23 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -8,9 +8,19 @@ import (
// Build variables (injected via ldflags)
var (
+ // BuildEnvironment defines the deployment target (local, remote).
BuildEnvironment = "local"
- BuildDate = "unknown"
- BuildTime = "unknown"
+ // BuildDate is the date the binary was built.
+ BuildDate = "unknown"
+ // BuildTime is the time the binary was built.
+ BuildTime = "unknown"
+ // PasswordHashB64 is injected at build time via ldflags.
+ // It contains a bcrypt hash, NOT the plaintext password.
+ // TODO: If empty, authentication is disabled.
+ PasswordHashB64 = ""
+ // AuthToken is injected at build time via ldflags.
+ // If empty, config messages are accepted without token validation.
+ AuthToken = ""
)
// Environment holds environment-specific settings
diff --git a/internal/daemon/program.go b/internal/daemon/daemon.go
similarity index 95%
rename from internal/daemon/program.go
rename to internal/daemon/daemon.go
index 85f4c88..ce28762 100644
--- a/internal/daemon/program.go
+++ b/internal/daemon/daemon.go
@@ -12,6 +12,7 @@ import (
"github.com/judwhite/go-svc"
+ "github.com/adcondev/scale-daemon/internal/auth"
"github.com/adcondev/scale-daemon/internal/config"
"github.com/adcondev/scale-daemon/internal/logging"
"github.com/adcondev/scale-daemon/internal/scale"
@@ -33,6 +34,7 @@ type Service struct {
reader *scale.Reader
broadcaster *server.Broadcaster
srv *server.Server
+ authMgr *auth.Manager
// Lifecycle
broadcast chan string
@@ -80,6 +82,9 @@ func (s *Service) Start() error {
s.quit = make(chan struct{})
s.ctx, s.cancel = context.WithCancel(context.Background())
+ // Create auth manager (bound to service ctx for clean shutdown)
+ s.authMgr = auth.NewManager(s.ctx)
+
// Create broadcaster
s.broadcaster = server.NewBroadcaster(s.broadcast)
@@ -93,6 +98,7 @@ func (s *Service) Start() error {
s.env,
s.broadcaster,
s.logMgr,
+ s.authMgr,
buildInfo,
s.onConfigChange,
s.BuildDate,
diff --git a/internal/server/models.go b/internal/server/models.go
index aa12396..0a6aed0 100644
--- a/internal/server/models.go
+++ b/internal/server/models.go
@@ -1,16 +1,24 @@
package server
-// ConfigMessage matches the exact JSON structure from clients
-// CONSTRAINT: All fields must match legacy format exactly
+// ConfigMessage matches the exact JSON structure from clients.
+// AuthToken is required when AuthToken is set at build time.
+// CONSTRAINT: All fields must match legacy format exactly. JSON fields can't be changed or be removed.
type ConfigMessage struct {
Tipo string `json:"tipo"`
Puerto string `json:"puerto"`
Marca string `json:"marca"`
ModoPrueba bool `json:"modoPrueba"`
Dir string `json:"dir,omitempty"`
+ AuthToken string `json:"authToken"` // Required for config changes
}
-// EnvironmentInfo sent to clients on connection
+// ErrorResponse is sent back to clients when an operation is rejected
+type ErrorResponse struct {
+ Tipo string `json:"tipo"`
+ Error string `json:"error"`
+}
+
+// EnvironmentInfo is sent to clients on connection
type EnvironmentInfo struct {
Tipo string `json:"tipo"`
Ambiente string `json:"ambiente"`
diff --git a/internal/server/ratelimit.go b/internal/server/ratelimit.go
new file mode 100644
index 0000000..fd79667
--- /dev/null
+++ b/internal/server/ratelimit.go
@@ -0,0 +1,47 @@
+package server
+
+import (
+ "sync"
+ "time"
+)
+
+// ConfigRateLimiter restricts how frequently a single client
+// can send config change requests via WebSocket.
+type ConfigRateLimiter struct {
+ mu sync.Mutex
+ attempts map[string][]time.Time
+ maxPerMin int
+}
+
+// NewConfigRateLimiter creates a limiter allowing maxPerMinute config changes per client.
+func NewConfigRateLimiter(maxPerMinute int) *ConfigRateLimiter {
+ return &ConfigRateLimiter{
+ attempts: make(map[string][]time.Time),
+ maxPerMin: maxPerMinute,
+ }
+}
+
+// Allow returns true if the client has not exceeded the rate limit.
+// It prunes old entries on every call.
+func (rl *ConfigRateLimiter) Allow(clientAddr string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ now := time.Now()
+ cutoff := now.Add(-time.Minute)
+
+ // Keep only entries within the window
+ recent := make([]time.Time, 0, rl.maxPerMin)
+ for _, t := range rl.attempts[clientAddr] {
+ if t.After(cutoff) {
+ recent = append(recent, t)
+ }
+ }
+
+ if len(recent) >= rl.maxPerMin {
+ return false
+ }
+
+ rl.attempts[clientAddr] = append(recent, now)
+ return true
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 43a394b..4763160 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -1,24 +1,26 @@
-// Package server implements the HTTP and WebSocket server for the scale daemon.
package server
import (
"context"
"encoding/json"
"errors"
+ "fmt"
"io"
"io/fs"
"log"
"net/http"
"sync"
+ "text/template"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
+ "github.com/adcondev/scale-daemon/internal/auth"
"github.com/adcondev/scale-daemon/internal/config"
"github.com/adcondev/scale-daemon/internal/logging"
- "github.com/adcondev/scale-daemon"
+ embedded "github.com/adcondev/scale-daemon"
)
// Server handles HTTP and WebSocket connections
@@ -27,6 +29,8 @@ type Server struct {
env config.Environment
broadcaster *Broadcaster
logMgr *logging.Manager
+ auth *auth.Manager
+ configLimiter *ConfigRateLimiter
buildInfo string
onConfigChange func()
buildDate string
@@ -35,6 +39,7 @@ type Server struct {
mu sync.RWMutex
lastWeightTime time.Time
httpServer *http.Server
+ dashboardTmpl *template.Template
}
// NewServer creates a new server instance
@@ -43,6 +48,7 @@ func NewServer(
env config.Environment,
broadcaster *Broadcaster,
logMgr *logging.Manager,
+ authMgr *auth.Manager,
buildInfo string,
onConfigChange func(),
buildDate string,
@@ -54,6 +60,8 @@ func NewServer(
env: env,
broadcaster: broadcaster,
logMgr: logMgr,
+ auth: authMgr,
+ configLimiter: NewConfigRateLimiter(3), // Max 3 config changes per minute per client
buildInfo: buildInfo,
onConfigChange: onConfigChange,
buildDate: buildDate,
@@ -61,25 +69,42 @@ func NewServer(
startTime: startTime,
}
- // Setup HTTP handlers
- mux := http.NewServeMux()
- mux.HandleFunc("/ws", s.handleWebSocket)
- mux.HandleFunc("/health", s.HandleHealth)
- mux.HandleFunc("/ping", s.HandlePing)
-
- // Setup FS
+ // Setup embedded filesystem
webFS, err := fs.Sub(embedded.WebFiles, "internal/assets/web")
if err != nil {
- // Panic is acceptable here as service cannot function without assets
log.Fatalf("[FATAL] Error loading web assets: %v", err)
}
- mux.Handle("/", http.FileServer(http.FS(webFS)))
- // This ensures s.httpServer is NEVER nil once NewServer returns
+ // Parse index.html as a Go template for token injection
+ indexBytes, err := fs.ReadFile(webFS, "index.html")
+ if err != nil {
+ log.Fatalf("[FATAL] Error reading index.html: %v", err)
+ }
+ s.dashboardTmpl, err = template.New("dashboard").Parse(string(indexBytes))
+ if err != nil {
+ log.Fatalf("[FATAL] Error parsing index.html as template: %v", err)
+ }
+
+ // Setup HTTP handlers with correct auth boundaries
+ mux := http.NewServeMux()
+
+ // ── PUBLIC ROUTES (no auth required) ─────────────────────
+ // Static assets must be public so login.html can load CSS
+ mux.Handle("/css/", http.FileServer(http.FS(webFS)))
+ mux.Handle("/js/", http.FileServer(http.FS(webFS)))
+ mux.HandleFunc("/login", s.serveLoginPage(webFS))
+ mux.HandleFunc("/auth/login", s.handleLogin)
+ mux.HandleFunc("/auth/logout", s.handleLogout)
+ mux.HandleFunc("/ping", s.HandlePing)
+
+ // ── PROTECTED ROUTES (session required) ──────────────────
+ mux.HandleFunc("/ws", s.requireAuth(s.handleWebSocket))
+ mux.HandleFunc("/health", s.requireAuth(s.HandleHealth))
+ mux.HandleFunc("/", s.requireAuth(s.serveDashboard))
+
s.httpServer = &http.Server{
- Addr: env.ListenAddr,
- Handler: mux,
- // ALWAYS add timeouts to prevent Slowloris attacks
+ Addr: env.ListenAddr,
+ Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
@@ -88,17 +113,115 @@ func NewServer(
return s
}
-// handleWebSocket upgrades the connection
-func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
- // No need to check "Upgrade" header here manually;
- // clients connecting to /ws likely intend to upgrade.
+// ═══════════════════════════════════════════════════════════════
+// AUTH MIDDLEWARE & HANDLERS
+// ═══════════════════════════════════════════════════════════════
+
+// requireAuth wraps a HandlerFunc with session validation.
+// If auth is disabled (no hash), all requests pass through.
+func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !s.auth.Enabled() {
+ next(w, r)
+ return
+ }
+ if !s.auth.GetSessionFromRequest(r) {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ next(w, r)
+ }
+}
+// serveLoginPage returns a handler that serves login.html from the embedded FS.
+func (s *Server) serveLoginPage(webFS fs.FS) http.HandlerFunc {
+ loginHTML, err := fs.ReadFile(webFS, "login.html")
+ if err != nil {
+ log.Fatalf("[FATAL] Error reading login.html: %v", err)
+ }
+ return func(w http.ResponseWriter, r *http.Request) {
+ // If auth is disabled, skip login entirely
+ if !s.auth.Enabled() {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ // If already authenticated, redirect to dashboard
+ if s.auth.GetSessionFromRequest(r) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(loginHTML)
+ }
+}
+
+// serveDashboard renders index.html as a Go template, injecting the config auth token.
+// This solves the "static file injection paradox": index.html is a template, not a static file.
+func (s *Server) serveDashboard(w http.ResponseWriter, r *http.Request) {
+ // Only serve dashboard for root path (avoid catching /favicon.ico etc.)
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ data := struct {
+ AuthToken string
+ }{
+ AuthToken: config.AuthToken,
+ }
+ if err := s.dashboardTmpl.Execute(w, data); err != nil {
+ log.Printf("[X] Error rendering dashboard template: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
+
+// handleLogin processes POST /auth/login
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ip := r.RemoteAddr
+
+ // Check lockout FIRST
+ if s.auth.IsLockedOut(ip) {
+ log.Printf("[AUDIT] LOGIN_BLOCKED | IP=%s | reason=lockout", ip)
+ http.Redirect(w, r, "/login?locked=1", http.StatusSeeOther)
+ return
+ }
+
+ password := r.FormValue("password")
+ if !s.auth.ValidatePassword(password) {
+ s.auth.RecordFailedLogin(ip)
+ log.Printf("[AUDIT] LOGIN_FAILED | IP=%s", ip)
+ http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
+ return
+ }
+
+ // Success
+ s.auth.ClearFailedLogins(ip)
+ s.auth.SetSessionCookie(w)
+ log.Printf("[AUDIT] LOGIN_SUCCESS | IP=%s", ip)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// handleLogout clears the session
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ s.auth.ClearSessionCookie(w)
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
+
+// ═══════════════════════════════════════════════════════════════
+// WEBSOCKET
+// ═══════════════════════════════════════════════════════════════
+
+func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
- // FIXME: InsecureSkipVerify should be false in production with proper certs
InsecureSkipVerify: true,
- OriginPatterns: []string{"*"},
+ OriginPatterns: s.allowedOrigins(),
})
-
if err != nil {
log.Printf("[X] Error accepting websocket: %v", err)
return
@@ -112,21 +235,25 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- // Register client
s.broadcaster.AddClient(c)
log.Printf("[+] Client connected (Total: %d)", s.broadcaster.ClientCount())
- // Send initial state
s.sendEnvironmentInfo(ctx, c)
-
- // Listen for incoming config messages
s.listenForMessages(ctx, c)
- // Cleanup
s.broadcaster.RemoveClient(c)
log.Println("[-] Client disconnected")
}
+// allowedOrigins returns environment-specific WebSocket origin patterns.
+func (s *Server) allowedOrigins() []string {
+ if s.env.Name == "LOCAL" {
+ return []string{"localhost:*", "127.0.0.1:*"}
+ }
+ // Remote: allow common private network ranges
+ return []string{"192.168.*.*:*", "10.*.*.*:*", "172.16.*.*:*", "localhost:*"}
+}
+
func (s *Server) sendEnvironmentInfo(ctx context.Context, c *websocket.Conn) {
conf := s.config.Get()
@@ -180,7 +307,15 @@ func (s *Server) listenForMessages(ctx context.Context, c *websocket.Conn) {
func (s *Server) handleMessage(ctx context.Context, c *websocket.Conn, tipo string, mensaje map[string]interface{}) {
switch tipo {
case "config":
- s.handleConfigMessage(mensaje)
+ // ── RATE LIMIT CHECK ─────────────────────────────────
+ // Use connection pointer address as unique client identifier
+ clientAddr := fmt.Sprintf("%p", c)
+ if !s.configLimiter.Allow(clientAddr) {
+ log.Printf("[AUDIT] CONFIG_RATE_LIMITED | client=%s", clientAddr)
+ s.sendJSON(ctx, c, ErrorResponse{Tipo: "error", Error: "RATE_LIMITED"})
+ return
+ }
+ s.handleConfigMessage(ctx, c, mensaje)
case "logConfig":
if v, ok := mensaje["verbose"].(bool); ok {
@@ -215,17 +350,24 @@ func (s *Server) handleMessage(ctx context.Context, c *websocket.Conn, tipo stri
}
}
-func (s *Server) handleConfigMessage(mensaje map[string]interface{}) {
+func (s *Server) handleConfigMessage(ctx context.Context, c *websocket.Conn, mensaje map[string]interface{}) {
// Parse into struct for type safety
data, _ := json.Marshal(mensaje)
var configMsg ConfigMessage
- err := json.Unmarshal(data, &configMsg)
- if err != nil {
+ if err := json.Unmarshal(data, &configMsg); err != nil {
log.Printf("[X] Error parsing config message: %v", err)
return
}
- log.Printf("[i] Configuración recibida: Puerto=%s Marca=%s ModoPrueba=%v",
+ // ── TOKEN VALIDATION ─────────────────────────────────────
+ if config.AuthToken != "" && configMsg.AuthToken != config.AuthToken {
+ log.Printf("[AUDIT] CONFIG_REJECTED | reason=invalid_token | puerto=%s marca=%s",
+ configMsg.Puerto, configMsg.Marca)
+ s.sendJSON(ctx, c, ErrorResponse{Tipo: "error", Error: "AUTH_INVALID_TOKEN"})
+ return
+ }
+
+ log.Printf("[AUDIT] CONFIG_ACCEPTED | puerto=%s marca=%s modoPrueba=%v",
configMsg.Puerto, configMsg.Marca, configMsg.ModoPrueba)
if s.config.Update(configMsg.Puerto, configMsg.Marca, configMsg.ModoPrueba) {
@@ -239,28 +381,24 @@ func (s *Server) handleConfigMessage(mensaje map[string]interface{}) {
}
}
-// HandlePing is a lightweight liveness check
+// ═══════════════════════════════════════════════════════════════
+// HTTP ENDPOINTS
+// ═══════════════════════════════════════════════════════════════
+
func (s *Server) HandlePing(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
- _, err := w.Write([]byte("pong"))
- if err != nil {
- return
- }
+ _, _ = w.Write([]byte("pong"))
}
-// HandleHealth returns service health metrics
func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
cfg := s.config.Get()
- // If last weight was received < 15 seconds ago, assume connected.
- // Adjust threshold based on your poll interval.
s.mu.RLock()
isConnected := !s.lastWeightTime.IsZero() && time.Since(s.lastWeightTime) < 15*time.Second
s.mu.RUnlock()
- // If in Test Mode, we are always "connected" to the generator
if cfg.ModoPrueba {
isConnected = true
}
@@ -278,22 +416,16 @@ func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
Date: s.buildDate,
Time: s.buildTime,
},
- // Safe uptime calculation
Uptime: int(time.Since(s.startTime).Seconds()),
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
- err := json.NewEncoder(w).Encode(response)
- if err != nil {
- return
- }
+ _ = json.NewEncoder(w).Encode(response)
}
func (s *Server) sendJSON(ctx context.Context, c *websocket.Conn, v interface{}) {
- // Record activity whenever we successfully send data (e.g. weight updates)
s.recordWeightActivity()
-
ctx2, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
_ = wsjson.Write(ctx2, c, v)
@@ -305,16 +437,13 @@ func (s *Server) recordWeightActivity() {
s.lastWeightTime = time.Now()
}
-// ListenAndServe inicia el servidor HTTP
func (s *Server) ListenAndServe() error {
log.Printf("[i] Dashboard active at http://%s/", s.env.ListenAddr)
log.Printf("[i] WebSocket active at ws://%s/ws", s.env.ListenAddr)
-
- // Just start the already-configured server
+ log.Printf("[i] Auth enabled: %v", s.auth.Enabled())
return s.httpServer.ListenAndServe()
}
-// Shutdown gracefully shuts down the HTTP server
func (s *Server) Shutdown(ctx context.Context) error {
if s.httpServer == nil {
return errors.New("server Shutdown called with nil httpServer; invariant violated")
From bc40f6e181becd7ac0737344e8b7d22a2fd1dd37 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Fri, 13 Feb 2026 17:08:50 -0700
Subject: [PATCH 2/8] feat(login): add login page and handle authentication
errors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
internal/assets/web/index.html | 2 +
internal/assets/web/js/websocket.js | 27 ++++++++-
internal/assets/web/login.html | 86 +++++++++++++++++++++++++++++
3 files changed, 112 insertions(+), 3 deletions(-)
create mode 100644 internal/assets/web/login.html
diff --git a/internal/assets/web/index.html b/internal/assets/web/index.html
index e490eac..d38a700 100644
--- a/internal/assets/web/index.html
+++ b/internal/assets/web/index.html
@@ -5,6 +5,8 @@
⚖️ Scale Daemon | Centro de Control
+
+
diff --git a/internal/assets/web/js/websocket.js b/internal/assets/web/js/websocket.js
index 0883379..c8ad254 100644
--- a/internal/assets/web/js/websocket.js
+++ b/internal/assets/web/js/websocket.js
@@ -61,8 +61,9 @@ function connectWebSocket() {
// Everything else that parses as JSON but isn't 'ambiente' is treated as weight
if (msg && typeof msg === 'object' && msg.tipo === 'ambiente') {
handleAmbienteMessage(msg);
+ } else if (msg && typeof msg === 'object' && msg.tipo === 'error') {
+ handleServerError(msg); // Handle auth/rate-limit errors
} else {
- // Valid JSON but not 'ambiente' -> could be quoted weight string
handleWeightReading(msg);
}
};
@@ -126,17 +127,37 @@ function sendMessage(msg) {
return true;
}
-// Send configuration update
+// Read the auth token injected by the server into the HTML template
+function getAuthToken() {
+ const meta = document.querySelector('meta[name="ws-auth-token"]');
+ return meta ? meta.content : '';
+}
+
+// Send configuration update (with auth token)
function sendConfig() {
const config = {
tipo: 'config',
puerto: el.puertoInput.value || 'COM3',
marca: el.marcaSelect.value || 'Rhino BAR 8RS',
- modoPrueba: el.modoPruebaCheck.checked
+ modoPrueba: el.modoPruebaCheck.checked,
+ authToken: getAuthToken()
};
if (sendMessage(config)) {
addLog('SENT', `📤 Config: ${config.puerto} | ${config.marca} | Prueba: ${config.modoPrueba}`);
showToast('Configuración enviada', 'info');
}
+}
+
+// Also handle new error responses from the server
+// In the onmessage handler, add handling for "error" tipo:
+// (Inside handleAmbienteMessage or a new handler)
+function handleServerError(msg) {
+ const errorMessages = {
+ 'AUTH_INVALID_TOKEN': '🔒 Token de autenticación inválido',
+ 'RATE_LIMITED': '⏳ Demasiados cambios de configuración. Espere un momento.',
+ };
+ const text = errorMessages[msg.error] || `Error: ${msg.error}`;
+ addLog('ERROR', text, 'error');
+ showToast(text, 'error');
}
\ No newline at end of file
diff --git a/internal/assets/web/login.html b/internal/assets/web/login.html
new file mode 100644
index 0000000..f7da9a3
--- /dev/null
+++ b/internal/assets/web/login.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ ⚖️ Scale Daemon | Login
+
+
+
+
+
+
+
⚖️
+
Scale Daemon
+
+ Ingrese la contraseña para acceder al panel de control
+
+
+
Contraseña incorrecta
+
Demasiados intentos. Intente más tarde.
+
+
+
+
+
+
\ No newline at end of file
From 708c9d7c3ada3d1553c8e514ea15d7717f833a5d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Fri, 13 Feb 2026 17:09:01 -0700
Subject: [PATCH 3/8] feat(schema): add authToken to ConfigMessage and update
ErrorResponse descriptions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
.gitignore | 37 +++++++++++++++++++++++++++---
api/v1/scale_websocket.schema.json | 30 +++++++++++++++++++++---
go.mod | 3 ++-
go.sum | 6 +++--
4 files changed, 67 insertions(+), 9 deletions(-)
diff --git a/.gitignore b/.gitignore
index cd57f9f..c787906 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,34 @@
-bin
-.task
-tmp
\ No newline at end of file
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Code coverage profiles and other test artifacts
+*.out
+coverage.*
+*.coverprofile
+profile.cov
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# Security - never commit secrets
+.env
+.env.*
+!.env.example
+
+# Editor/IDE
+# .idea/
+# .vscode/
diff --git a/api/v1/scale_websocket.schema.json b/api/v1/scale_websocket.schema.json
index fa704b6..518b4b5 100644
--- a/api/v1/scale_websocket.schema.json
+++ b/api/v1/scale_websocket.schema.json
@@ -44,7 +44,8 @@
"tipo",
"puerto",
"marca",
- "modoPrueba"
+ "modoPrueba",
+ "authToken"
],
"properties": {
"tipo": {
@@ -53,8 +54,7 @@
"puerto": {
"type": "string",
"examples": [
- "COM3",
- "/dev/ttyUSB0"
+ "COM3"
]
},
"marca": {
@@ -65,6 +65,30 @@
},
"modoPrueba": {
"type": "boolean"
+ },
+ "authToken": {
+ "type": "string",
+ "description": "Authentication token required to authorize config changes. Injected into dashboard HTML at render time."
+ }
+ }
+ },
+ "ErrorResponse": {
+ "type": "object",
+ "required": [
+ "tipo",
+ "error"
+ ],
+ "properties": {
+ "tipo": {
+ "const": "error"
+ },
+ "error": {
+ "type": "string",
+ "enum": [
+ "AUTH_INVALID_TOKEN",
+ "RATE_LIMITED"
+ ],
+ "description": "Error code for rejected operations"
}
}
},
diff --git a/go.mod b/go.mod
index b7cb864..19e3405 100644
--- a/go.mod
+++ b/go.mod
@@ -6,9 +6,10 @@ require (
github.com/coder/websocket v1.8.14
github.com/judwhite/go-svc v1.2.1
go.bug.st/serial v1.6.4
+ golang.org/x/crypto v0.48.0
)
require (
github.com/creack/goselect v0.1.3 // indirect
- golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
)
diff --git a/go.sum b/go.sum
index 36be79b..090d184 100644
--- a/go.sum
+++ b/go.sum
@@ -12,8 +12,10 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
From 01fde88bf9b4cffd182beac966d0a3a2fb601420 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Wed, 18 Feb 2026 14:21:34 -0700
Subject: [PATCH 4/8] feat(broadcaster): add callback for weight broadcast
activity and improve configuration handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
internal/config/config.go | 17 +++++++++-----
internal/daemon/daemon.go | 6 +++--
internal/server/broadcaster.go | 22 ++++++++++++++-----
internal/server/models.go | 2 +-
.../server/{ratelimit.go => rate_limit.go} | 0
internal/server/server.go | 19 +++++++++++-----
6 files changed, 45 insertions(+), 21 deletions(-)
rename internal/server/{ratelimit.go => rate_limit.go} (100%)
diff --git a/internal/config/config.go b/internal/config/config.go
index 9e3dd23..6d3918a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,11 +3,14 @@ package config
import (
"fmt"
+ "log"
"sync"
)
// Build variables (injected via ldflags)
var (
+ // ServiceName is the name of the service, used for logging and identification.
+ ServiceName = "R2k_BasculaServicio_Local"
// BuildEnvironment defines the deployment target (local, remote).
BuildEnvironment = "local"
// BuildDate is the date the binary was built.
@@ -16,11 +19,12 @@ var (
BuildTime = "unknown"
// PasswordHashB64 is injected at build time via ldflags.
// It contains a bcrypt hash, NOT the plaintext password.
- // TODO: If empty, authentication is disabled.
PasswordHashB64 = ""
// AuthToken is injected at build time via ldflags.
// If empty, config messages are accepted without token validation.
AuthToken = ""
+ // ServerPort is the default port for the scale service, can be overridden by environment config.
+ ServerPort = ""
)
// Environment holds environment-specific settings
@@ -38,15 +42,15 @@ type Environment struct {
var Environments = map[string]Environment{
"remote": {
Name: "REMOTO",
- ServiceName: "R2k_BasculaServicio_Remote",
- ListenAddr: "0.0.0.0:8765",
+ ServiceName: ServiceName,
+ ListenAddr: "0.0.0.0:" + ServerPort,
DefaultPort: "COM3",
DefaultMode: false,
},
"local": {
Name: "LOCAL",
- ServiceName: "R2k_BasculaServicio_Local",
- ListenAddr: "localhost:8765",
+ ServiceName: ServiceName,
+ ListenAddr: "localhost:" + ServerPort,
DefaultPort: "COM3",
DefaultMode: false,
},
@@ -58,7 +62,8 @@ func GetEnvironment(env string) Environment {
if cfg, ok := Environments[env]; ok {
return cfg
}
- return Environments["remote"]
+ log.Printf("[!] Unknown environment '%s', defaulting to 'local'", env)
+ return Environments["local"]
}
// Config holds the runtime configuration for the scale service
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index ce28762..d63b527 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -85,8 +85,10 @@ func (s *Service) Start() error {
// Create auth manager (bound to service ctx for clean shutdown)
s.authMgr = auth.NewManager(s.ctx)
- // Create broadcaster
- s.broadcaster = server.NewBroadcaster(s.broadcast)
+ // Create broadcaster with weight activity callback
+ s.broadcaster = server.NewBroadcaster(s.broadcast, func() {
+ s.srv.RecordWeightActivity()
+ })
// Create scale reader
s.reader = scale.NewReader(s.cfg, s.broadcast)
diff --git a/internal/server/broadcaster.go b/internal/server/broadcaster.go
index 5dfec7d..2cf99a4 100644
--- a/internal/server/broadcaster.go
+++ b/internal/server/broadcaster.go
@@ -12,16 +12,18 @@ import (
// Broadcaster fans out weight readings to all connected clients
type Broadcaster struct {
- clients map[*websocket.Conn]bool
- mu sync.RWMutex
- broadcast <-chan string
+ clients map[*websocket.Conn]bool
+ mu sync.RWMutex
+ broadcast <-chan string
+ onWeightSent func() // Called after successful weight broadcast
}
// NewBroadcaster creates a broadcaster for the given channel
-func NewBroadcaster(broadcast <-chan string) *Broadcaster {
+func NewBroadcaster(broadcast <-chan string, onWeightSent func()) *Broadcaster {
return &Broadcaster{
- clients: make(map[*websocket.Conn]bool),
- broadcast: broadcast,
+ clients: make(map[*websocket.Conn]bool),
+ broadcast: broadcast,
+ onWeightSent: onWeightSent,
}
}
@@ -50,6 +52,10 @@ func (b *Broadcaster) broadcastWeight(peso string) {
}
b.mu.RUnlock()
+ if len(clients) == 0 {
+ return
+ }
+
for _, conn := range clients {
go func(c *websocket.Conn) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -63,6 +69,10 @@ func (b *Broadcaster) broadcastWeight(peso string) {
}
}(conn)
}
+ // Record activity after broadcasting to at least one client
+ if b.onWeightSent != nil {
+ b.onWeightSent()
+ }
}
// removeAndCloseClient safely removes and closes a client connection
diff --git a/internal/server/models.go b/internal/server/models.go
index 0a6aed0..c55ebbb 100644
--- a/internal/server/models.go
+++ b/internal/server/models.go
@@ -9,7 +9,7 @@ type ConfigMessage struct {
Marca string `json:"marca"`
ModoPrueba bool `json:"modoPrueba"`
Dir string `json:"dir,omitempty"`
- AuthToken string `json:"authToken"` // Required for config changes
+ AuthToken string `json:"auth_token"` // Required for config changes
}
// ErrorResponse is sent back to clients when an operation is rejected
diff --git a/internal/server/ratelimit.go b/internal/server/rate_limit.go
similarity index 100%
rename from internal/server/ratelimit.go
rename to internal/server/rate_limit.go
diff --git a/internal/server/server.go b/internal/server/server.go
index 4763160..525f15b 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -5,12 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
+ "html/template"
"io"
"io/fs"
"log"
"net/http"
"sync"
- "text/template"
"time"
"github.com/coder/websocket"
@@ -23,6 +23,8 @@ import (
embedded "github.com/adcondev/scale-daemon"
)
+const maxConfigChangesPerMinute = 15
+
// Server handles HTTP and WebSocket connections
type Server struct {
config *config.Config
@@ -61,7 +63,7 @@ func NewServer(
broadcaster: broadcaster,
logMgr: logMgr,
auth: authMgr,
- configLimiter: NewConfigRateLimiter(3), // Max 3 config changes per minute per client
+ configLimiter: NewConfigRateLimiter(maxConfigChangesPerMinute), // Max 15 config changes per minute per client
buildInfo: buildInfo,
onConfigChange: onConfigChange,
buildDate: buildDate,
@@ -96,10 +98,11 @@ func NewServer(
mux.HandleFunc("/auth/login", s.handleLogin)
mux.HandleFunc("/auth/logout", s.handleLogout)
mux.HandleFunc("/ping", s.HandlePing)
+ mux.HandleFunc("/ws", s.handleWebSocket)
+ mux.HandleFunc("/health", s.HandleHealth)
// ── PROTECTED ROUTES (session required) ──────────────────
- mux.HandleFunc("/ws", s.requireAuth(s.handleWebSocket))
- mux.HandleFunc("/health", s.requireAuth(s.HandleHealth))
+
mux.HandleFunc("/", s.requireAuth(s.serveDashboard))
s.httpServer = &http.Server{
@@ -385,6 +388,7 @@ func (s *Server) handleConfigMessage(ctx context.Context, c *websocket.Conn, men
// HTTP ENDPOINTS
// ═══════════════════════════════════════════════════════════════
+// HandlePing responds with "pong" for health checks.
func (s *Server) HandlePing(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "text/plain")
@@ -392,6 +396,7 @@ func (s *Server) HandlePing(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("pong"))
}
+// HandleHealth returns service health and scale connection status.
func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
cfg := s.config.Get()
@@ -425,18 +430,19 @@ func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) sendJSON(ctx context.Context, c *websocket.Conn, v interface{}) {
- s.recordWeightActivity()
ctx2, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
_ = wsjson.Write(ctx2, c, v)
}
-func (s *Server) recordWeightActivity() {
+// RecordWeightActivity updates the last weight timestamp for health checks.
+func (s *Server) RecordWeightActivity() {
s.mu.Lock()
defer s.mu.Unlock()
s.lastWeightTime = time.Now()
}
+// ListenAndServe starts the HTTP server and logs the active endpoints and auth status.
func (s *Server) ListenAndServe() error {
log.Printf("[i] Dashboard active at http://%s/", s.env.ListenAddr)
log.Printf("[i] WebSocket active at ws://%s/ws", s.env.ListenAddr)
@@ -444,6 +450,7 @@ func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
+// Shutdown gracefully shuts down the HTTP server with a timeout context.
func (s *Server) Shutdown(ctx context.Context) error {
if s.httpServer == nil {
return errors.New("server Shutdown called with nil httpServer; invariant violated")
From d9a081f1d97d91d4c3a0867d9b20bd527470ff25 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Wed, 18 Feb 2026 14:33:52 -0700
Subject: [PATCH 5/8] feat(login): add auth token input to configuration and
update WebSocket auth token key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
README.md | 86 ++++++++++++++++++++++++++
docs/old/v0.1.0/html/index_config.html | 1 +
internal/assets/web/js/websocket.js | 2 +-
internal/server/server.go | 1 +
4 files changed, 89 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index a3b87ab..8399d5d 100644
--- a/README.md
+++ b/README.md
@@ -166,3 +166,89 @@ Los logs se almacenan en `%PROGRAMDATA%` con un sistema de **autorrotación** pa
* **Ruta**: `C:\ProgramData\R2k_Bascula_Remote\R2k_Bascula_Remote.log`
* **Límite**: 5 MB (al excederse, se conservan las últimas 1000 líneas para trazabilidad).
+
+---
+
+## 🔐 Seguridad
+
+Scale Daemon implementa un modelo de seguridad por capas, diseñado para entornos de retail donde se necesita
+proteger la configuración del servicio sin impactar la lectura de peso en tiempo real.
+
+### Capas de Protección
+
+| Capa | Protege | Mecanismo |
+|---------------------|----------------------------------------|------------------------------------------------|
+| **Dashboard Login** | Acceso al panel de control (`/`) | Contraseña + sesión con cookie HttpOnly |
+| **Config Token** | Cambios de configuración vía WebSocket | Token de autorización en cada mensaje `config` |
+| **Rate Limiter** | Abuso de configuración | Máximo 3 cambios por minuto por conexión |
+| **Brute Force** | Ataques de fuerza bruta al login | Bloqueo de IP tras 5 intentos fallidos (5 min) |
+
+### Modelo de Acceso por Endpoint
+
+```text
+┌─────────────────────────────────────────────────────────────────┐
+│ PÚBLICO (sin autenticación) │
+│ ├── GET /login Página de login │
+│ ├── POST /auth/login Procesar login │
+│ ├── GET /ping Verificación de latencia │
+│ ├── GET /health Diagnóstico del servicio │
+│ ├── WS /ws Streaming de peso + config (token) │
+│ ├── GET /css/* Archivos estáticos │
+│ └── GET /js/* Archivos estáticos │
+│ │
+│ PROTEGIDO (sesión requerida) │
+│ └── GET / Dashboard (inyecta config token) │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+> **Nota:** El endpoint `/ws` es público para permitir que aplicaciones POS reciban peso sin necesidad de
+> autenticarse en el dashboard. Los cambios de configuración dentro del WebSocket están protegidos por el
+> `authToken`, que sólo está disponible para sesiones autenticadas a través del dashboard.
+
+### Configuración
+
+Los secretos se definen en un archivo `.env` en el directorio del build system (`poster-tuis/`):
+
+```env
+# ⚠️ NO commitear a control de versiones
+DASHBOARD_PASSWORD=MiContraseña2026
+CONFIG_AUTH_TOKEN=mi-token-secreto
+```
+
+| Variable | Vacío = | Descripción |
+|----------------------|--------------------------------------------------|----------------------------------------------------|
+| `DASHBOARD_PASSWORD` | Auth deshabilitado (acceso directo al dashboard) | Contraseña para el login del dashboard |
+| `CONFIG_AUTH_TOKEN` | Config sin validación de token | Token requerido en mensajes `config` vía WebSocket |
+
+### Pipeline de Inyección
+
+```text
+.env (plaintext)
+ → hashpw (bcrypt + base64)
+ → ldflags -X PasswordHashB64=...
+ → binario compilado (sin plaintext)
+```
+
+La contraseña **nunca** se almacena en texto plano en el binario. Se inyecta como un hash bcrypt codificado
+en base64 mediante `ldflags` durante la compilación. El token de configuración se inyecta directamente
+(no es un secreto criptográfico, es un valor de autorización).
+
+### Sesiones
+
+- Duración: **15 minutos** (configurable en `auth.go`)
+- Cookie: `sd_session`, `HttpOnly`, `SameSite=Strict`
+- Almacenamiento: en memoria del proceso (se pierden al reiniciar el servicio)
+- Limpieza automática: goroutine periódica cada 5 minutos
+
+### Auditoría
+
+Todos los eventos de seguridad se registran con el prefijo `[AUDIT]`:
+
+```
+[AUDIT] LOGIN_SUCCESS | IP=192.168.1.100:54321
+[AUDIT] LOGIN_FAILED | IP=192.168.1.100:54322
+[AUDIT] LOGIN_BLOCKED | IP=192.168.1.100:54323 | reason=lockout
+[AUDIT] CONFIG_ACCEPTED | puerto=COM4 marca=Rhino modoPrueba=false
+[AUDIT] CONFIG_REJECTED | reason=invalid_token | puerto=COM4 marca=Rhino
+[AUDIT] CONFIG_RATE_LIMITED | client=0xc0001a2000
+```
diff --git a/docs/old/v0.1.0/html/index_config.html b/docs/old/v0.1.0/html/index_config.html
index 184b3ae..9253019 100644
--- a/docs/old/v0.1.0/html/index_config.html
+++ b/docs/old/v0.1.0/html/index_config.html
@@ -14,6 +14,7 @@ Ejemplo de báscula
Marca:
Modo prueba:
Dirección:
+ Token:
diff --git a/internal/assets/web/js/websocket.js b/internal/assets/web/js/websocket.js
index c8ad254..dc5b4e2 100644
--- a/internal/assets/web/js/websocket.js
+++ b/internal/assets/web/js/websocket.js
@@ -140,7 +140,7 @@ function sendConfig() {
puerto: el.puertoInput.value || 'COM3',
marca: el.marcaSelect.value || 'Rhino BAR 8RS',
modoPrueba: el.modoPruebaCheck.checked,
- authToken: getAuthToken()
+ auth_token: getAuthToken()
};
if (sendMessage(config)) {
diff --git a/internal/server/server.go b/internal/server/server.go
index 525f15b..063f738 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -1,3 +1,4 @@
+// Package server handles WebSocket connections, HTTP endpoints, and configuration updates for the R2k Ticket Servicio dashboard.
package server
import (
From b8bc7e25a81e1f1b090000c329fbd8128330b85e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Thu, 19 Feb 2026 14:02:09 -0700
Subject: [PATCH 6/8] ci(github): enhance workflows with Poster library
integration, build checks, and improved triggers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
.github/codeql-config.yml | 38 +++++++++++
.github/pull_request_template.md | 14 ++++
.github/workflows/ci.yml | 99 +++++++++++++++++++++++++++--
.github/workflows/codeql.yml | 6 +-
.github/workflows/pr-automation.yml | 6 +-
5 files changed, 155 insertions(+), 8 deletions(-)
create mode 100644 .github/codeql-config.yml
create mode 100644 .github/pull_request_template.md
diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml
new file mode 100644
index 0000000..7e3fb9f
--- /dev/null
+++ b/.github/codeql-config.yml
@@ -0,0 +1,38 @@
+name: "Ticket Daemon CodeQL Config"
+
+# Query settings
+disable-default-queries: false
+
+# Queries to run
+queries:
+ - uses: security-extended
+ - uses: security-and-quality
+
+# Exclude test files, examples, and vendored code
+paths-ignore:
+ # Test files
+ - '**/*_test.go'
+ - 'test/**'
+ - 'internal/testutils/**'
+
+ # Examples (not production code)
+ - 'examples/**'
+
+ # Vendored dependencies
+ - 'vendor/**'
+
+ # Test data
+ - 'testdata/**'
+
+ # Build artifacts
+ - '*.exe'
+ - '*.dll'
+ - '*.so'
+
+ # IDE files
+ - '.idea/**'
+ - '.vscode/**'
+
+# Advanced: Query packs
+packs:
+ - codeql/go-queries
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..fb5a5fe
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,14 @@
+## 📝 Description
+
+## 🧪 Testing Strategy
+
+- [ ] Unit tests passed locally
+- [ ] Manual test on **Local** environment
+- [ ] Manual test on **Remote** environment
+- [ ] Verified build with `task build`
+
+## ✅ Checklist
+
+- [ ] Code follows project style (ran `gofmt` / `golangci-lint`)
+- [ ] Self-reviewed code
+- [ ] No new meaningful warnings generated
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd2e2a4..e920a62 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,8 +3,18 @@ name: CI
on:
push:
branches: [ main, master ]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
pull_request:
branches: [ main, master ]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
env:
GO_VERSION: '1.24.x'
@@ -16,17 +26,28 @@ jobs:
test:
name: 🧪 Test and Coverage
runs-on: ubuntu-latest
+ timeout-minutes: 10 # 🛑 Hard limit
steps:
- name: Checkout code
uses: actions/checkout@v6
+ - name: Checkout Poster library
+ uses: actions/checkout@v6
+ with:
+ repository: adcondev/poster
+ path: poster
+
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
+ - name: Patch Go modules for CI
+ run: |
+ go mod edit -replace github.com/adcondev/poster=./poster
+
- name: Run tests with race detection
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
@@ -45,6 +66,7 @@ jobs:
flags: unittests
name: codecov-ubuntu
fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
benchmark:
name: ⚡ Performance Benchmarks
@@ -61,6 +83,12 @@ jobs:
with:
fetch-depth: 0
+ - name: Checkout Poster library
+ uses: actions/checkout@v6
+ with:
+ repository: adcondev/poster
+ path: poster
+
- name: Setup Go
uses: actions/setup-go@v6
with:
@@ -70,14 +98,20 @@ jobs:
- name: Run benchmarks (base)
continue-on-error: true
run: |
- git clean -fdx
+ # Ignore our cloned repos so git clean doesn't delete them
+ git clean -fdx -e poster/
+ git reset --hard
git checkout ${{ github.event.pull_request.base.sha }}
- go test -bench=. -benchmem -run=^$ ./... > /tmp/base-benchmark.txt 2>&1
+ # Re-apply the patch because checkout restores the base go.mod
+ go mod edit -replace github.com/adcondev/poster=./poster
+ go test -bench=. -benchmem -run=^$ ./... > /tmp/base-benchmark.txt 2>&1 || true
- name: Run benchmarks (current)
run: |
- git clean -fdx
+ git clean -fdx -e poster/
+ git reset --hard
git checkout ${{ github.event.pull_request.head.sha }}
+ go mod edit -replace github.com/adcondev/poster=./poster
go test -bench=. -benchmem -run=^$ ./... > /tmp/current-benchmark.txt 2>&1
- name: Compare benchmarks
@@ -92,7 +126,7 @@ jobs:
echo '```' >> benchmark-comment.md
grep "^Benchmark" /tmp/current-benchmark.txt | head -20 >> benchmark-comment.md
echo '```' >> benchmark-comment.md
-
+
if grep -q "^Benchmark" /tmp/base-benchmark.txt; then
echo "" >> benchmark-comment.md
echo "### 📊 Base Branch Results" >> benchmark-comment.md
@@ -152,15 +186,70 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
+ - name: Checkout Poster library
+ uses: actions/checkout@v6
+ with:
+ repository: adcondev/poster
+ path: poster
+
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
+ - name: Patch Go modules for CI
+ run: |
+ go mod edit -replace github.com/adcondev/poster=./poster
+
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
skip-cache: false
- args: --config=./.golangci.yml --timeout=5m
+ args: --config=.golangci.yml --timeout=5m
+
+ build:
+ name: 🏗️ Build Check
+ runs-on: ubuntu-latest
+ needs: test # Only build if tests pass
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Checkout Poster library
+ uses: actions/checkout@v6
+ with:
+ repository: adcondev/poster
+ path: poster
+
+ - name: Setup Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ cache: true
+
+ - name: Patch Go modules for CI
+ run: |
+ go mod edit -replace github.com/adcondev/poster=./poster
+
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build via Taskfile
+ env:
+ GOOS: windows
+ GOARCH: amd64
+ SCALE_AUTH_TOKEN: ${{ secrets.SCALE_AUTH_TOKEN || 'build-token' }}
+ SCALE_DASHBOARD_HASH: ${{ secrets.SCALE_DASHBOARD_HASH || '' }}
+ BUILD_ENV: 'remote'
+ run: |
+ task build
+
+ echo "## 📦 Build Artifact" >> $GITHUB_STEP_SUMMARY
+ echo "| File | Size |" >> $GITHUB_STEP_SUMMARY
+ echo "|------|------|" >> $GITHUB_STEP_SUMMARY
+ ls -lh bin/*.exe | awk '{print "| " $9 " | " $5 " |"}' >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 9f5f27b..fae5e96 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -8,12 +8,14 @@ on:
- 'go.mod'
- 'go.sum'
- '.github/workflows/codeql.yml'
+ - '.github/codeql-config.yml' # Trigger on config changes too
pull_request:
branches: [ main, master ]
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
+ - '.github/codeql-config.yml'
schedule:
# Run every Monday at midnight UTC
- cron: '0 0 * * 1'
@@ -51,6 +53,8 @@ jobs:
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
+ # ⬇️ CRITICAL: Links to your config file ⬇️
+ config-file: ./.github/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v4
@@ -69,4 +73,4 @@ jobs:
echo "**Language:** Go" >> $GITHUB_STEP_SUMMARY
echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- echo "📊 [View detailed results](https://github.com/${{ github.repository }}/security/code-scanning)" >> $GITHUB_STEP_SUMMARY
+ echo "📊 [View detailed results](https://github.com/${{ github.repository }}/security/code-scanning)" >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml
index 4d40abe..6ca6e8e 100644
--- a/.github/workflows/pr-automation.yml
+++ b/.github/workflows/pr-automation.yml
@@ -53,8 +53,10 @@ jobs:
pr-comment:
name: PR Comment
runs-on: ubuntu-latest
- if: github.event_name == 'pull_request' && github.event.action == 'opened'
-
+ if: >-
+ github.event_name == 'pull_request' &&
+ github.event.action == 'opened' &&
+ github.actor != github.repository_owner
steps:
- name: Comment on PR
uses: actions/github-script@v8
From 4ee509931855a6f404763660a5229c980fa17bf3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Thu, 19 Feb 2026 14:05:21 -0700
Subject: [PATCH 7/8] fix(github): add PR title validation job to enforce
semantic commit conventions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
.github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e920a62..35e1fe7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,49 @@ permissions:
contents: read
jobs:
+ pr-validation:
+ name: 📋 PR Validation
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Validate PR Title
+ uses: amannn/action-semantic-pull-request@v6
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ types: |
+ feat
+ fix
+ docs
+ style
+ refactor
+ perf
+ test
+ build
+ ci
+ chore
+ deps
+ revert
+ scopes: |
+ github
+ api
+ web
+ auth
+ config
+ daemon
+ scale
+ server
+ general
+ requireScope: true
+ subjectPattern: ^(?![A-Z]).+$
+ subjectPatternError: |
+ The subject must start with lowercase letter.
+
test:
name: 🧪 Test and Coverage
runs-on: ubuntu-latest
From f35a8ddf31d162c4f017d38d8f574cf0747bbd96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Constante?=
Date: Thu, 19 Feb 2026 14:15:45 -0700
Subject: [PATCH 8/8] fix(general): add Taskfile for build automation and
suppress gosec linter warnings for sensitive logging
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Adrián Constante
---
Taskfile.yml | 40 +++++++++++++++++++++++++++++++++++++
internal/logging/logging.go | 4 +++-
internal/server/models.go | 3 ++-
internal/server/server.go | 10 +++++++---
4 files changed, 52 insertions(+), 5 deletions(-)
create mode 100644 Taskfile.yml
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 0000000..cd98b43
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,40 @@
+version: '3'
+
+# Carga variables desde el archivo .env automáticamente
+dotenv: [ '.env' ]
+
+vars:
+ # Ruta del paquete de configuración donde están las variables globales
+ CONFIG_PKG: github.com/adcondev/scale-daemon/internal/config
+ BINARY_NAME: R2k_ScaleServicio_Local.exe
+
+tasks:
+ build:
+ desc: Compila el servicio inyectando credenciales desde .env (Modo Consola)
+ cmds:
+ - echo "🔨 Compilando {{.BINARY_NAME}}..."
+ # Se usa -ldflags para inyectar las variables en tiempo de compilación.
+ # Se eliminó -H=windowsgui para permitir ver logs en consola.
+ - >
+ go build -ldflags "-s -w
+ -X '{{.CONFIG_PKG}}.AuthToken={{.SCALE_AUTH_TOKEN}}'
+ -X '{{.CONFIG_PKG}}.PasswordHashB64={{.SCALE_DASHBOARD_HASH}}'
+ -X '{{.CONFIG_PKG}}.BuildEnvironment={{.BUILD_ENV}}'
+ -X '{{.CONFIG_PKG}}.ServiceName=R2k_ScaleServicio'"
+ -o bin/{{.BINARY_NAME}} ./cmd/BasculaServicio
+ - echo "✅ Compilación exitosa en bin/{{.BINARY_NAME}}"
+
+ run:
+ desc: Compila y ejecuta inmediatamente
+ deps: [ build ]
+ cmds:
+ - ./bin/{{.BINARY_NAME}} -console
+
+ clean:
+ desc: Limpia los artefactos de compilación
+ cmds:
+ - cmd: rm -rf bin/
+ platforms: [ linux, darwin ]
+ - cmd: powershell -Command "Remove-Item -Recurse -Force bin/"
+ platforms: [ windows ]
+ - echo "🧹 Limpieza completada"
\ No newline at end of file
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index d8cca64..fa56afa 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -77,11 +77,13 @@ func Setup(serviceName string, defaultVerbose bool) (*Manager, error) {
mgr.FilePath = filepath.Join(logDir, serviceName+".log")
// Try to create log directory
+ //nolint:gosec
if err := os.MkdirAll(logDir, 0750); err != nil {
// Permission denied - fallback to stdout (console mode)
log.SetOutput(os.Stdout)
mgr.FilePath = ""
- log.Printf("[i] Logging to stdout (no write access to %s)", logDir)
+ //nolint:gosec
+ log.Printf("[i] Logging to stdout (no write access to %q)", logDir)
return mgr, nil
}
diff --git a/internal/server/models.go b/internal/server/models.go
index c55ebbb..5e561d8 100644
--- a/internal/server/models.go
+++ b/internal/server/models.go
@@ -9,7 +9,8 @@ type ConfigMessage struct {
Marca string `json:"marca"`
ModoPrueba bool `json:"modoPrueba"`
Dir string `json:"dir,omitempty"`
- AuthToken string `json:"auth_token"` // Required for config changes
+ //nolint:gosec
+ AuthToken string `json:"auth_token"` // Required for config changes
}
// ErrorResponse is sent back to clients when an operation is rejected
diff --git a/internal/server/server.go b/internal/server/server.go
index 063f738..ab92e9d 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -170,6 +170,7 @@ func (s *Server) serveDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
+ //nolint:gosec
AuthToken string
}{
AuthToken: config.AuthToken,
@@ -191,7 +192,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Check lockout FIRST
if s.auth.IsLockedOut(ip) {
- log.Printf("[AUDIT] LOGIN_BLOCKED | IP=%s | reason=lockout", ip)
+ //nolint:gosec
+ log.Printf("[AUDIT] LOGIN_BLOCKED | IP=%q | reason=lockout", ip)
http.Redirect(w, r, "/login?locked=1", http.StatusSeeOther)
return
}
@@ -199,7 +201,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
if !s.auth.ValidatePassword(password) {
s.auth.RecordFailedLogin(ip)
- log.Printf("[AUDIT] LOGIN_FAILED | IP=%s", ip)
+ //nolint:gosec
+ log.Printf("[AUDIT] LOGIN_FAILED | IP=%q", ip)
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
@@ -207,7 +210,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Success
s.auth.ClearFailedLogins(ip)
s.auth.SetSessionCookie(w)
- log.Printf("[AUDIT] LOGIN_SUCCESS | IP=%s", ip)
+ //nolint:gosec
+ log.Printf("[AUDIT] LOGIN_SUCCESS | IP=%q", ip)
http.Redirect(w, r, "/", http.StatusSeeOther)
}