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