ff058a4f10
Foundation only, no version bump -- nothing customer-visible changes yet. The
offer UI (Part 3), the degraded banner (Part 4) and the controller half of the
absent-target signal (Part 5) are NOT here; they are tracked as E-2 in
OPEN-ITEMS.md so this cannot become a sixth "seam built but never wired". The
fifth was found by E-2's own Phase 0: NotifyStorageDisconnected is defined and
called nowhere, so a drive going absent emits no event at all today.
StoragePath gains BackupTarget bool -- the sibling role to Schedulable/
IsDefault/Kind. It is INTENT, not truth: the authority is the agent's
backup.local_backup_target, and truth is read from GET /backup/tiers. This
records what the customer ASSIGNED so the controller can render the state,
notice the drive going absent, and detect drift.
Invariants, each pinned by a test asserting the CONSEQUENCE not the mechanism:
- a drive NEVER acquires the role by appearing (registration does not set it)
- exactly one carrier; assigning moves rather than duplicates
- sticky: a new bigger/faster drive does not steal an assigned target
- an absent target STAYS assigned -- clearing on disconnect would be a silent
retarget by omission ("no target configured" instead of "drive missing")
- a network share is refused (R-108 risk model; the role is about LOCAL disk
failure)
Red-proof C: adding auto-elevation to AddStoragePath fails
TestRegisteringDrivesNeverAssignsTheBackupTarget with
registering drives assigned the backup target "/mnt/hdd_1"
Attributes may suggest and refuse the absurd, never select: demo-felhom's backup
drive is an external USB HDD and BOTH demo boxes report removable=0, so a
transport rule disqualifies the reference drive and a removable rule finds no
candidate at all.
Green gate: build + vet + test rc=0, run separately from this commit.
1786 lines
66 KiB
Go
1786 lines
66 KiB
Go
package settings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
)
|
|
|
|
// Settings holds customer-modifiable overrides and cached state.
|
|
// Persisted as a single JSON file (settings.json) in the data directory.
|
|
type Settings struct {
|
|
mu sync.RWMutex `json:"-"`
|
|
path string `json:"-"`
|
|
log *log.Logger `json:"-"`
|
|
debug bool `json:"-"`
|
|
|
|
// LoadWarning is set (non-empty, Hungarian) when Load recovered from a corrupt settings.json —
|
|
// surfaced to the dashboard as a persistent banner. Not persisted.
|
|
LoadWarning string `json:"-"`
|
|
|
|
// Auth
|
|
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
|
|
|
|
// Guest launcher share (v0.165.0). LauncherShareToken is the ≥160-bit URL capability token that
|
|
// serves the read-only guest launcher at /s/<token>; empty means sharing is OFF (there is no
|
|
// separate enabled flag — an empty token matches nothing). LauncherSharePasswordHash is an
|
|
// OPTIONAL bcrypt hash for a per-share password, ALWAYS SEPARATE from the admin PasswordHash above.
|
|
// The token is a secret and must never be logged.
|
|
LauncherShareToken string `json:"launcher_share_token,omitempty"`
|
|
LauncherSharePasswordHash string `json:"launcher_share_password_hash,omitempty"`
|
|
|
|
// Customer-claim arc (v0.122.0, F-4). Claimed is SET-ONLY (a claim or reset completed at
|
|
// least once — never cleared). ClaimCode* cache the freshest hub-delivered code state (report
|
|
// ACK; beats controller.yaml when its generation is newer). ClaimConsumedGeneration records
|
|
// the last code generation successfully consumed — a code of a consumed generation is dead
|
|
// even if its hash still matches (single-use).
|
|
Claimed bool `json:"claimed,omitempty"`
|
|
ClaimCodeHash string `json:"claim_code_hash,omitempty"`
|
|
ClaimCodeGeneration int `json:"claim_code_generation,omitempty"`
|
|
ClaimCodeIssuedAt string `json:"claim_code_issued_at,omitempty"` // RFC3339
|
|
ClaimConsumedGeneration int `json:"claim_consumed_generation,omitempty"`
|
|
|
|
// Notification preferences (Phase 2 — define struct now, leave empty)
|
|
Notifications *NotificationPrefs `json:"notifications,omitempty"`
|
|
|
|
// OffboxEnlargeNoticeSeeded (v0.135.0) guards the ONE-TIME seed of offbox_enlarge_blocked into an
|
|
// existing customer's stored prefs. Persisted so a later opt-out sticks (the 3a-fix getter append
|
|
// re-enabled it on every read — this replaces it).
|
|
OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"`
|
|
|
|
// Cached state
|
|
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
|
|
|
// Per-app backup preferences
|
|
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
|
|
|
// Customer-configurable backup-window start "HH:MM" (v0.168.0). "" = use controller.yaml
|
|
// db_dump_schedule (then the "02:30" default). Every nightly leg derives from this at fixed
|
|
// offsets; overrides yaml when a valid value is present (mirrors PasswordHash precedence).
|
|
BackupWindowStart string `json:"backup_window_start,omitempty"`
|
|
|
|
// Storage paths registry
|
|
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
|
|
|
|
// Cross-drive restic repo password (auto-generated on first use)
|
|
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
|
|
|
|
// Last-seen guest boot-id (intermediary-mount model): persisted so the controller can detect a guest
|
|
// reboot across its own restart (it restarts with the guest) and deterministically recreate
|
|
// drive-backed apps once the agent re-propagates the drive.
|
|
LastGuestBootID string `json:"last_guest_boot_id,omitempty"`
|
|
|
|
// Hub verification state
|
|
HubVerified bool `json:"hub_verified,omitempty"`
|
|
HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339
|
|
HubLastCheck string `json:"hub_last_check,omitempty"` // RFC3339
|
|
|
|
// AppliedConfigVersion is the hub config_version this controller has last pulled + applied
|
|
// (v0.26.0 pull-based config-refresh). 0 = none recorded yet → the first report ACK records the
|
|
// baseline without restarting. A change vs. the ACK triggers a re-pull + self-restart.
|
|
AppliedConfigVersion int `json:"applied_config_version,omitempty"`
|
|
|
|
// Recovery credentials (saved from setup wizard input)
|
|
RetrievalPassword string `json:"retrieval_password,omitempty"`
|
|
|
|
// Pending events (queued for next Hub push)
|
|
PendingEvents []PendingEvent `json:"pending_events,omitempty"`
|
|
|
|
// Geo-restriction settings (Cloudflare WAF rules)
|
|
GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"`
|
|
|
|
// App-to-app integration state (e.g., "onlyoffice:filebrowser" → state)
|
|
Integrations map[string]IntegrationState `json:"integrations,omitempty"`
|
|
|
|
// AppEmail is the global app-email (SMTP relay) toggle. When on, deployed apps with an
|
|
// smtp_mapping can send mail via the in-controller shim → hub → Resend. Relay-only:
|
|
// no BYO host/port/user/pass (that escape hatch is deferred).
|
|
AppEmail *AppEmail `json:"app_email,omitempty"`
|
|
|
|
// Offbox is the off-box (NAS) restic-SFTP backup target (Part B). One per box. No secrets here —
|
|
// the repo password + SSH key are 0600 files in the data dir.
|
|
Offbox *OffboxTarget `json:"offbox,omitempty"`
|
|
|
|
// SMB holds the LAN network-sharing (Samba) feature state (R-7 slice 1). The household SMB
|
|
// password is NEVER stored here — only UserSet records that one exists (it lives in the samba
|
|
// container's passdb volume). nil = feature never touched (disabled). See internal/settings/smb.go.
|
|
SMB *SMBSettings `json:"smb,omitempty"`
|
|
// SMBShares is the ordered registry of exported folders. Each Path is an absolute host path under
|
|
// a registered storage root; the smb.conf + compose + backup classification all ride this list.
|
|
SMBShares []SMBShare `json:"smb_shares,omitempty"`
|
|
}
|
|
|
|
// AppEmail holds the global app-email toggle and an optional household display name.
|
|
type AppEmail struct {
|
|
Enabled bool `json:"enabled"`
|
|
FromName string `json:"from_name,omitempty"` // optional household display name for the From line
|
|
}
|
|
|
|
// IntegrationState holds the state of a provider:target integration pair.
|
|
type IntegrationState struct {
|
|
Enabled bool `json:"enabled"`
|
|
EnabledAt string `json:"enabled_at,omitempty"` // RFC3339
|
|
Status string `json:"status,omitempty"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
// AppBackupPrefs holds per-app backup toggle state.
|
|
type AppBackupPrefs struct {
|
|
// Existing: includes app data in nightly restic (same drive)
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// Cross-drive backup to secondary storage
|
|
CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
|
|
|
|
// Offbox: include this app's recovery unit + DB dumps in the off-box (NAS) restic-SFTP backup
|
|
// (Part B — the "1 off-site" leg of 3-2-1, distinct from the local cross-drive copy and PBS whole-CT).
|
|
Offbox bool `json:"offbox,omitempty"`
|
|
}
|
|
|
|
// OffboxTarget configures the single off-box (NAS) backup destination: an encrypted restic repo reached
|
|
// over SFTP (Part B). It holds NO secrets — the repo password + SSH private key live in 0600 files in the
|
|
// controller data dir (off-box of the secrets rides DR via the PBS whole-CT snapshot of the rootfs); the
|
|
// known-host key is pinned out-of-band. Runtime status is persisted for the UI.
|
|
type OffboxTarget struct {
|
|
Enabled bool `json:"enabled"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"` // default 22
|
|
User string `json:"user"`
|
|
RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo
|
|
Schedule string `json:"schedule"` // "daily" | "manual"
|
|
// QuotaGB is the shared-model SOFT quota (SLICE 4), mapped from the hub descriptor by the
|
|
// apply-bridge. 0 = no soft limit (dedicated boxes are Hetzner-enforced; manual targets unset).
|
|
// Felhom-enforced: at ≥100% NEW backup runs are refused (prune/restore never are); ≥80% warns.
|
|
QuotaGB int `json:"quota_gb,omitempty"`
|
|
|
|
// Runtime status (written by the off-box runner; never holds a secret).
|
|
LastRun string `json:"last_run,omitempty"` // RFC3339
|
|
LastStatus string `json:"last_status,omitempty"` // "ok" | "error" | "running"
|
|
// LastSuccess (R-100) is the RFC3339 stamp of the last run that actually SUCCEEDED.
|
|
//
|
|
// IT EXISTS BECAUSE LastRun RECORDS AN ATTEMPT, NOT A RESULT. LastRun is written
|
|
// unconditionally at the end of every run, including failures, so "how long since LastRun" answers
|
|
// "how long since we last TRIED" — which is not the question any freshness verdict is asking. The
|
|
// hub's OffsiteChecker asked exactly that question of exactly that field, so a tier failing on
|
|
// every run read as perfectly fresh forever.
|
|
//
|
|
// Written ONLY on the success branch. Never cleared by a failure: a tier that succeeded on Monday
|
|
// and has failed every night since must keep Monday's stamp, because that stamp is precisely what
|
|
// makes the staleness threshold elapse. Clearing it on failure would restore the bug in mirror
|
|
// image (an instantly-stale tier on the first blip — the F-A1 noise path).
|
|
LastSuccess string `json:"last_success,omitempty"` // RFC3339
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastDuration string `json:"last_duration,omitempty"`
|
|
RepoSizeHuman string `json:"repo_size_human,omitempty"`
|
|
// RepoSizeBytes (SLICE 4) is the machine-readable repo size from `restic stats` — the soft-quota
|
|
// gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe).
|
|
RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"`
|
|
SnapshotCount int `json:"snapshot_count,omitempty"`
|
|
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
|
|
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
|
|
LastWarning string `json:"last_warning,omitempty"`
|
|
// EnlargedBlocked (3a) lists the apps whose ENLARGED (mandatory-userdata) offsite push was refused
|
|
// by the pre-push quota gate on the last run — their unit-only push still succeeded. Replaced each
|
|
// OK run (sorted; empty clears). Drives the per-app "config+DB only" note on /backups/remote and
|
|
// the edge-triggered enlarge-blocked notification. Not a secret (app-name list).
|
|
EnlargedBlocked []string `json:"enlarged_blocked,omitempty"`
|
|
// Shares offsite leg status (R-7b). The offsite run gained a SIBLING shares source that pushes the
|
|
// „Felhőmentés"-marked SMB shares plus the share-definition manifest under the reserved `_shares`
|
|
// tag. These three fields let the „Megosztás" page state per-tier truth instead of inferring it
|
|
// from the app-wide LastStatus. SharesLastCount is the number of share folders in the push (0 = a
|
|
// definitions-only push, e.g. quota-degraded or no share is marked for the cloud). Not secrets.
|
|
SharesLastRun string `json:"shares_last_run,omitempty"` // RFC3339
|
|
SharesLastStatus string `json:"shares_last_status,omitempty"` // "ok" | "error" | "blocked" | "skipped"
|
|
SharesLastCount int `json:"shares_last_count,omitempty"`
|
|
// EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending"
|
|
// |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run
|
|
// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
|
|
// offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here.
|
|
EscrowState string `json:"escrow_state,omitempty"`
|
|
// CeremonyCompletedAt (v0.138.0) is the RFC3339 stamp of the last successful escrow ceremony
|
|
// (recovery-code claim) taken while EscrowState is still "pending". It drives the "awaiting hub
|
|
// confirmation" card on /backups/remote during the report-cycle gap between the ceremony and the
|
|
// hub-verified pending→escrowed flip (report.EscrowAutoConfirmer). Zeroed by that flip (and the
|
|
// deprecated manual confirm). Persisted, so it survives a controller restart mid-wait. Not a secret.
|
|
CeremonyCompletedAt string `json:"ceremony_completed_at,omitempty"`
|
|
|
|
// RepoState (v0.142.0, offsite continuity) classifies the offsite REPO — "" normal | "orphaned".
|
|
// ORPHANED = the remote repo exists but was keyed under a passphrase this controller no longer has
|
|
// (the reinstall/recreated-volume shape: `restic cat config` → "wrong password or no key found").
|
|
// While orphaned, scheduled runs SKIP (one event, not nightly) and the remote page shows the orphan
|
|
// card instead of the raw restic error; a reset (move-aside + init) clears it. Not a secret.
|
|
RepoState string `json:"repo_state,omitempty"`
|
|
// OrphanedAt is the RFC3339 stamp of the orphan detection (drives the card copy).
|
|
OrphanedAt string `json:"orphaned_at,omitempty"`
|
|
// OrphanedRenamedTo records the move-aside path of the last reset (e.g. <repo>.orphaned-20260717),
|
|
// so the card/log can name where the old (recovery-code-recoverable) history was set aside.
|
|
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
|
|
}
|
|
|
|
// CrossDriveBackup configures per-app backup to a secondary drive.
|
|
type CrossDriveBackup struct {
|
|
Enabled bool `json:"enabled"`
|
|
Method string `json:"method"` // "rsync" or "restic"
|
|
DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
|
|
Schedule string `json:"schedule"` // "daily", "weekly", "manual"
|
|
|
|
// Runtime state (updated by backup runner, persisted for display)
|
|
LastRun string `json:"last_run,omitempty"` // RFC3339
|
|
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
|
LastError string `json:"last_error,omitempty"`
|
|
// LastSuccess (R-101) is the last COPY that actually succeeded — the only timestamp a customer may
|
|
// be shown as evidence that a copy exists. LastRun above is written on failure too
|
|
// (recordTier2Failure), so it records an ATTEMPT; presenting it as „Legutóbbi másolat" in the
|
|
// restore dialog told a customer a copy existed from last night when last night had failed.
|
|
// Same rule and shape as the offsite tier's anchor — see backup.offboxAnchorAfterRun.
|
|
LastSuccess string `json:"last_success,omitempty"` // RFC3339
|
|
// SuccessTracked distinguishes "this row predates the anchor" from "this row has an anchor and it
|
|
// is empty, i.e. nothing has succeeded". Without it the two are indistinguishable (both are
|
|
// LastSuccess=="") and every pre-existing row on the fleet would render as never-succeeded on the
|
|
// deploy — all 7 rows on the two demo boxes were in exactly that state. Set by every runner write.
|
|
SuccessTracked bool `json:"success_tracked,omitempty"`
|
|
LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian)
|
|
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
|
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
|
|
|
// Customer preference (set from the per-app Tier-2 config panel; PRESERVED across the runner's
|
|
// status writes). UserDisabled turns Tier 2 off for this app; PreferredTarget pins a chosen
|
|
// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
|
|
UserDisabled bool `json:"user_disabled,omitempty"`
|
|
PreferredTarget string `json:"preferred_target,omitempty"`
|
|
}
|
|
|
|
// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe
|
|
// lifecycle (agent /disks). A NETWORK share is a NAS (Part A2) proxied to the agent /netstorage — a
|
|
// DISTINCT class with NO drive lifecycle (no eject/decommission/migrate/wipe/SMART; remove is the only
|
|
// lifecycle action). An empty Kind means "drive" (back-compat with already-persisted paths).
|
|
const (
|
|
StorageKindDrive = "drive"
|
|
StorageKindNetwork = "network"
|
|
)
|
|
|
|
// NetworkMountRoot is the in-guest path under which the agent propagates NAS shares (mirrors the agent's
|
|
// /mnt/felhom-drives bind root). A registered network path is NetworkMountRoot + "/" + <share name>.
|
|
const NetworkMountRoot = "/mnt/felhom-drives"
|
|
|
|
// StoragePath represents a registered external storage location.
|
|
type StoragePath struct {
|
|
Path string `json:"path"` // e.g., "/mnt/hdd_1" (drive) or "/mnt/felhom-drives/<name>" (network)
|
|
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
|
|
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
|
|
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
|
|
AddedAt string `json:"added_at"` // RFC3339
|
|
Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected
|
|
DisconnectedAt string `json:"disconnected_at,omitempty"` // RFC3339 timestamp of disconnect detection
|
|
StoppedStacks []string `json:"stopped_stacks,omitempty"` // stacks auto-stopped on disconnect
|
|
Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another
|
|
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
|
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
|
|
|
|
// Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network
|
|
// share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it.
|
|
Kind string `json:"kind,omitempty"`
|
|
|
|
// BackupTarget (E-2) marks the drive the WHOLE-GUEST vzdump is written to — the sibling role to
|
|
// Schedulable/IsDefault/Kind.
|
|
//
|
|
// THIS FIELD IS INTENT, NOT TRUTH. The authority is the agent's `backup.local_backup_target` in
|
|
// agent.json; this records which drive the customer ASSIGNED so the controller can (a) render the
|
|
// degraded/healthy state, (b) notice when the assigned drive goes absent, and (c) detect drift from
|
|
// what the agent is actually doing. Read the agent's `GET /backup/tiers` for truth; never assume
|
|
// this flag and the agent agree.
|
|
//
|
|
// THE RULE THIS EXISTS TO ENFORCE: a drive NEVER acquires this role by appearing. Nothing in the
|
|
// registration path sets it — only an explicit customer choice through SetBackupTarget does.
|
|
// Attributes (transport, removable, size) may SUGGEST a candidate and may refuse the absurd; they
|
|
// may never select one. The reference hardware settles it: demo-felhom's backup drive is an
|
|
// external USB HDD and BOTH demo boxes' drives report removable=0, so a transport rule would
|
|
// disqualify the reference drive and a removable rule would find no candidate at all.
|
|
//
|
|
// Exactly one path may carry it (SetBackupTarget clears the others), and it is STICKY: it does not
|
|
// move because a new drive appeared, and it is never cleared just because the drive is absent —
|
|
// an absent target must stay assigned and ALARM (E-2 Part 5), because silently retargeting is how
|
|
// a backup lands somewhere nobody expects. See [[storage-authz-redesign]] for the role vocabulary.
|
|
BackupTarget bool `json:"backup_target,omitempty"`
|
|
// Network-storage descriptors (Kind=="network" only; mirror the agent A1 add request). NO password
|
|
// is stored — the SMB credential is passed through to the agent at add-time and never persisted here.
|
|
Protocol string `json:"protocol,omitempty"` // nfs | smb
|
|
Server string `json:"server,omitempty"`
|
|
Export string `json:"export,omitempty"`
|
|
MappedUID int `json:"mapped_uid,omitempty"`
|
|
MappedGID int `json:"mapped_gid,omitempty"`
|
|
}
|
|
|
|
// IsNetwork reports whether this is a NAS network-storage path (vs a physical drive). The drive
|
|
// lifecycle (eject/decommission/migrate/wipe/SMART) must NEVER be applied to a network path.
|
|
func (p StoragePath) IsNetwork() bool { return p.Kind == StorageKindNetwork }
|
|
|
|
// NotificationPrefs holds customer notification preferences.
|
|
type NotificationPrefs struct {
|
|
Email string `json:"email,omitempty"`
|
|
EnabledEvents []string `json:"enabled_events,omitempty"`
|
|
CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6
|
|
}
|
|
|
|
// DefaultEnabledEvents are the events enabled by default for new customers.
|
|
var DefaultEnabledEvents = []string{
|
|
"backup_failed",
|
|
"db_dump_failed",
|
|
"disk_warning",
|
|
"disk_critical",
|
|
"storage_disconnected",
|
|
"node_down",
|
|
"health_critical",
|
|
"expected_backup_missed",
|
|
"expected_dbdump_missed",
|
|
"offbox_enlarge_blocked", // 3a-fix (warning-class): remote enlargement refused by the quota gate
|
|
}
|
|
|
|
// PendingEvent is an event queued for the next Hub push cycle.
|
|
type PendingEvent struct {
|
|
EventType string `json:"event_type"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
Details string `json:"details"` // JSON string
|
|
CreatedAt string `json:"created_at"` // RFC3339
|
|
}
|
|
|
|
// GeoRestriction holds global and per-app geo-restriction settings.
|
|
type GeoRestriction struct {
|
|
Enabled bool `json:"enabled"`
|
|
AllowedCountries []string `json:"allowed_countries"`
|
|
AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"`
|
|
|
|
// Sync state (updated by geo sync manager)
|
|
LastSync string `json:"last_sync,omitempty"` // RFC3339
|
|
LastSyncError string `json:"last_sync_error,omitempty"`
|
|
ZoneID string `json:"zone_id,omitempty"` // cached Cloudflare zone ID
|
|
RulesetID string `json:"ruleset_id,omitempty"` // cached Cloudflare ruleset ID
|
|
}
|
|
|
|
// AppGeoOverride holds per-app country override.
|
|
type AppGeoOverride struct {
|
|
AllowedCountries []string `json:"allowed_countries"`
|
|
}
|
|
|
|
// DBValidationCache holds cached DB dump validation results.
|
|
type DBValidationCache struct {
|
|
ValidatedAt string `json:"validated_at"` // RFC3339
|
|
TableCount int `json:"table_count"`
|
|
HasHeader bool `json:"has_header"`
|
|
Error string `json:"error,omitempty"`
|
|
// M18: Size + ModTime let ListDumpFiles skip the expensive line-by-line re-validation on every
|
|
// ~5-min scheduler cycle when the dump file is unchanged. A cache entry is a HIT only when both the
|
|
// file size and (RFC3339, second-precision) modtime match the on-disk file.
|
|
Size int64 `json:"size,omitempty"`
|
|
ModTime string `json:"mod_time,omitempty"` // RFC3339 (UTC)
|
|
}
|
|
|
|
// SetDebug enables or disables debug logging for settings operations.
|
|
func (s *Settings) SetDebug(debug bool) {
|
|
s.debug = debug
|
|
}
|
|
|
|
// Load reads settings from the given file path.
|
|
// Returns empty Settings if the file doesn't exist (not an error).
|
|
func Load(path string, logger *log.Logger) (*Settings, error) {
|
|
s := &Settings{
|
|
path: path,
|
|
log: logger,
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
logger.Printf("[INFO] [settings] No settings.json found, using defaults")
|
|
return s, nil
|
|
}
|
|
return nil, fmt.Errorf("reading settings file: %w", err)
|
|
}
|
|
|
|
if err := json.Unmarshal(data, s); err != nil {
|
|
// CORRUPT primary — never crash-loop. Recover from the last-known-good .bak; failing that,
|
|
// preserve the corrupt file for forensics and start on safe defaults (recoverable: an empty
|
|
// PasswordHash falls back to controller.yaml; the storage registry re-discovers on startup).
|
|
logger.Printf("[ERROR] [settings] primary settings corrupt (%v) — attempting recovery from .bak", err)
|
|
if bak, berr := os.ReadFile(path + ".bak"); berr == nil {
|
|
s2 := &Settings{path: path, log: logger}
|
|
if json.Unmarshal(bak, s2) == nil {
|
|
logger.Printf("[WARN] [settings] recovered settings from .bak; re-promoting to primary")
|
|
_ = os.WriteFile(path, bak, 0644) // best-effort promote
|
|
s2.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból"
|
|
s2.migrateResticToRsync()
|
|
s2.seedOffboxEnlargeNotice()
|
|
return s2, nil
|
|
}
|
|
}
|
|
corrupt := fmt.Sprintf("%s.corrupt-%d", path, time.Now().Unix())
|
|
_ = os.Rename(path, corrupt)
|
|
logger.Printf("[ERROR] [settings] settings unrecoverable — preserved as %s; starting with safe defaults", corrupt)
|
|
return &Settings{path: path, log: logger, LoadWarning: "settings.json sérült és helyreállíthatatlan — alapértelmezett beállítások"}, nil
|
|
}
|
|
|
|
logger.Printf("[INFO] [settings] Loaded settings from %s", path)
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] loaded: storage_paths=%d integrations=%d pending_events=%d",
|
|
len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
|
|
}
|
|
s.migrateResticToRsync()
|
|
s.seedOffboxEnlargeNotice()
|
|
return s, nil
|
|
}
|
|
|
|
// seedOffboxEnlargeNotice runs ONCE (guarded by OffboxEnlargeNoticeSeeded): an existing customer whose
|
|
// stored prefs predate offbox_enlarge_blocked (v0.135.0) gets it appended enabled — they could not have
|
|
// deliberately disabled a type that did not exist. Persisted, so a LATER opt-out sticks (unlike the
|
|
// 3a-fix getter append, which re-enabled it on every read). Fresh customers (nil prefs) get the type via
|
|
// DefaultEnabledEvents; the seed only touches customers with an explicit stored EnabledEvents list.
|
|
func (s *Settings) seedOffboxEnlargeNotice() {
|
|
if s.OffboxEnlargeNoticeSeeded {
|
|
return
|
|
}
|
|
s.OffboxEnlargeNoticeSeeded = true
|
|
if s.Notifications != nil && s.Notifications.EnabledEvents != nil {
|
|
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "offbox_enlarge_blocked")
|
|
}
|
|
if err := s.save(); err != nil && s.log != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save offbox-enlarge-notice seed: %v", err)
|
|
}
|
|
}
|
|
|
|
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
|
|
// Called once during Load() before the mutex is exposed.
|
|
func (s *Settings) migrateResticToRsync() {
|
|
changed := false
|
|
for name, prefs := range s.AppBackup {
|
|
if prefs.CrossDrive != nil && prefs.CrossDrive.Method == "restic" {
|
|
prefs.CrossDrive.Method = "rsync"
|
|
s.AppBackup[name] = prefs
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Migrated cross-drive backup for %s from restic to rsync", name)
|
|
}
|
|
changed = true
|
|
}
|
|
}
|
|
if changed {
|
|
if err := s.save(); err != nil && s.log != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save restic→rsync migration: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save writes settings to disk atomically (write to .tmp, rename).
|
|
// Caller must hold the write lock or call this from a method that does.
|
|
func (s *Settings) save() error {
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
if s.log != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
|
|
}
|
|
return fmt.Errorf("marshaling settings: %w", err)
|
|
}
|
|
|
|
tmpPath := s.path + ".tmp"
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
|
if s.log != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
|
|
}
|
|
return fmt.Errorf("creating settings dir: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
|
os.Remove(tmpPath) // clean up partial file
|
|
if s.log != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
|
|
}
|
|
return fmt.Errorf("writing tmp settings: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, s.path); err != nil {
|
|
os.Remove(tmpPath)
|
|
if s.log != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
|
|
}
|
|
return fmt.Errorf("renaming settings file: %w", err)
|
|
}
|
|
|
|
// last-known-good: written AFTER the primary rename succeeds, so .bak only ever holds settings that
|
|
// parsed + saved cleanly. Best-effort — a failed .bak must NOT fail the save.
|
|
if err := os.WriteFile(s.path+".bak", data, 0644); err != nil && s.log != nil {
|
|
s.log.Printf("[WARN] [settings] could not write .bak: %v", err)
|
|
}
|
|
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
|
|
}
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Settings saved")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPasswordHash returns the stored password hash (thread-safe).
|
|
func (s *Settings) GetPasswordHash() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.PasswordHash
|
|
}
|
|
|
|
// SetPasswordHash updates the password hash and saves to disk.
|
|
func (s *Settings) SetPasswordHash(hash string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.PasswordHash = hash
|
|
return s.save()
|
|
}
|
|
|
|
// ── Guest launcher share (v0.165.0) ──────────────────────────────────────────────
|
|
|
|
// ── Backup window (v0.168.0) ─────────────────────────────────────────────────────
|
|
|
|
// GetBackupWindowStart returns the customer-set backup-window start "HH:MM" ("" = fall back to
|
|
// controller.yaml, then the default — resolve via backupwindow.EffectiveWindow, never in isolation).
|
|
func (s *Settings) GetBackupWindowStart() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.BackupWindowStart
|
|
}
|
|
|
|
// SetBackupWindowStart stores (or clears, on "") the backup-window start and saves. The caller
|
|
// validates the HH:MM format first (the scheduler/backupwindow gate) and fans the change out to the
|
|
// three daily legs via UpdateDaily — this only persists the single source-of-truth value.
|
|
func (s *Settings) SetBackupWindowStart(start string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.BackupWindowStart = start
|
|
return s.save()
|
|
}
|
|
|
|
// GetLauncherShareToken returns the guest-launcher capability token ("" = sharing disabled).
|
|
func (s *Settings) GetLauncherShareToken() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.LauncherShareToken
|
|
}
|
|
|
|
// SetLauncherShareToken stores (or clears, on "") the guest-launcher token and saves. A new value
|
|
// rotates the link; because the guest gate cookie is bound to the token, any outstanding cookie is
|
|
// invalidated automatically. Never log the value.
|
|
func (s *Settings) SetLauncherShareToken(token string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.LauncherShareToken = token
|
|
return s.save()
|
|
}
|
|
|
|
// GetLauncherSharePasswordHash returns the optional per-share bcrypt hash ("" = no share password).
|
|
func (s *Settings) GetLauncherSharePasswordHash() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.LauncherSharePasswordHash
|
|
}
|
|
|
|
// SetLauncherSharePasswordHash stores (or clears, on "") the per-share bcrypt hash and saves. It is
|
|
// ALWAYS distinct from the admin password hash. Changing it invalidates outstanding guest cookies
|
|
// (they bind the hash into the signature).
|
|
func (s *Settings) SetLauncherSharePasswordHash(hash string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.LauncherSharePasswordHash = hash
|
|
return s.save()
|
|
}
|
|
|
|
// ── Customer-claim arc (v0.122.0) ──────────────────────────────────────────────
|
|
|
|
// GetClaimed reports whether this box has completed a claim (set-only).
|
|
func (s *Settings) GetClaimed() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.Claimed
|
|
}
|
|
|
|
// SetClaimed marks the box claimed (never un-claims) and saves.
|
|
func (s *Settings) SetClaimed() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.Claimed {
|
|
return nil
|
|
}
|
|
s.Claimed = true
|
|
return s.save()
|
|
}
|
|
|
|
// GetClaimCode returns the cached hub-delivered code state (hash, generation, issuedAt RFC3339).
|
|
func (s *Settings) GetClaimCode() (hash string, generation int, issuedAt string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.ClaimCodeHash, s.ClaimCodeGeneration, s.ClaimCodeIssuedAt
|
|
}
|
|
|
|
// SetClaimCode caches a hub-delivered code state (idempotent by generation — the caller guards).
|
|
func (s *Settings) SetClaimCode(hash string, generation int, issuedAt string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.ClaimCodeHash = hash
|
|
s.ClaimCodeGeneration = generation
|
|
s.ClaimCodeIssuedAt = issuedAt
|
|
return s.save()
|
|
}
|
|
|
|
// GetClaimConsumedGeneration returns the last successfully consumed code generation.
|
|
func (s *Settings) GetClaimConsumedGeneration() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.ClaimConsumedGeneration
|
|
}
|
|
|
|
// SetClaimConsumedGeneration records a consumed code generation (single-use enforcement).
|
|
func (s *Settings) SetClaimConsumedGeneration(gen int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if gen > s.ClaimConsumedGeneration {
|
|
s.ClaimConsumedGeneration = gen
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// GetDBValidations returns a copy of the cached DB validations.
|
|
func (s *Settings) GetDBValidations() map[string]DBValidationCache {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.DBValidations == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]DBValidationCache, len(s.DBValidations))
|
|
for k, v := range s.DBValidations {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetDBValidation saves a validation result for a dump file and persists to disk.
|
|
func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.DBValidations == nil {
|
|
s.DBValidations = make(map[string]DBValidationCache)
|
|
}
|
|
s.DBValidations[filename] = cache
|
|
return s.save()
|
|
}
|
|
|
|
// GetNotificationPrefs returns a copy of the notification preferences.
|
|
func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.Notifications == nil {
|
|
events := make([]string, len(DefaultEnabledEvents))
|
|
copy(events, DefaultEnabledEvents)
|
|
return &NotificationPrefs{
|
|
EnabledEvents: events,
|
|
CooldownHours: 6,
|
|
}
|
|
}
|
|
prefs := *s.Notifications
|
|
if prefs.CooldownHours == 0 {
|
|
prefs.CooldownHours = 6
|
|
}
|
|
if prefs.EnabledEvents == nil {
|
|
prefs.EnabledEvents = DefaultEnabledEvents
|
|
}
|
|
// Return a copy of the slice verbatim. The offbox_enlarge_blocked seed is a ONE-TIME persisted
|
|
// migration (seedOffboxEnlargeNotice at Load), NOT a getter append — so a customer's later opt-out
|
|
// sticks instead of being re-enabled on every read.
|
|
events := make([]string, len(prefs.EnabledEvents))
|
|
copy(events, prefs.EnabledEvents)
|
|
prefs.EnabledEvents = events
|
|
return &prefs
|
|
}
|
|
|
|
// appendIfAbsent appends want to list only if it is not already present (idempotent).
|
|
func appendIfAbsent(list []string, want string) []string {
|
|
for _, e := range list {
|
|
if e == want {
|
|
return list
|
|
}
|
|
}
|
|
return append(list, want)
|
|
}
|
|
|
|
// SetNotificationPrefs updates notification preferences and saves to disk.
|
|
// H17: Deep-copies prefs so caller mutations after the call don't affect stored state.
|
|
func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
|
|
if prefs == nil {
|
|
return fmt.Errorf("notification preferences cannot be nil")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cp := *prefs
|
|
if len(prefs.EnabledEvents) > 0 {
|
|
cp.EnabledEvents = make([]string, len(prefs.EnabledEvents))
|
|
for i, e := range prefs.EnabledEvents {
|
|
cp.EnabledEvents[i] = e
|
|
}
|
|
}
|
|
s.Notifications = &cp
|
|
return s.save()
|
|
}
|
|
|
|
// GetOffboxTarget returns a copy of the off-box target config (nil if unconfigured).
|
|
func (s *Settings) GetOffboxTarget() *OffboxTarget {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.Offbox == nil {
|
|
return nil
|
|
}
|
|
cp := *s.Offbox
|
|
return &cp
|
|
}
|
|
|
|
// SetOffboxTarget saves (or clears, on nil) the off-box target config.
|
|
func (s *Settings) SetOffboxTarget(t *OffboxTarget) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.Offbox = t
|
|
return s.save()
|
|
}
|
|
|
|
// UpdateOffboxStatus mutates the off-box target's runtime status in-place (no-op if unconfigured).
|
|
func (s *Settings) UpdateOffboxStatus(fn func(*OffboxTarget)) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.Offbox == nil {
|
|
return nil
|
|
}
|
|
fn(s.Offbox)
|
|
return s.save()
|
|
}
|
|
|
|
// IsAppOffbox reports whether a stack is toggled for off-box backup.
|
|
func (s *Settings) IsAppOffbox(stackName string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return false
|
|
}
|
|
return s.AppBackup[stackName].Offbox
|
|
}
|
|
|
|
// SetAppOffbox toggles a stack's off-box backup inclusion.
|
|
func (s *Settings) SetAppOffbox(stackName string, on bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
existing := s.AppBackup[stackName]
|
|
existing.Offbox = on
|
|
s.AppBackup[stackName] = existing
|
|
return s.save()
|
|
}
|
|
|
|
// GetOffboxApps returns the stack names toggled for off-box backup.
|
|
func (s *Settings) GetOffboxApps() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var out []string
|
|
for name, p := range s.AppBackup {
|
|
if p.Offbox {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set).
|
|
func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return nil
|
|
}
|
|
prefs, ok := s.AppBackup[stackName]
|
|
if !ok || prefs.CrossDrive == nil {
|
|
return nil
|
|
}
|
|
cp := *prefs.CrossDrive
|
|
return &cp
|
|
}
|
|
|
|
// SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack.
|
|
func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
existing := s.AppBackup[stackName]
|
|
existing.CrossDrive = cfg
|
|
s.AppBackup[stackName] = existing
|
|
return s.save()
|
|
}
|
|
|
|
// UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place.
|
|
// fn receives a pointer to the CrossDriveBackup and may mutate it.
|
|
// If no cross-drive config exists for the stack, does nothing and returns nil.
|
|
func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
existing := s.AppBackup[stackName]
|
|
if existing.CrossDrive == nil {
|
|
return nil // don't create config from thin air — just skip status update
|
|
}
|
|
fn(existing.CrossDrive)
|
|
s.AppBackup[stackName] = existing
|
|
return s.save()
|
|
}
|
|
|
|
// SetTier2Preference records the customer's Tier-2 choice (from the per-app config panel) WITHOUT
|
|
// disturbing the runner's status fields: it merges into the existing config if one is present, else
|
|
// seeds a minimal config carrying just the preference. The Tier-2 runner reads UserDisabled (skip)
|
|
// and PreferredTarget (pin a destination) and preserves both on every status write.
|
|
func (s *Settings) SetTier2Preference(stackName string, disabled bool, preferredTarget string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
existing := s.AppBackup[stackName]
|
|
if existing.CrossDrive == nil {
|
|
existing.CrossDrive = &CrossDriveBackup{Method: "rsync", Schedule: "daily"}
|
|
}
|
|
existing.CrossDrive.UserDisabled = disabled
|
|
existing.CrossDrive.PreferredTarget = preferredTarget
|
|
s.AppBackup[stackName] = existing
|
|
return s.save()
|
|
}
|
|
|
|
// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
|
|
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
result := make(map[string]*CrossDriveBackup)
|
|
for name, prefs := range s.AppBackup {
|
|
if prefs.CrossDrive != nil {
|
|
cp := *prefs.CrossDrive
|
|
result[name] = &cp
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// NOTE: GetCrossDriveResticPassword, SetCrossDriveResticPassword, and
|
|
// GetOrCreateCrossDrivePassword were removed in the Tier 2 restic deprecation.
|
|
// The CrossDriveResticPassword field is kept in the struct for backward-compat
|
|
// JSON loading but is no longer used.
|
|
|
|
// --- Storage Paths ---
|
|
|
|
// GetStoragePaths returns a copy of all registered storage paths.
|
|
func (s *Settings) GetStoragePaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if len(s.StoragePaths) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]StoragePath, len(s.StoragePaths))
|
|
copy(result, s.StoragePaths)
|
|
return result
|
|
}
|
|
|
|
// GetDefaultStoragePath returns the default storage path string, or "".
|
|
func (s *Settings) GetDefaultStoragePath() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.IsDefault {
|
|
return sp.Path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetStorageLabel returns the label for a storage path, or the base name if not found.
|
|
func (s *Settings) GetStorageLabel(path string) string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path == path && sp.Label != "" {
|
|
return sp.Label
|
|
}
|
|
}
|
|
return filepath.Base(path)
|
|
}
|
|
|
|
// GetSchedulableStoragePaths returns paths available for new deployments.
|
|
func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Schedulable && !sp.Decommissioned {
|
|
result = append(result, sp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// AddStoragePath registers a new storage path. Validation is done by caller.
|
|
func (s *Settings) AddStoragePath(sp StoragePath) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] AddStoragePath path=%q label=%q default=%v", sp.Path, sp.Label, sp.IsDefault)
|
|
}
|
|
for _, existing := range s.StoragePaths {
|
|
if existing.Path == sp.Path {
|
|
return fmt.Errorf("storage path %q already registered", sp.Path)
|
|
}
|
|
}
|
|
if sp.IsDefault {
|
|
for i := range s.StoragePaths {
|
|
s.StoragePaths[i].IsDefault = false
|
|
}
|
|
}
|
|
s.StoragePaths = append(s.StoragePaths, sp)
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Added storage path: %s", sp.Path)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// RemoveStoragePath removes a path by its path string.
|
|
func (s *Settings) RemoveStoragePath(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path)
|
|
}
|
|
var kept []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path != path {
|
|
kept = append(kept, sp)
|
|
}
|
|
}
|
|
s.StoragePaths = kept
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Removed storage path: %s", path)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// RepointStoragePath changes a registered path's Path string in place (intermediary-mount migration:
|
|
// /mnt/<name> → /mnt/felhom-drives/<name>), preserving all other fields (label, default, schedulable,
|
|
// disconnect/decommission state). No-op (nil) if oldPath isn't registered or already equals newPath.
|
|
// Errors if newPath collides with a different existing entry.
|
|
func (s *Settings) RepointStoragePath(oldPath, newPath string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if oldPath == newPath {
|
|
return nil
|
|
}
|
|
idx := -1
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == newPath {
|
|
return fmt.Errorf("repoint target %q already registered", newPath)
|
|
}
|
|
if s.StoragePaths[i].Path == oldPath {
|
|
idx = i
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return nil // nothing to repoint
|
|
}
|
|
s.StoragePaths[idx].Path = newPath
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Repointed storage path: %s → %s", oldPath, newPath)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// SetDefaultStoragePath changes which path is the default.
|
|
func (s *Settings) SetDefaultStoragePath(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
found := false
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].IsDefault = true
|
|
found = true
|
|
} else {
|
|
s.StoragePaths[i].IsDefault = false
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// SetSchedulable enables/disables a path for new deployments.
|
|
func (s *Settings) SetSchedulable(path string, schedulable bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Schedulable = schedulable
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// SetBackupTarget assigns the whole-guest backup-target role to exactly one storage path, clearing it
|
|
// from every other. This is the ONLY writer of StoragePath.BackupTarget — registration must never set
|
|
// it (E-2 §3: a drive never acquires a role by appearing).
|
|
//
|
|
// Refusals, both structural rather than advisory:
|
|
// - a NETWORK share can never be the target. vzdump writes a multi-GB archive through the host, and
|
|
// the whole point of the role is surviving a local disk failure — a share that is itself remote,
|
|
// credential-bound and mounted at its own root (R-108) is a different risk model entirely.
|
|
// - a path that is not registered cannot hold a role.
|
|
//
|
|
// It deliberately does NOT refuse a DISCONNECTED path: reassigning to a drive that is currently absent
|
|
// is a legitimate recovery order ("this is the drive, go find it"), and the absent-target alarm is what
|
|
// covers the gap. Refusing here would instead force a silent retarget elsewhere.
|
|
func (s *Settings) SetBackupTarget(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
if s.StoragePaths[idx].IsNetwork() {
|
|
return fmt.Errorf("a hálózati tárhely nem lehet a rendszermentés célja: %s", path)
|
|
}
|
|
for i := range s.StoragePaths {
|
|
s.StoragePaths[i].BackupTarget = i == idx
|
|
}
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] backup target assigned: %s", path)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// ClearBackupTarget removes the role from every path, leaving the box with no assigned target (the
|
|
// degraded state). Separate from SetBackupTarget on purpose: "no target" is a real, nameable state
|
|
// that must be reachable deliberately, not an accident of passing an empty string to a setter.
|
|
func (s *Settings) ClearBackupTarget() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
s.StoragePaths[i].BackupTarget = false
|
|
}
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] backup target cleared — the box is now in the degraded (system-drive) state")
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// BackupTargetPath returns the assigned target's path, or "" when none is assigned (degraded).
|
|
func (s *Settings) BackupTargetPath() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.BackupTarget {
|
|
return sp.Path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// BackupTargetAssigned reports whether ANY path carries the role. The negation is the degraded state
|
|
// the customer must be told about (E-2 Part 4).
|
|
func (s *Settings) BackupTargetAssigned() bool { return s.BackupTargetPath() != "" }
|
|
|
|
// SetStorageLabel updates the label for a storage path.
|
|
func (s *Settings) SetStorageLabel(path, label string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Label = label
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// AutoDiscoverStoragePaths scans for HDD_PATH values and registers any that are not
|
|
// already in the registry. It is ADDITIVE: pre-existing entries are never removed,
|
|
// modified, or reactivated.
|
|
// - discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml.
|
|
// - fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty).
|
|
//
|
|
// Invariants:
|
|
// - A path already present in the registry IN ANY STATE (including a Decommissioned
|
|
// soft-marked entry) is SKIPPED — never re-added and never re-activated.
|
|
// - A manually-added path is never removed or modified.
|
|
// - IsDefault is never flipped on an existing entry. A newly-discovered path becomes
|
|
// default ONLY if the registry currently has no default at all (and then only the
|
|
// first such new path).
|
|
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths))
|
|
}
|
|
|
|
// Index existing paths (in ANY state) and whether a default already exists.
|
|
existing := make(map[string]bool, len(s.StoragePaths))
|
|
hasDefault := false
|
|
for i := range s.StoragePaths {
|
|
existing[filepath.Clean(s.StoragePaths[i].Path)] = true
|
|
if s.StoragePaths[i].IsDefault {
|
|
hasDefault = true
|
|
}
|
|
}
|
|
|
|
// Build the de-duplicated, cleaned candidate list (discovered first, then fallback).
|
|
seen := make(map[string]bool)
|
|
var ordered []string
|
|
for _, p := range discoveredPaths {
|
|
cleaned := filepath.Clean(p)
|
|
if cleaned != "" && cleaned != "." && !seen[cleaned] {
|
|
seen[cleaned] = true
|
|
ordered = append(ordered, cleaned)
|
|
}
|
|
}
|
|
if fallbackHDDPath != "" {
|
|
cleaned := filepath.Clean(fallbackHDDPath)
|
|
if cleaned != "" && cleaned != "." && !seen[cleaned] {
|
|
seen[cleaned] = true
|
|
ordered = append(ordered, cleaned)
|
|
}
|
|
}
|
|
|
|
added := 0
|
|
for _, path := range ordered {
|
|
if existing[path] {
|
|
continue // already registered in some state — never re-add or reactivate
|
|
}
|
|
sp := StoragePath{
|
|
Path: path,
|
|
Label: InferStorageLabel(path),
|
|
IsDefault: !hasDefault, // first newly-added path defaults only if none exists yet
|
|
Schedulable: true,
|
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if sp.IsDefault {
|
|
hasDefault = true // don't promote a second new path
|
|
}
|
|
s.StoragePaths = append(s.StoragePaths, sp)
|
|
existing[path] = true
|
|
added++
|
|
}
|
|
|
|
if added == 0 {
|
|
return // nothing new to register
|
|
}
|
|
|
|
if err := s.save(); err != nil {
|
|
logger.Printf("[ERROR] [settings] Failed to save auto-discovered storage paths: %v", err)
|
|
return
|
|
}
|
|
logger.Printf("[INFO] [settings] Auto-discovered %d new storage path(s)", added)
|
|
for _, sp := range s.StoragePaths {
|
|
logger.Printf("[INFO] [settings] %s (%s) default=%v decommissioned=%v", sp.Path, sp.Label, sp.IsDefault, sp.Decommissioned)
|
|
}
|
|
}
|
|
|
|
// InferStorageLabel generates a human-readable label for a storage path.
|
|
func InferStorageLabel(path string) string {
|
|
base := filepath.Base(path)
|
|
// The internal system volume's data path ends in the felhom-data namespace dir
|
|
// (e.g. /mnt/sys_drive/felhom-data) — Model-A user drives register their MOUNT ROOT
|
|
// (e.g. /mnt/felhom-usb), never .../felhom-data, so this can't mislabel a user drive.
|
|
if base == appbackup.FelhomDataDir {
|
|
return "Belső SSD (rendszer)"
|
|
}
|
|
if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") {
|
|
return fmt.Sprintf("Külső tárhely (%s)", base)
|
|
}
|
|
return fmt.Sprintf("Tárhely (%s)", base)
|
|
}
|
|
|
|
// SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped.
|
|
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetDisconnected path=%q disconnected=%v stopped_stacks=%d", path, disconnected, len(stoppedStacks))
|
|
}
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Storage path %s disconnected=%v", path, disconnected)
|
|
}
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Disconnected = disconnected
|
|
if disconnected {
|
|
s.StoragePaths[i].DisconnectedAt = time.Now().UTC().Format(time.RFC3339)
|
|
s.StoragePaths[i].StoppedStacks = stoppedStacks
|
|
} else {
|
|
s.StoragePaths[i].DisconnectedAt = ""
|
|
// Preserve StoppedStacks on reconnect so the UI can offer restart
|
|
if stoppedStacks != nil {
|
|
s.StoragePaths[i].StoppedStacks = stoppedStacks
|
|
}
|
|
}
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// ClearDisconnected marks a path as connected and clears all disconnect-related fields.
|
|
func (s *Settings) ClearDisconnected(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Disconnected = false
|
|
s.StoragePaths[i].DisconnectedAt = ""
|
|
s.StoragePaths[i].StoppedStacks = nil
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// IsDisconnected returns whether a storage path is marked as disconnected.
|
|
func (s *Settings) IsDisconnected(path string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path == path {
|
|
return sp.Disconnected
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetDisconnectedPaths returns a copy of all storage paths that are marked disconnected.
|
|
func (s *Settings) GetDisconnectedPaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Disconnected {
|
|
result = append(result, sp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetConnectedPaths returns a copy of all storage paths that are NOT disconnected and NOT decommissioned.
|
|
func (s *Settings) GetConnectedPaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if !sp.Disconnected && !sp.Decommissioned {
|
|
result = append(result, sp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// IsStoragePathKnown returns whether a path belongs to any registered storage path
|
|
// (connected, disconnected, or decommissioned). A path removed entirely from
|
|
// storage_paths is NOT known.
|
|
func (s *Settings) IsStoragePathKnown(path string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsNetworkStoragePath reports whether `path` belongs to a registered NAS network-storage path
|
|
// (Kind=="network"). The drive lifecycle (eject/decommission/migrate/wipe) must refuse such a path —
|
|
// a NAS has no device lifecycle. Matches the exact path or a child under it.
|
|
func (s *Settings) IsNetworkStoragePath(path string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
|
|
return sp.IsNetwork()
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsStoragePathSchedulable returns whether a path belongs to a registered,
|
|
// schedulable (active) storage path. Returns false if the path is unknown,
|
|
// disconnected, decommissioned, or inactive.
|
|
func (s *Settings) IsStoragePathSchedulable(path string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
|
|
return sp.Schedulable && !sp.Disconnected && !sp.Decommissioned
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetStoppedStacks returns the list of stacks that were auto-stopped for a storage path.
|
|
func (s *Settings) GetStoppedStacks(path string) []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path == path {
|
|
if len(sp.StoppedStacks) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]string, len(sp.StoppedStacks))
|
|
copy(result, sp.StoppedStacks)
|
|
return result
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearStoppedStacks removes the stopped stacks list for a storage path (e.g., after restart).
|
|
func (s *Settings) ClearStoppedStacks(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].StoppedStacks = nil
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// SetDecommissioned marks a storage path as decommissioned with migration target.
|
|
// Clears IsDefault and Schedulable.
|
|
func (s *Settings) SetDecommissioned(path, migratedTo string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetDecommissioned path=%q migrated_to=%q", path, migratedTo)
|
|
}
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Storage path %s decommissioned (migrated_to=%s)", path, migratedTo)
|
|
}
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Decommissioned = true
|
|
s.StoragePaths[i].DecommissionedAt = time.Now().UTC().Format(time.RFC3339)
|
|
s.StoragePaths[i].MigratedTo = migratedTo
|
|
s.StoragePaths[i].IsDefault = false
|
|
s.StoragePaths[i].Schedulable = false
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// ClearDecommissioned removes the decommissioned state from a storage path.
|
|
func (s *Settings) ClearDecommissioned(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Decommissioned = false
|
|
s.StoragePaths[i].DecommissionedAt = ""
|
|
s.StoragePaths[i].MigratedTo = ""
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// IsDecommissioned returns whether a storage path is marked as decommissioned.
|
|
func (s *Settings) IsDecommissioned(path string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path == path {
|
|
return sp.Decommissioned
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetDecommissionedPaths returns a copy of all decommissioned storage paths.
|
|
func (s *Settings) GetDecommissionedPaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Decommissioned {
|
|
result = append(result, sp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Hub Verification ---
|
|
|
|
// GetHubVerified returns the hub verification state.
|
|
func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.HubVerified, s.HubVerifiedAt
|
|
}
|
|
|
|
// SetHubVerified updates the hub verification state and saves to disk.
|
|
func (s *Settings) SetHubVerified(verified bool, at time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.HubVerified = verified
|
|
s.HubVerifiedAt = at.UTC().Format(time.RFC3339)
|
|
s.HubLastCheck = at.UTC().Format(time.RFC3339)
|
|
return s.save()
|
|
}
|
|
|
|
// GetAppliedConfigVersion returns the last-applied hub config_version (0 = none recorded yet).
|
|
func (s *Settings) GetAppliedConfigVersion() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.AppliedConfigVersion
|
|
}
|
|
|
|
// SetAppliedConfigVersion persists the config_version this controller has pulled + applied. Recorded
|
|
// BEFORE a config-refresh self-restart so the restarted process sees it applied and does not loop.
|
|
func (s *Settings) SetAppliedConfigVersion(v int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.AppliedConfigVersion = v
|
|
return s.save()
|
|
}
|
|
|
|
// SetHubLastCheck updates the last Hub check timestamp without changing verification status.
|
|
// GetLastGuestBootID returns the persisted last-seen guest boot-id ("" if never recorded).
|
|
func (s *Settings) GetLastGuestBootID() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.LastGuestBootID
|
|
}
|
|
|
|
// SetLastGuestBootID persists the current guest boot-id (after a deterministic boot-recreate pass).
|
|
func (s *Settings) SetLastGuestBootID(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.LastGuestBootID = id
|
|
return s.save()
|
|
}
|
|
|
|
func (s *Settings) SetHubLastCheck(at time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.HubLastCheck = at.UTC().Format(time.RFC3339)
|
|
return s.save()
|
|
}
|
|
|
|
// IsLimitedMode returns true if the controller should operate in limited mode
|
|
// (new deployments blocked). This happens when:
|
|
// - Never verified AND >7 days since controller started, OR
|
|
// - Hub explicitly set customer as blocked (HubVerified=false after a successful check)
|
|
func (s *Settings) IsLimitedMode() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.HubVerified {
|
|
return false
|
|
}
|
|
|
|
// If we have a last check timestamp and it says not verified, limited mode
|
|
if s.HubLastCheck != "" {
|
|
return true
|
|
}
|
|
|
|
// Never checked yet — check if grace period (7 days) expired
|
|
if s.HubVerifiedAt == "" {
|
|
// No verification timestamp at all — not yet in limited mode (grace period from startup)
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, s.HubVerifiedAt)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return time.Since(t) > 7*24*time.Hour
|
|
}
|
|
|
|
// --- Retrieval Password ---
|
|
|
|
// GetRetrievalPassword returns the stored retrieval password (thread-safe).
|
|
func (s *Settings) GetRetrievalPassword() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.RetrievalPassword
|
|
}
|
|
|
|
// SetRetrievalPassword updates the retrieval password and saves to disk.
|
|
func (s *Settings) SetRetrievalPassword(password string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.RetrievalPassword = password
|
|
return s.save()
|
|
}
|
|
|
|
// --- Pending Events ---
|
|
|
|
// AddPendingEvent queues an event for the next Hub push cycle.
|
|
func (s *Settings) AddPendingEvent(event PendingEvent) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] AddPendingEvent type=%q severity=%q", event.EventType, event.Severity)
|
|
}
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] [settings] Added pending event: %s", event.EventType)
|
|
}
|
|
s.PendingEvents = append(s.PendingEvents, event)
|
|
return s.save()
|
|
}
|
|
|
|
// DrainPendingEvents returns and clears all pending events (thread-safe).
|
|
func (s *Settings) DrainPendingEvents() []PendingEvent {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.PendingEvents) == 0 {
|
|
return nil
|
|
}
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents))
|
|
}
|
|
events := make([]PendingEvent, len(s.PendingEvents))
|
|
copy(events, s.PendingEvents)
|
|
s.PendingEvents = nil
|
|
if err := s.save(); err != nil {
|
|
s.log.Printf("[ERROR] [settings] Failed to save after draining pending events: %v — restoring events", err)
|
|
s.PendingEvents = events
|
|
return nil
|
|
}
|
|
return events
|
|
}
|
|
|
|
// --- Geo-Restriction ---
|
|
|
|
// GetGeoRestriction returns a deep copy of the geo-restriction settings.
|
|
func (s *Settings) GetGeoRestriction() *GeoRestriction {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.GeoRestriction == nil {
|
|
return nil
|
|
}
|
|
geo := *s.GeoRestriction
|
|
if len(s.GeoRestriction.AllowedCountries) > 0 {
|
|
geo.AllowedCountries = make([]string, len(s.GeoRestriction.AllowedCountries))
|
|
copy(geo.AllowedCountries, s.GeoRestriction.AllowedCountries)
|
|
}
|
|
if len(s.GeoRestriction.AppOverrides) > 0 {
|
|
geo.AppOverrides = make(map[string]AppGeoOverride, len(s.GeoRestriction.AppOverrides))
|
|
for k, v := range s.GeoRestriction.AppOverrides {
|
|
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
|
|
copy(ov.AllowedCountries, v.AllowedCountries)
|
|
geo.AppOverrides[k] = ov
|
|
}
|
|
}
|
|
return &geo
|
|
}
|
|
|
|
// SetGeoRestriction replaces the entire geo-restriction config and saves to disk.
|
|
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
if geo == nil {
|
|
s.log.Printf("[DEBUG] [settings] SetGeoRestriction geo=nil (clearing)")
|
|
} else {
|
|
s.log.Printf("[DEBUG] [settings] SetGeoRestriction enabled=%v countries=%d", geo.Enabled, len(geo.AllowedCountries))
|
|
}
|
|
}
|
|
if geo == nil {
|
|
s.GeoRestriction = nil
|
|
return s.save()
|
|
}
|
|
cp := *geo
|
|
if len(geo.AllowedCountries) > 0 {
|
|
cp.AllowedCountries = make([]string, len(geo.AllowedCountries))
|
|
copy(cp.AllowedCountries, geo.AllowedCountries)
|
|
}
|
|
if len(geo.AppOverrides) > 0 {
|
|
cp.AppOverrides = make(map[string]AppGeoOverride, len(geo.AppOverrides))
|
|
for k, v := range geo.AppOverrides {
|
|
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
|
|
copy(ov.AllowedCountries, v.AllowedCountries)
|
|
cp.AppOverrides[k] = ov
|
|
}
|
|
}
|
|
s.GeoRestriction = &cp
|
|
return s.save()
|
|
}
|
|
|
|
// SetGeoAppOverride sets a per-app geo override. Creates the GeoRestriction if nil.
|
|
// Pass override=nil to remove the override (same as RemoveGeoAppOverride).
|
|
func (s *Settings) SetGeoAppOverride(appName string, override *AppGeoOverride) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if override == nil {
|
|
// nil override = remove (fall back to global)
|
|
if s.GeoRestriction != nil && s.GeoRestriction.AppOverrides != nil {
|
|
delete(s.GeoRestriction.AppOverrides, appName)
|
|
}
|
|
return s.save()
|
|
}
|
|
if s.GeoRestriction == nil {
|
|
s.GeoRestriction = &GeoRestriction{AllowedCountries: []string{"HU"}}
|
|
}
|
|
if s.GeoRestriction.AppOverrides == nil {
|
|
s.GeoRestriction.AppOverrides = make(map[string]AppGeoOverride)
|
|
}
|
|
ov := AppGeoOverride{AllowedCountries: make([]string, len(override.AllowedCountries))}
|
|
copy(ov.AllowedCountries, override.AllowedCountries)
|
|
s.GeoRestriction.AppOverrides[appName] = ov
|
|
return s.save()
|
|
}
|
|
|
|
// RemoveGeoAppOverride removes a per-app override (app falls back to global).
|
|
func (s *Settings) RemoveGeoAppOverride(appName string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.GeoRestriction == nil || s.GeoRestriction.AppOverrides == nil {
|
|
return nil
|
|
}
|
|
delete(s.GeoRestriction.AppOverrides, appName)
|
|
return s.save()
|
|
}
|
|
|
|
// SetGeoSyncState updates the geo sync status fields.
|
|
func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.GeoRestriction == nil {
|
|
return nil
|
|
}
|
|
s.GeoRestriction.LastSync = time.Now().UTC().Format(time.RFC3339)
|
|
s.GeoRestriction.LastSyncError = syncError
|
|
if zoneID != "" {
|
|
s.GeoRestriction.ZoneID = zoneID
|
|
}
|
|
if rulesetID != "" {
|
|
s.GeoRestriction.RulesetID = rulesetID
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// --- App email (SMTP relay) ---
|
|
|
|
// GetAppEmail returns the global app-email toggle (a copy; never the live pointer).
|
|
func (s *Settings) GetAppEmail() AppEmail {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppEmail == nil {
|
|
return AppEmail{}
|
|
}
|
|
return *s.AppEmail
|
|
}
|
|
|
|
// AppEmailEnabled reports whether app-email is globally on.
|
|
func (s *Settings) AppEmailEnabled() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.AppEmail != nil && s.AppEmail.Enabled
|
|
}
|
|
|
|
// SetAppEmail updates the global app-email toggle and persists it.
|
|
func (s *Settings) SetAppEmail(enabled bool, fromName string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetAppEmail enabled=%v from_name=%q", enabled, fromName)
|
|
}
|
|
s.AppEmail = &AppEmail{Enabled: enabled, FromName: strings.TrimSpace(fromName)}
|
|
return s.save()
|
|
}
|
|
|
|
// --- App-to-app integrations ---
|
|
|
|
// GetIntegrationState returns the state for a specific integration key (e.g., "onlyoffice:filebrowser").
|
|
func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.Integrations == nil {
|
|
return IntegrationState{}, false
|
|
}
|
|
state, ok := s.Integrations[key]
|
|
return state, ok
|
|
}
|
|
|
|
// SetIntegrationState updates (or creates) the state for a single integration key.
|
|
func (s *Settings) SetIntegrationState(key string, state IntegrationState) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetIntegrationState key=%q status=%q enabled=%v", key, state.Status, state.Enabled)
|
|
}
|
|
if s.Integrations == nil {
|
|
s.Integrations = make(map[string]IntegrationState)
|
|
}
|
|
s.Integrations[key] = state
|
|
return s.save()
|
|
}
|
|
|
|
// RemoveIntegrationState removes an integration key entirely.
|
|
func (s *Settings) RemoveIntegrationState(key string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.Integrations != nil {
|
|
delete(s.Integrations, key)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// GetIntegrationsForProvider returns all integration states where key starts with "provider:".
|
|
func (s *Settings) GetIntegrationsForProvider(provider string) map[string]IntegrationState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
prefix := provider + ":"
|
|
result := make(map[string]IntegrationState)
|
|
for k, v := range s.Integrations {
|
|
if strings.HasPrefix(k, prefix) {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetIntegrationsForTarget returns all integration states where key ends with ":target".
|
|
func (s *Settings) GetIntegrationsForTarget(target string) map[string]IntegrationState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
suffix := ":" + target
|
|
result := make(map[string]IntegrationState)
|
|
for k, v := range s.Integrations {
|
|
if strings.HasSuffix(k, suffix) {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|