Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gearbox/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ dev-assets: ## Download CDN assets locally for secure development (CSP-compliant
@curl -fsSL https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js -o static/js/vendor/hammer.min.js
@echo " → Chart.js Zoom Plugin..."
@curl -fsSL https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js -o static/js/vendor/chartjs-plugin-zoom.min.js
@echo " → D3.js..."
@curl -fsSL https://d3js.org/d3.v7.min.js -o static/js/vendor/d3.v7.min.js
@echo " → SortableJS..."
@curl -fsSL https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js -o static/js/vendor/sortable.min.js
@echo " → Tabulator CSS..."
@curl -fsSL https://unpkg.com/tabulator-tables@6.3.0/dist/css/tabulator.min.css -o static/css/vendor/tabulator.min.css
@echo " → Tabulator JS..."
Expand Down
37 changes: 10 additions & 27 deletions gearbox/internal/framework/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,77 +181,67 @@ func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
// GetUser retrieves the authenticated user from the session.
// OWASP 2026: Validates session token stored in database to prevent session fixation.
func (m *Manager) GetUser(r *http.Request) (*models.User, error) {
m.logger.Info("🔍 GetUser START", "path", r.URL.Path)

session, err := m.sessionStore.Get(r, sessionName)
if err != nil {
m.logger.Error("❌ Failed to get session", "error", err)
m.logger.Debug("failed to get session", "error", err)
return nil, err
}

// Check if user ID is in session (now string/UUID)
userID, ok := session.Values[sessionUserIDKey].(string)
if !ok || userID == "" {
m.logger.Warn("❌ No user ID in session")
return nil, fmt.Errorf("not authenticated")
}
m.logger.Info("👤 User ID from session", "user_id", userID)

// CRITICAL SECURITY: Validate session token from cookie against database
sessionToken, ok := session.Values[sessionTokenKey].(string)
if !ok || sessionToken == "" {
m.logger.Warn("❌ No session token in cookie", "has_key", ok, "token_empty", sessionToken == "")
m.logger.Warn("no session token in cookie", "user_id", userID)
return nil, fmt.Errorf("invalid session: missing token")
}
m.logger.Info("🔑 Session token from cookie", "token_length", len(sessionToken))

// Check if session has expired (time-based)
loginTime, ok := session.Values[sessionLoginKey].(int64)
if !ok {
m.logger.Warn("❌ No login time in session")
return nil, fmt.Errorf("invalid session")
}

if time.Since(time.Unix(loginTime, 0)) > m.timeout {
m.logger.Warn("⏰ Session expired", "login_time", time.Unix(loginTime, 0), "timeout", m.timeout)
m.logger.Debug("session expired", "user_id", userID)
return nil, fmt.Errorf("session expired")
}
m.logger.Info("⏰ Session time valid", "age", time.Since(time.Unix(loginTime, 0)))

// Get user from database
user, err := m.db.GetUserByID(userID)
if err != nil {
m.logger.Error("❌ Failed to get user from DB", "error", err, "user_id", userID)
m.logger.Error("failed to get user from DB", "error", err, "user_id", userID)
return nil, fmt.Errorf("failed to get user: %w", err)
}

if user == nil {
m.logger.Warn("❌ User not found", "user_id", userID)
m.logger.Warn("user not found", "user_id", userID)
return nil, fmt.Errorf("user not found")
}
m.logger.Info("👤 User found in DB", "email", user.Email)

// CRITICAL SECURITY: Validate session token against database
// This prevents old cookies from working after DB wipe or logout
valid, err := m.db.ValidateSessionToken(userID, sessionToken)
if err != nil {
m.logger.Error("❌ Session token validation error", "error", err, "user_id", userID)
m.logger.Error("session token validation error", "error", err, "user_id", userID)
return nil, fmt.Errorf("session validation failed")
}

if !valid {
m.logger.Warn("❌ Session token invalid in DB", "user_id", userID)
m.logger.Warn("session token invalid in DB", "user_id", userID)
return nil, fmt.Errorf("session invalid: please log in again")
}
m.logger.Info("✅ Session token validated against DB")

// Verify user is still active
if user.Status != models.UserStatusActive {
m.logger.Warn("❌ User not active", "status", user.Status)
m.logger.Warn("user not active", "user_id", userID, "status", user.Status)
return nil, fmt.Errorf("account is no longer active")
}

m.logger.Info("✅ GetUser COMPLETE", "user_id", user.ID)
return user, nil
}

