Ctrl K
ComunidadGuías…

server/internal/db/db.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package db

import (
	"context"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync/atomic"
	"time"

	"blackbox/server/internal/models"
	"blackbox/shared/types"
	"github.com/glebarez/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var memoryDBCounter atomic.Uint64

const legacyEntriesTimestampIndex = "idx_entries_timestamp"

func Init(path string) (*gorm.DB, error) {
	dsn := path
	if path == ":memory:" {
		dsn = fmt.Sprintf("file:blackbox-%d-%d?mode=memory&cache=shared", time.Now().UnixNano(), memoryDBCounter.Add(1))
	} else if err := ensureWritablePath(path); err != nil {
		return nil, err
	}
	dsn = appendSQLitePragma(dsn, "_pragma=foreign_keys(1)")
	database, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
		Logger:         logger.Default.LogMode(logger.Silent),
		TranslateError: true,
	})
	if err != nil {
		return nil, err
	}
	if err := database.Exec("PRAGMA foreign_keys=ON").Error; err != nil {
		return nil, fmt.Errorf("enable foreign keys: %w", err)
	}
	sqlDB, err := database.DB()
	if err != nil {
		return nil, err
	}
	sqlDB.SetMaxOpenConns(1)

	if path != ":memory:" {
		if err := database.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
			return nil, fmt.Errorf("set WAL mode: %w", err)
		}
		if err := database.Exec("PRAGMA busy_timeout=5000").Error; err != nil {
			return nil, fmt.Errorf("set busy timeout: %w", err)
		}
	}
	if err := database.AutoMigrate(
		&models.SetupState{},
		&models.User{},
		&models.InviteCode{},
		&models.OIDCState{},
		&models.OIDCProviderConfig{},
		&models.AppSetting{},
		&types.Entry{},
		&models.Node{},
		&models.EntryNote{},
		&models.Incident{},
		&models.IncidentEntry{},
		&models.SystemdUnitConfig{},
		&models.NotificationDest{},
		&models.NotificationLog{},
		&models.ExcludedTarget{},
		&models.AuditLog{},
		&models.WebhookDelivery{},
		&models.DataSourceInstance{},
	); err != nil {
		return nil, err
	}
	if err := ensureEntryIndexes(database); err != nil {
		return nil, err
	}
	if err := ensureExcludedTargetIndexes(database); err != nil {
		return nil, err
	}
	if err := ensureDataSourceConstraints(database); err != nil {
		return nil, err
	}
	if err := ensureDataSourceCleanupTriggers(database); err != nil {
		return nil, err
	}
	if err := EnsureEntriesFTS(database); err != nil {
		return nil, err
	}
	return database, nil
}

func ensureEntryIndexes(database *gorm.DB) error {
	if !database.Migrator().HasIndex(&types.Entry{}, "idx_entries_timestamp_id") {
		if err := database.Migrator().CreateIndex(&types.Entry{}, "idx_entries_timestamp_id"); err != nil {
			return err
		}
	}
	if database.Migrator().HasIndex(&types.Entry{}, legacyEntriesTimestampIndex) {
		if err := database.Migrator().DropIndex(&types.Entry{}, legacyEntriesTimestampIndex); err != nil {
			return err
		}
	}
	return nil
}

func ensureExcludedTargetIndexes(database *gorm.DB) error {
	return database.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_excluded_targets_type_lower_name
ON excluded_targets(type, lower(name));
`).Error
}

// Keep these singleton allowlists in sync with handlers.knownSourceTypes and
// the singleton enforcement in handlers.CreateSource.
func ensureDataSourceConstraints(database *gorm.DB) error {
	agentSingletons := quoteSQLStrings(models.GetAgentScopedSingletonSourceTypes())
	serverSingletons := quoteSQLStrings(models.GetServerScopedSingletonSourceTypes())
	if len(agentSingletons) > 0 {
		if err := database.Exec(fmt.Sprintf(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_source_singleton_agent
ON data_source_instances(type, node_id)
WHERE scope = 'agent' AND type IN (%s)
`, strings.Join(agentSingletons, ", "))).Error; err != nil {
			return err
		}
	}
	if len(serverSingletons) == 0 {
		return nil
	}
	return database.Exec(fmt.Sprintf(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_source_singleton_server
ON data_source_instances(type)
WHERE scope = 'server' AND type IN (%s)
`, strings.Join(serverSingletons, ", "))).Error
}

func appendSQLitePragma(dsn, pragma string) string {
	separator := "?"
	if strings.Contains(dsn, "?") {
		separator = "&"
	}
	return dsn + separator + pragma
}

func quoteSQLStrings(values []string) []string {
	quoted := make([]string, 0, len(values))
	for _, value := range values {
		escaped := strings.ReplaceAll(value, "'", "''")
		quoted = append(quoted, fmt.Sprintf("'%s'", escaped))
	}
	return quoted
}

func ensureDataSourceCleanupTriggers(database *gorm.DB) error {
	// TODO: This raw SQLite trigger must be translated explicitly when porting DB init to
	// Postgres/MySQL. Coverage lives in TestInit_EnforcesSingletonDataSourceUniqueness and
	// TestInit_CascadesAgentScopedDataSourcesWhenNodeDeleted, which expect agent-scoped rows
	// to be removed on node delete while server-scoped rows are retained.
	return database.Exec(`
CREATE TRIGGER IF NOT EXISTS trg_data_source_instances_delete_node
AFTER DELETE ON nodes
FOR EACH ROW
BEGIN
  DELETE FROM data_source_instances
  WHERE scope = 'agent' AND node_id = OLD.name;
END
`).Error
}

func ensureWritablePath(path string) error {
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o750); err != nil {
		return fmt.Errorf("database directory %s is not writable by uid=%d gid=%d: %w", dir, os.Getuid(), os.Getgid(), err)
	}

	probe, err := os.CreateTemp(dir, ".blackbox-write-test-*")
	if err != nil {
		return fmt.Errorf("database directory %s is not writable by uid=%d gid=%d: %w", dir, os.Getuid(), os.Getgid(), err)
	}
	probeName := probe.Name()
	if err := probe.Close(); err != nil {
		_ = os.Remove(probeName)
		return fmt.Errorf("database directory %s is not writable by uid=%d gid=%d: %w", dir, os.Getuid(), os.Getgid(), err)
	}
	if err := os.Remove(probeName); err != nil {
		return fmt.Errorf("database directory %s is not writable by uid=%d gid=%d: %w", dir, os.Getuid(), os.Getgid(), err)
	}

	if _, err := os.Stat(path); err == nil {
		file, err := os.OpenFile(path, os.O_RDWR, 0)
		if err != nil {
			return fmt.Errorf("database file %s is not writable by uid=%d gid=%d: %w", path, os.Getuid(), os.Getgid(), err)
		}
		if err := file.Close(); err != nil {
			return fmt.Errorf("database file %s is not writable by uid=%d gid=%d: %w", path, os.Getuid(), os.Getgid(), err)
		}
		return nil
	} else if !os.IsNotExist(err) {
		return fmt.Errorf("database file %s could not be checked by uid=%d gid=%d: %w", path, os.Getuid(), os.Getgid(), err)
	}

	file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
	if err != nil {
		return fmt.Errorf("database file %s could not be created by uid=%d gid=%d: %w", path, os.Getuid(), os.Getgid(), err)
	}
	if err := file.Close(); err != nil {
		return fmt.Errorf("database file %s could not be prepared by uid=%d gid=%d: %w", path, os.Getuid(), os.Getgid(), err)
	}

	return nil
}

func deleteExpiredOIDCStates(database *gorm.DB) {
	result := database.Delete(&models.OIDCState{}, "expires_at < ?", time.Now())
	if result.Error != nil {
		log.Printf("oidc state sweep error: %v", result.Error)
	}
}

func sweepExpiredOIDCStates(ctx context.Context, database *gorm.DB) {
	deleteExpiredOIDCStates(database)

	ticker := time.NewTicker(15 * time.Minute)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			deleteExpiredOIDCStates(database)
		}
	}
}

func StartOIDCStateSweeper(ctx context.Context, database *gorm.DB) {
	go sweepExpiredOIDCStates(ctx, database)
}

// notificationLogRetention is longer than the largest rate-limit window (24h)
// so sliding-window counts always have their backing rows.
const notificationLogRetention = 48 * time.Hour

// deleteOldNotificationLogs prunes notification log rows older than the
// retention horizon, while preserving pending held rows (decision = "held" with
// no flushed_at) that a deferred-digest flush still needs.
func deleteOldNotificationLogs(database *gorm.DB) {
	cutoff := time.Now().Add(-notificationLogRetention)
	result := database.Where(
		"created_at < ? AND (decision != ? OR flushed_at IS NOT NULL)", cutoff, "held",
	).Delete(&models.NotificationLog{})
	if result.Error != nil {
		log.Printf("notification log sweep error: %v", result.Error)
	}
}

func sweepOldNotificationLogs(ctx context.Context, database *gorm.DB) {
	deleteOldNotificationLogs(database)

	ticker := time.NewTicker(time.Hour)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			deleteOldNotificationLogs(database)
		}
	}
}

func StartNotificationLogSweeper(ctx context.Context, database *gorm.DB) {
	go sweepOldNotificationLogs(ctx, database)
}