c7446f2d6a
R-225 — an unread store said '0 pillanatkép / 0 / 50 GB' above a card stating it held backups under another key. An SFTP listing found snapshot f3d9cd67 and 12 535 KB really there; snapshot_count and repo_size_bytes were simply ABSENT and the zero value spoke for them. StatsKnown is now NAMED, for the same reason OffsiteInventory.Empty is: zero is what an unread store and an empty one both look like, and on the wire 'absent' and '0' are the same bytes. The fill bar renders only when the fill is known — a 0%-wide bar is a picture of emptiness, and a picture is a claim. A measured zero still says zero. R-227 — WHICH LAYER ANSWERS: traefik, and this repo generates its config. But traefik v3 serves no static files, so a branded proxy page needs a new always-up container for every 502 on the box — out of proportion, and scoped in the report rather than built. Shipped instead: the unlock posts via fetch and answers a gateway failure in Hungarian without leaving the page. Progressive enhancement — with no JS the plain POST is unchanged and still shows the proxy's error, which the report says plainly rather than implying otherwise. R-228 — the set-aside history was recorded in orphaned_renamed_to and read by nobody: a census found zero references in any template or handler, while 12 535 KB sat at that path. It is surfaced as two facts and stops. It does NOT promise the history can be reopened, because it cannot be by anyone today (R-199's inventory is unbuilt) — and the set-aside CONFIRMATION copy was corrected for the same reason: 'a helyreállítási kód nélkül többé nem lesznek megnyithatók' implied that WITH the code they could be. The field's own comment called it 'recovery-code-recoverable', which was the same over-promise in the code. Tests: scenarios F, G, H as render tests per branch of each gate. Red-proofs, each demonstrated failing then restored: remove the StatsKnown guards (F, 'R-225 RETURNED: an unread store reports a snapshot COUNT of zero'), delete the set-aside block (H). The F assertion on the fill bar is scoped to the bar's own container — a bare width:0% search matched unrelated elements and would have passed for the wrong reason. 28 packages ok, vet clean, all controller gates OK (the emoji gate caught a warning sign in a template comment).
2040 lines
80 KiB
Go
2040 lines
80 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"`
|
|
|
|
// HubEscrowIdentityPresent (v0.199.0, R-204 item 4 / R-193) caches the report ACK's
|
|
// `escrow.identity_blob_present` — whether the HUB is holding a sealed recovery package for this
|
|
// box. It is recorded on EVERY ACK that carries an escrow object, including when no off-site
|
|
// target is configured, which is the whole point: until now that field was read only by the
|
|
// auto-confirm, which returns early on an unconfigured box, so a REBUILT box threw away the one
|
|
// fact that distinguishes it from a box that never had off-site backups.
|
|
//
|
|
// It is half of the "I am rebuilt and stranded" predicate (see backup.OffboxReportStatus). The
|
|
// other half is local: no repository password on disk. **Freshness alone is a box that never had
|
|
// off-site backups; an escrow alone is a healthy box. Only both together mean rebuilt.**
|
|
//
|
|
// Cached, not derived: a rebuilt box has an empty settings.json, so this is re-learned from its
|
|
// first ACK — which is correct, because the hub is the authority on what the hub holds.
|
|
HubEscrowIdentityPresent bool `json:"hub_escrow_identity_present,omitempty"`
|
|
|
|
// HubEscrowSupersededPresent / HubEscrowSupersededAt (v0.201.0, R-222) cache the report ACK's
|
|
// `escrow.superseded_present` / `superseded_at` — whether the hub is ALSO keeping an EARLIER
|
|
// sealed package for this box, and when it was set aside.
|
|
//
|
|
// They exist for one message and nothing else. Without them the recovery screen cannot tell a
|
|
// genuinely wrong recovery code from a code that is RIGHT about an earlier package, because both
|
|
// look identical from here: the unseal fails closed against the package the hub currently serves.
|
|
// Measured on 2026-08-05 (CAMPAIGN-11 Phase 3 step 7) — the customer entered the correct code for
|
|
// their orphaned history and was told to check their typing.
|
|
//
|
|
// ⚠ These grant NO read path. The hub serves the CURRENT package only; retrieving a superseded one
|
|
// is a link that has never been built (R-199's inventory). The screen may say an earlier package is
|
|
// kept and when — both true, both non-secret — and must not promise it can be opened.
|
|
HubEscrowSupersededPresent bool `json:"hub_escrow_superseded_present,omitempty"`
|
|
HubEscrowSupersededAt string `json:"hub_escrow_superseded_at,omitempty"`
|
|
|
|
// RecoveryNoticePostponed (v0.200.0, R-193) — the customer chose "most nem" on the full-page
|
|
// recovery screen. It suppresses THE FULL-PAGE INTERRUPTION ONLY. The entry point in the backups
|
|
// area stays, permanently, for as long as the situation lasts: the data is still there whether or
|
|
// not anyone clicked, and a one-shot notice a flustered person clicks past is a notice that never
|
|
// happened. It is deliberately NOT cleared by anything except the situation ending.
|
|
RecoveryNoticePostponed bool `json:"recovery_notice_postponed,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 — "ok" | "incomplete" | "error" | "running". R-203 added "incomplete": the run
|
|
// completed and what it captured is real, but a directory the app declares MANDATORY could not be
|
|
// captured, so the app is NOT fully protected. Distinct from "error" (the run failed) on purpose;
|
|
// SnapshotCount and LastSuccess still record what WAS captured.
|
|
LastStatus string `json:"last_status,omitempty"`
|
|
// 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"`
|
|
// StatsKnown — R-225. Whether SnapshotCount / RepoSizeBytes above were ever actually READ from the
|
|
// repository, as opposed to never having been measured.
|
|
//
|
|
// ⚠ IT IS NAMED RATHER THAN INFERRED FROM THE COUNTS, for the same reason `OffsiteInventory.Empty`
|
|
// is: **zero is what an unread store and a genuinely empty one both look like**, and the two are
|
|
// opposite news. Both fields are `omitempty` ints, so on the wire and on disk "absent" and "0" are
|
|
// the same bytes — the distinction cannot be recovered downstream and has to be carried.
|
|
//
|
|
// Measured live on 2026-08-05 (CAMPAIGN-11, R-225): after a rebuild the box rendered
|
|
// „Tároló méret · 0 pillanatkép" and „Tárhelykeret: 0 / 50 GB (0%)" — directly above a card saying
|
|
// the store held backups made under another key. An SFTP listing found snapshot `f3d9cd67` and
|
|
// 12 535 KB really there. The keys were simply ABSENT from settings.json and the zero value spoke
|
|
// for them.
|
|
//
|
|
// Set true by the run that reads the stats, whatever it reads — a true zero is knowledge too.
|
|
StatsKnown bool `json:"stats_known,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 history was set aside.
|
|
//
|
|
// ⚠ R-228 (2026-08-06) — THIS WAS WRITTEN AND READ BY NOBODY, and this comment used to call the
|
|
// set-aside history "recovery-code-recoverable", which is **not true today**: serving a superseded
|
|
// blob is an unbuilt link (R-199's inventory), so the old store cannot be opened by the customer,
|
|
// the operator, or anyone else. Measured on 2026-08-05 (CAMPAIGN-11 F7): after a customer chose to
|
|
// set their old backups aside, 12 535 KB sat at this exact path and `/backups/remote` mentioned it
|
|
// nowhere — a census found zero references to this field in any template or handler. The screen
|
|
// promised the data was kept and then showed nothing.
|
|
//
|
|
// It is surfaced now, as a plain statement that an earlier history is KEPT and NOT DELETED. It must
|
|
// NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
|
|
// that turns out false is worse on this surface than saying less.
|
|
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()
|
|
}
|
|
|
|
// ── Hub-held recovery package (v0.199.0, R-204 item 4) ─────────────────────────
|
|
|
|
// GetHubEscrowIdentityPresent reports whether the hub is holding a sealed identity/recovery package
|
|
// for this box, as last stated by a report ACK. False when no ACK has carried an escrow object yet.
|
|
func (s *Settings) GetHubEscrowIdentityPresent() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.HubEscrowIdentityPresent
|
|
}
|
|
|
|
// SetHubEscrowIdentityPresent records the ACK's `escrow.identity_blob_present`. It is a plain
|
|
// last-write-wins mirror of the hub's statement — NOT set-only, because the hub losing an escrow row
|
|
// (a customer RESET) must be able to turn the box's declaration back off. Saves only on a change, so
|
|
// the ordinary 15-minute ACK does not rewrite settings.json every cycle.
|
|
func (s *Settings) SetHubEscrowIdentityPresent(present bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.HubEscrowIdentityPresent == present {
|
|
return nil
|
|
}
|
|
s.HubEscrowIdentityPresent = present
|
|
return s.save()
|
|
}
|
|
|
|
// GetHubEscrowSuperseded reports whether the hub is keeping an EARLIER sealed package for this box,
|
|
// and when it was set aside (R-222). Both zero until an ACK has said so.
|
|
func (s *Settings) GetHubEscrowSuperseded() (present bool, at string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.HubEscrowSupersededPresent, s.HubEscrowSupersededAt
|
|
}
|
|
|
|
// SetHubEscrowSuperseded records the ACK's `escrow.superseded_present` / `superseded_at`. Same
|
|
// last-write-wins mirror discipline as SetHubEscrowIdentityPresent — the hub is the authority, and a
|
|
// customer RESET that clears the retained rows must be able to turn the box's message back off.
|
|
// Saves only on a change.
|
|
func (s *Settings) SetHubEscrowSuperseded(present bool, at string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.HubEscrowSupersededPresent == present && s.HubEscrowSupersededAt == at {
|
|
return nil
|
|
}
|
|
s.HubEscrowSupersededPresent, s.HubEscrowSupersededAt = present, at
|
|
return s.save()
|
|
}
|
|
|
|
// ── Recovery screen (v0.200.0, R-193) ──────────────────────────────────────────
|
|
|
|
// GetRecoveryNoticePostponed reports whether the customer chose "most nem" on the recovery page.
|
|
// Suppresses the full-page interruption ONLY — never the backups-area entry point.
|
|
func (s *Settings) GetRecoveryNoticePostponed() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.RecoveryNoticePostponed
|
|
}
|
|
|
|
// SetRecoveryNoticePostponed records the "most nem" choice.
|
|
func (s *Settings) SetRecoveryNoticePostponed(v bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.RecoveryNoticePostponed == v {
|
|
return nil
|
|
}
|
|
s.RecoveryNoticePostponed = v
|
|
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
|
|
}
|
|
|
|
// ReloadClaimCode re-reads the PERSISTED claim-code state from settings.json into the in-process
|
|
// cache, so a code minted by ANOTHER PROCESS is visible without restarting this one.
|
|
//
|
|
// WHY THIS EXISTS (R-204 item 1, v0.198.0). `--print-reset-code` runs as a separate process
|
|
// (`docker exec`): it loads settings itself, mints a code, persists it and exits. The running
|
|
// server's cache never heard, so it kept validating against the previous hash and the code the
|
|
// customer was told to type was refused until the controller restarted. Nothing said so. During the
|
|
// 2026-08-04 recovery drill that cost two failed attempts with an operator present; a customer alone
|
|
// stops there. THE READ IS THE FIX — it is not a cache refresh for tidiness.
|
|
//
|
|
// READ-THROUGH, NOT A WATCHER AND NOT A TTL, deliberately (R-204 §8.1). A watcher/signal/background
|
|
// reloader is a new failure mode for one stale read. A TTL is worse than the bug: it opens a window
|
|
// in which a SUPERSEDED code still works. Pinned by
|
|
// web.TestClaimCode_SupersededByASecondMint_RefusedImmediately, whose stated red-proof is exactly
|
|
// that TTL. The read happens on the claim path only — see web.effectiveClaimCode — and only while the
|
|
// box carries no password, i.e. exactly the gate window.
|
|
//
|
|
// WHAT IT REFRESHES AND WHAT IT DELIBERATELY DOES NOT: hash/generation/issuedAt only.
|
|
// ClaimConsumedGeneration is NOT re-read. This process is its only writer and its in-memory value is
|
|
// monotonic; re-reading it could move it BACKWARDS if a save had failed, which would resurrect an
|
|
// already-consumed code — the exact widening this fix is not allowed to introduce.
|
|
//
|
|
// An ABSENT file is not an error: a box before its first save legitimately has no persisted state and
|
|
// falls back to the controller.yaml bake. A present-but-unreadable or corrupt file IS an error, and
|
|
// the caller fails closed on it.
|
|
func (s *Settings) ReloadClaimCode() error {
|
|
if s.path == "" {
|
|
return nil // no persistence configured (tests) — the cache is all there is
|
|
}
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("reading persisted claim state: %w", err)
|
|
}
|
|
var onDisk struct {
|
|
ClaimCodeHash string `json:"claim_code_hash"`
|
|
ClaimCodeGeneration int `json:"claim_code_generation"`
|
|
ClaimCodeIssuedAt string `json:"claim_code_issued_at"`
|
|
}
|
|
if err := json.Unmarshal(data, &onDisk); err != nil {
|
|
return fmt.Errorf("parsing persisted claim state: %w", err)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.ClaimCodeHash = onDisk.ClaimCodeHash
|
|
s.ClaimCodeGeneration = onDisk.ClaimCodeGeneration
|
|
s.ClaimCodeIssuedAt = onDisk.ClaimCodeIssuedAt
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// RefuseAsAppNamespace reports whether `path` must be REFUSED as an app's data namespace (its
|
|
// HDD_PATH), and why. It is the single predicate every placement surface consults — R-108.
|
|
//
|
|
// WHY AN APP NAMESPACE MAY NOT LIVE ON A NAS (operator ruling, 2026-07-30). An app's namespace root is
|
|
// also where its backups go: `namespaceRoot(drivePath)` returns a non-system drive path AS-IS, so the
|
|
// app's recovery unit lands at `<path>/backups/primary/<stack>/` (appbackup.RecoveryUnitPath). For a
|
|
// network share that directory would sit inside the share ROOT — which FileBrowser binds whole, with
|
|
// `download: true`, and MUST keep binding whole: the `:rslave` share-root bind is load-bearing for
|
|
// automount wake/idle propagation into the running container (R-67), and scoping it is impossible
|
|
// besides — apps on a share store at `<share>/<app>`, there is no `userdata/` layer, and creating one
|
|
// would write Felhom's convention onto a customer's own NAS, which R-67 forbids outright.
|
|
//
|
|
// So the browsing surface cannot be narrowed and the backup tree must therefore never be placed under
|
|
// it. Tier 2 already refuses network targets for exactly this class of reason (F-6C-1); this closes the
|
|
// PRIMARY namespace, which was the remaining way a `backups/` tree could appear inside a share-root
|
|
// bind. That is the precondition D5 was waiting on.
|
|
//
|
|
// FAIL CLOSED, and the two non-obvious cases are why this is a function and not an `IsNetwork()` call:
|
|
//
|
|
// - `NetworkMountRoot` holds BOTH kinds in-guest (`/mnt/felhom-drives/hdd_1` is a local drive,
|
|
// `/mnt/felhom-drives/Felhom-Share` is a NAS), so a path prefix CANNOT classify. `Kind` is the only
|
|
// discriminator, and it exists only on a REGISTERED path.
|
|
// - therefore an UNREGISTERED path under `NetworkMountRoot` is un-classifiable, and un-classifiable
|
|
// must refuse. Allowing it would be a fallback to "probably a drive" on the one surface that
|
|
// accepts an arbitrary caller-supplied path (the deploy POST validates only `os.Stat` existence).
|
|
// Every NAS share is registered under this root by construction (see NetworkMountRoot), so refusing
|
|
// the unregistered case makes the network set completely covered without touching drives.
|
|
//
|
|
// An empty path is ALLOWED: it means the app is system/SSD-resident and has no external namespace at
|
|
// all. A nil receiver refuses — we cannot consult the registry, so we cannot tell.
|
|
func (s *Settings) RefuseAsAppNamespace(path string) (bool, string) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return false, "" // SSD-resident: no external namespace to place
|
|
}
|
|
if s == nil {
|
|
return true, refuseAppNamespaceUndeterminable
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
|
|
if sp.IsNetwork() {
|
|
return true, refuseAppNamespaceNetwork
|
|
}
|
|
return false, "" // a registered DRIVE — the supported case, unchanged
|
|
}
|
|
}
|
|
// Not registered. Under the shared mount root its kind is undeterminable → refuse (see above).
|
|
if path == NetworkMountRoot || strings.HasPrefix(path, NetworkMountRoot+"/") {
|
|
return true, refuseAppNamespaceUndeterminable
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// Refusal reasons for RefuseAsAppNamespace. Hungarian, adult tone, no emoji — these reach the customer
|
|
// through the deploy/migrate error surfaces. They name the storage class and what to do instead, never
|
|
// an internal path or field name.
|
|
const (
|
|
refuseAppNamespaceNetwork = "Hálózati tárhelyen (NAS) nem futtatható alkalmazás adatkönyvtára — " +
|
|
"a NAS megosztás tallózásra és médiatárolásra használható. Válasszon csatlakoztatott meghajtót."
|
|
refuseAppNamespaceUndeterminable = "A megadott tárhely nem azonosítható regisztrált meghajtóként, " +
|
|
"ezért alkalmazás adatkönyvtáraként nem használható. Válasszon a listából csatlakoztatott meghajtót."
|
|
)
|
|
|
|
// 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
|
|
}
|