feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+181 -9
View File
@@ -21,7 +21,12 @@ type Store struct {
db *sql.DB
}
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
var (
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
)
type ManagedTarget struct {
Target model.Target
@@ -116,6 +121,9 @@ func (s *Store) migrate(ctx context.Context) error {
if err := s.applyMigrationV10(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV11(ctx, tx); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err
@@ -242,6 +250,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
return nil
}
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
return fmt.Errorf("apply schema v11: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
@@ -453,6 +478,22 @@ const schemaV10 = `
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
`
const schemaV11 = `
CREATE TABLE software_update_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
registry_username TEXT NOT NULL DEFAULT '',
token_ciphertext BLOB NOT NULL DEFAULT X'',
token_nonce BLOB NOT NULL DEFAULT X'',
latest_version TEXT NOT NULL DEFAULT '',
release_url TEXT NOT NULL DEFAULT '',
desired_version TEXT NOT NULL DEFAULT '',
last_checked_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
`
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
@@ -460,8 +501,20 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
}
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now())
return err
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
SELECT ?, ?, ?, 'admin', 1, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
if err != nil {
return err
}
created, err := result.RowsAffected()
if err != nil {
return err
}
if created != 1 {
return ErrAdminAlreadyConfigured
}
return nil
}
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
@@ -481,6 +534,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s
return nil
}
func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
}
@@ -531,18 +603,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
}
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var currentRole string
var currentActive int
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(&currentRole, &currentActive); err != nil {
return err
}
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
var activeAdmins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
return err
}
if activeAdmins <= 1 {
return ErrLastActiveAdmin
}
}
result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
// Administrators receive full access from their role. Removing stale member
// grants prevents hidden permissions from resurfacing after a later demotion.
if role == "admin" {
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
}
if currentRole != role || intBool(currentActive) != active {
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
return s.UpdatePassword(ctx, userID, passwordHash)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
@@ -587,6 +707,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection
return err
}
defer func() { _ = tx.Rollback() }()
var role string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
return err
}
if role == "admin" {
return ErrAdminAccessImmutable
}
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
@@ -702,8 +829,43 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled
return err
}
func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
var settings model.SoftwareUpdateSettings
var checkedAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
)
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
return settings, err
}
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
return err
}
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
return err
}
// RecordSoftwareUpdateFailure intentionally retains the last known release so
// a temporary Gitea outage never removes an already visible update reminder.
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
return err
}
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now())
return err
}
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
_, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
_, err := s.db.ExecContext(ctx, `UPDATE settings SET
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6),
last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
return err
}
@@ -1095,8 +1257,18 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error)
}
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`)
return err
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {