feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
+181
-9
@@ -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(¤tRole, ¤tActive); 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 {
|
||||
|
||||
Reference in new issue
Block a user