Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
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
33 changes: 18 additions & 15 deletions gearbox/internal/framework/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -1208,17 +1208,17 @@ func (d *DB) SetUserSessionToken(userID, sessionToken, ip, userAgent string) err
}

// 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. Also updates last activity time asynchronously.
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 +1233,17 @@ func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) {
return false, nil
}

// Token matches - update last activity
now := time.Now()
_, err = d.db.Exec(`
UPDATE users
SET session_last_activity = ?
WHERE id = ?`, now, userID)

return true, err
// Token matches - update last activity asynchronously to avoid blocking
go func() {
d.mu.Lock()
defer d.mu.Unlock()
_, _ = d.db.Exec(`
UPDATE users
SET session_last_activity = ?
WHERE id = ?`, time.Now(), userID)
}()
Comment thread
sarg3nt marked this conversation as resolved.
Outdated
Comment thread
sarg3nt marked this conversation as resolved.

return true, nil
}

// 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: 2 additions & 0 deletions gearbox/internal/framework/templates/layouts/base.templ
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,8 @@ templ Base(title string, user *models.User, currentPath ...string) {
<script src="/static/js/utils/api.js" defer></script>
<script src="/static/js/common/page-header.js" defer></script>
<script src="/static/js/common/box-selector.js" defer></script>
<!-- Sortable.js for sidebar nav reordering -->
<script type="module" src="/static/js/dashboard/sortable-loader.js"></script>
</head>
<body class="h-full bg-gray-100 dark:bg-slate-900">
@ui.CollapsibleRestoreScript()
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
17 changes: 0 additions & 17 deletions gearbox/internal/framework/templates/pages/user_pages.templ
Original file line number Diff line number Diff line change
Expand Up @@ -891,23 +891,6 @@ templ Settings(user *models.User, perms *models.UserPermissions) {
</a>


<!-- Disabled Entities - shown to admins or users with manage permission on backends -->
if user.IsAdmin() || perms.HasPermission(models.ComponentDisabledEntities, models.PermissionManage) {
<a href="/settings/admin/disabled-entities" class="block bg-white dark:bg-slate-800 p-6 rounded-lg shadow hover:shadow-lg transition-shadow">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600 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>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Disabled Entities</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage disabled backends and services</p>
</div>
</div>
</a>
}

<!-- Plugins - shown to admins or users with manage permission on plugins -->
if user.IsAdmin() || perms.HasPermission(models.ComponentPlugins, models.PermissionManage) {
<a href="/settings/plugins" class="block bg-white dark:bg-slate-800 p-6 rounded-lg shadow hover:shadow-lg transition-shadow">
Expand Down
2 changes: 2 additions & 0 deletions gearbox/internal/plugins/dashboard/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ func (h *Handlers) OverviewPage(w http.ResponseWriter, r *http.Request) {
// integrationPathFromName maps an integration name to its URL path.
func integrationPathFromName(name string) string {
switch name {
case "haproxy":
return "/haproxy"
case "metrics":
return "/history"
case "logs":
Expand Down
16 changes: 16 additions & 0 deletions gearbox/static/js/traffic/traffic-visualization.js
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,15 @@ function updateNetworkVisualization(data) {
}

function createVisualization(newNodeData, newLinkData, width, height) {
// Guard: ensure SVG group is initialized before creating visualization
if (!g) {
initVisualization();
if (!g) {
console.warn('Cannot create visualization: SVG container not available');
return;
}
}

// Convert maps to arrays
nodes = Array.from(newNodeData.values()).map(d => {
const node = { ...d };
Expand Down Expand Up @@ -1471,6 +1480,13 @@ function createVisualization(newNodeData, newLinkData, width, height) {
}

function updateVisualizationData(newNodeData, newLinkData, width, height) {
// Guard: if g (SVG group) hasn't been initialized, fall back to full creation
if (!g) {
isFirstRender = true;
createVisualization(newNodeData, newLinkData, width, height);
return;
}

// Track what nodes/links exist now
const currentNodeIds = new Set(nodes.map(n => n.id));
const newNodeIds = new Set(newNodeData.keys());
Expand Down
Loading