Expand Down Expand Up @@ -545,31 +535,25 @@ func (m *Manager) RequirePermission(r *http.Request, component models.Component,
// CreateSessionForUser creates a session for a user without password validation.
// This is used for passkey authentication where the user has already been verified.
func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, user *models.User) error {
m.logger.Info("🔐 CreateSessionForUser START", "user_id", user.ID, "email", user.Email)

// Generate cryptographically secure session token
sessionToken, err := GenerateSessionToken()
if err != nil {
return fmt.Errorf("failed to generate session token: %w", err)
}
m.logger.Info("🔑 Session token generated", "token_length", len(sessionToken))

// Store session token in database for server-side validation
ip := r.RemoteAddr
userAgent := r.UserAgent()
m.logger.Info("💾 Storing session token in DB", "user_id", user.ID, "ip", ip)
if err := m.db.SetUserSessionToken(user.ID, sessionToken, ip, userAgent); err != nil {
m.logger.Error("failed to store session token", "error", err, "user_id", user.ID)
return fmt.Errorf("failed to create session: %w", err)
}
m.logger.Info("✅ Session token stored in DB")

// Create session cookie
session, err := m.sessionStore.Get(r, sessionName)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
m.logger.Info("🍪 Session cookie retrieved")

// Generate CSRF token
csrfToken, err := GenerateCSRFToken()
Expand All @@ -582,15 +566,14 @@ func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, u
session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request
session.Values[sessionLoginKey] = time.Now().Unix()
session.Values[csrfTokenKey] = csrfToken
m.logger.Info("📝 Session values set", "user_id", user.ID, "has_token", sessionToken != "")

// Save session
if err := session.Save(r, w); err != nil {
m.logger.Error("❌ FAILED to save session cookie", "error", err)
m.logger.Error("failed to save session cookie", "error", err)
return fmt.Errorf("failed to save session: %w", err)
}
m.logger.Info("✅ CreateSessionForUser COMPLETE - session saved")

m.logger.Info("session created for user", "user_id", user.ID, "ip", ip)
return nil
}

Expand Down
11 changes: 9 additions & 2 deletions gearbox/internal/framework/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ type DB struct {
db *sql.DB
logger *slog.Logger
mu sync.RWMutex

// sessionActivityMu protects the sessionActivityTimes map.
sessionActivityMu sync.Mutex
// sessionActivityTimes tracks the last time we updated session_last_activity per user.
// Updates are debounced to avoid write lock contention on the main mutex during auth checks.
sessionActivityTimes map[string]time.Time
Comment on lines +22 to +26

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sessionActivityTimes map grows indefinitely and is never cleaned up. Each user ID that ever logs in will have an entry in this map that persists for the lifetime of the application. Over time, this will cause a memory leak, especially in systems with many users or where user IDs change frequently.

Consider implementing periodic cleanup to remove entries that haven't been accessed in a long time (e.g., entries older than sessionActivityDebounce * 2), or remove entries when users log out via ClearUserSessionToken.

Copilot uses AI. Check for mistakes.
}

// New creates a new database connection.
Expand All @@ -39,8 +45,9 @@ func New(dbPath string, logger *slog.Logger) (*DB, error) {
}

d := &DB{
db: db,
logger: logger,
db: db,
logger: logger,
sessionActivityTimes: make(map[string]time.Time),
}

// Initialize user schema FIRST (other tables have foreign key references to users)
Expand Down
7 changes: 6 additions & 1 deletion gearbox/internal/framework/database/log_sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,13 @@ func (d *DB) GetEnabledLogSources(haproxyID int64) ([]LogSourceSetting, error) {
}

// GetEnabledLogSourcesByServerID returns enabled log sources for a server by its server_id.
// Returns empty slice (not error) if the server has no HAProxy configuration or no log sources.
func (d *DB) GetEnabledLogSourcesByServerID(serverID string) ([]LogSourceSetting, error) {
d.mu.RLock()
defer d.mu.RUnlock()

// Use LEFT JOIN so servers without HAProxy entries return empty results instead of errors.

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "Use LEFT JOIN so servers without HAProxy entries return empty results" but the query actually uses an INNER JOIN (line 57). An INNER JOIN will only return rows where both tables have matching records, so servers without HAProxy entries won't return any results - which achieves the goal. However, the comment should be corrected to match the actual query type being used, or the query should be changed to LEFT JOIN if that was the original intent. Since the goal is to return empty results for servers without HAProxy, the INNER JOIN is functionally correct, but the comment is misleading.

Suggested change
// Use LEFT JOIN so servers without HAProxy entries return empty results instead of errors.
// Use a JOIN so only servers with HAProxy entries return log sources; others return empty results.

Copilot uses AI. Check for mistakes.
// The haproxy_servers table may not have an entry for every server.

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 52 states "Use LEFT JOIN so servers without HAProxy entries return empty results" but the actual SQL query on line 57 uses an INNER JOIN. An INNER JOIN will only return rows where both tables have matching records, which means servers without HAProxy entries will indeed return empty results, but not for the reason stated. If the goal is to gracefully handle servers without HAProxy configuration, the comment should either:

  1. Be updated to match the implementation: "Use INNER JOIN which naturally returns empty results for servers without HAProxy entries", OR
  2. The query should be changed to use LEFT JOIN if there's a need to return additional information about servers without HAProxy configuration.

The current implementation appears correct for the stated goal (returning empty results), but the comment is misleading.

Copilot uses AI. Check for mistakes.
query := `
SELECT ls.id, ls.haproxy_server_id, ls.log_name, ls.display_name
FROM log_source_settings ls
Expand All @@ -58,7 +61,9 @@ func (d *DB) GetEnabledLogSourcesByServerID(serverID string) ([]LogSourceSetting

rows, err := d.db.Query(query, serverID)
if err != nil {
return nil, fmt.Errorf("failed to query log sources: %w", err)
// If the query fails (e.g., haproxy_servers table doesn't exist yet),
// return empty list rather than propagating the error
return nil, nil
Comment thread
sarg3nt marked this conversation as resolved.
}
Comment thread
sarg3nt marked this conversation as resolved.
Outdated
defer func() { _ = rows.Close() }()

Expand Down
60 changes: 46 additions & 14 deletions gearbox/internal/framework/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -1207,18 +1207,23 @@ func (d *DB) SetUserSessionToken(userID, sessionToken, ip, userAgent string) err
return err
}

// sessionActivityDebounce is the minimum interval between session_last_activity DB writes per user.
// This prevents write lock contention on the main mutex during rapid page refreshes.
const sessionActivityDebounce = 5 * time.Minute

// ValidateSessionToken checks if the provided session token matches the user's stored token.
// Returns true if valid, false otherwise. Also updates last activity time.
// Returns true if valid, false otherwise. Updates last activity time in a debounced manner
// to avoid write lock contention that causes backend lockups during rapid requests.
func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) {
d.mu.Lock()
defer d.mu.Unlock()

// Use read lock for the validation check (most common path)
d.mu.RLock()
var storedToken sql.NullString
err := d.db.QueryRow(`
SELECT session_token
FROM users
SELECT session_token
FROM users
WHERE id = ?`, userID).Scan(&storedToken)

d.mu.RUnlock()

if err != nil {
return false, err
}
Expand All @@ -1233,14 +1238,41 @@ func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) {
return false, nil
}

// Token matches - update last activity
// Debounce session_last_activity updates to avoid write lock contention.
// Go's sync.RWMutex is write-preferring: a pending Lock() blocks new RLock() callers.
// Without debouncing, every auth check spawns a goroutine that calls Lock(), which
// blocks all subsequent RLock() calls (including new auth checks), causing cascading lockups.
d.updateSessionActivityDebounced(userID)

return true, nil
}

// updateSessionActivityDebounced updates session_last_activity at most once per sessionActivityDebounce
// interval per user. This uses a separate lightweight mutex (not the main DB mutex) to check
// timing, and only acquires the main write lock when an actual DB write is needed.
func (d *DB) updateSessionActivityDebounced(userID string) {
now := time.Now()
_, err = d.db.Exec(`
UPDATE users
SET session_last_activity = ?
WHERE id = ?`, now, userID)

return true, err

d.sessionActivityMu.Lock()
lastUpdate, exists := d.sessionActivityTimes[userID]
if exists && now.Sub(lastUpdate) < sessionActivityDebounce {
d.sessionActivityMu.Unlock()
return // Skip — updated recently
}
d.sessionActivityTimes[userID] = now
d.sessionActivityMu.Unlock()
Comment on lines +1256 to +1263

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sessionActivityTimes map is never cleaned up and will grow unbounded over time as users log in. Even if a user logs out or their account is deleted, their entry remains in this map indefinitely. This is a memory leak that will accumulate entries for every unique user ID that ever authenticates. Consider adding a cleanup mechanism, such as: removing entries when users log out (in ClearUserSessionToken), periodically pruning entries older than sessionActivityDebounce, or using a size-limited cache structure like an LRU cache.

Copilot uses AI. Check for mistakes.

// Perform the actual DB write in a goroutine so it doesn't block the auth response.
// This is safe because the debounce check above ensures at most one write per user
// per interval, preventing goroutine pileup.
go func() {
d.mu.Lock()
defer d.mu.Unlock()
_, _ = d.db.Exec(`
UPDATE users
SET session_last_activity = ?
WHERE id = ?`, now, userID)
}()
Comment thread
sarg3nt marked this conversation as resolved.
}

// ClearUserSessionToken invalidates a user's session token.
Expand Down
7 changes: 2 additions & 5 deletions gearbox/internal/framework/middleware/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ const (
// InjectAssetConfig injects the asset loading configuration into the request context.
// This allows templates to conditionally load CDN vs local assets.
func InjectAssetConfig(useLocalAssets bool) func(http.Handler) http.Handler {
log.Printf("🔧 InjectAssetConfig middleware initialized with useLocalAssets=%v", useLocalAssets)
log.Printf("InjectAssetConfig middleware initialized with useLocalAssets=%v", useLocalAssets)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), UseLocalAssetsKey, useLocalAssets)
log.Printf("📝 Request to %s - injecting useLocalAssets=%v into context", r.URL.Path, useLocalAssets)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Expand All @@ -29,7 +28,5 @@ func InjectAssetConfig(useLocalAssets bool) func(http.Handler) http.Handler {
// UseLocalAssets retrieves the UseLocalAssets value from the request context.
func UseLocalAssets(ctx context.Context) bool {
val, ok := ctx.Value(UseLocalAssetsKey).(bool)
result := ok && val
log.Printf("🔍 UseLocalAssets called - found in context: %v, value: %v, returning: %v", ok, val, result)
return result
return ok && val
}
2 changes: 1 addition & 1 deletion gearbox/internal/framework/middleware/security_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func buildCSP() string {
// CDNs provide automatic updates and good performance via edge caching
directives = []string{
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net",
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net https://d3js.org",
"style-src 'self' 'unsafe-inline' https://unpkg.com",
"img-src 'self' data: blob:",
"font-src 'self' data:",
Expand Down
2 changes: 2 additions & 0 deletions gearbox/internal/framework/templates/layouts/base.templ
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ templ Base(title string, user *models.User, currentPath ...string) {
<script src="/static/js/vendor/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/static/css/vendor/tabulator.min.css"/>
<script src="/static/js/vendor/tabulator.min.js"></script>
<script src="/static/js/vendor/sortable.min.js"></script>
} else {
<!-- CDN JavaScript libraries -->
<script src="https://unpkg.com/htmx.org@1.9.10/dist/htmx.min.js"></script>
Expand All @@ -95,6 +96,7 @@ templ Base(title string, user *models.User, currentPath ...string) {
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.0/dist/css/tabulator.min.css"/>
<script src="https://unpkg.com/tabulator-tables@6.3.0/dist/js/tabulator.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
}
<style>
/* Base styles */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,6 @@ templ WidgetConfigModal() {

// DashboardEditorScript provides the editor JavaScript
templ DashboardEditorScript(dash *dashboard.Dashboard) {
<!-- Load Sortable.js module -->
<script type="module" src="/static/js/dashboard/sortable-loader.js"></script>
<!-- Load widget palette logic -->
<script src="/static/js/dashboard/palette.js" defer></script>
<!-- Load dashboard editor logic -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,22 +275,24 @@ templ DisabledEntitiesPage(user *models.User, entities []database.DisabledEntity
const checkboxes = document.querySelectorAll('.entity-checkbox:checked');
const bulkBar = document.getElementById('bulk-actions-bar');
const countSpan = document.getElementById('selected-count');
const selectAll = document.getElementById('select-all');

if (checkboxes.length > 0) {
bulkBar.classList.remove('hidden');
countSpan.textContent = checkboxes.length;
if (bulkBar) bulkBar.classList.remove('hidden');
if (countSpan) countSpan.textContent = checkboxes.length;
} else {
bulkBar.classList.add('hidden');
if (bulkBar) bulkBar.classList.add('hidden');
// Also uncheck "select all"
document.getElementById('select-all').checked = false;
if (selectAll) selectAll.checked = false;
}
}

// Clear all selections
function clearSelection() {
const checkboxes = document.querySelectorAll('.entity-checkbox');
checkboxes.forEach(cb => cb.checked = false);
document.getElementById('select-all').checked = false;
const selectAll = document.getElementById('select-all');
if (selectAll) selectAll.checked = false;
updateBulkActionsBar();
}

Expand Down
29 changes: 20 additions & 9 deletions gearbox/internal/framework/templates/pages/haproxy_settings.templ
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,26 @@ templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) {
Manage monitored box connections
</p>
</div>
<a
href="/settings/boxes/new"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Box
</a>
<div class="flex items-center space-x-3">
<a
href="/settings/admin/disabled-entities"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-5 h-5 mr-2 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
Disabled Entities
</a>
<a
href="/settings/boxes/new"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Box
</a>
</div>
</div>
if len(servers) == 0 {
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-8 text-center">
Expand Down
Loading