Skip to content

Latest commit

 

History

History
600 lines (462 loc) · 35.7 KB

File metadata and controls

600 lines (462 loc) · 35.7 KB

Operations, security boundaries and recovery

Everything about running the container: what protects it, what constrains it, and how it recovers when the machine it runs on loses something.

Access control

The tool is single-user — no usernames, no account model, no permission model. What it has is an optional password gate in front of the whole UI.

Auth__Password (configuration key Auth:Password):

  • Unset or empty → authentication off, every endpoint open, with a Warning logged at startup: Authentication is disabled: Auth__Password is not set.
  • Set → a login is required.

The image sets no default. A default password is more dangerous than no password.

Decision Conclusion
Session mechanism a cookie signed by the Data Protection key ring
Password storage plaintext in the environment variable, no pre-hashing
Protection scope /api/* only; static assets are unprotected
Unauthenticated response 401, never a redirect
Session lifetime sliding expiry, 30 days

Rationale — a cookie rather than a localStorage token or HTTP Basic. The key ring already exists, so signing costs nothing new. HttpOnly puts the cookie out of reach of XSS, expiry and sliding renewal are built in, and logging out is just deleting it. A localStorage token is readable by XSS; HTTP Basic gives no custom login page and makes logging out awkward.

Rationale — 401 rather than a redirect. The frontend is an SPA using fetch, and a redirect would just hand fetch a page of HTML.

Rationale — no pre-hashing. It would require the user to run a tool to compute a hash before they could set a password: a disproportionate burden for a self-hosted single-user tool.

The cookie's SecurePolicy must be SameAsRequest

Never hard-coded to Always.

Rationale. The image listens on HTTP by default. Forcing Secure means the browser will not send the cookie back over HTTP at all, which presents as "login succeeds and immediately asks for login again" — a failure that is very hard to trace back from the symptom. SameAsRequest adds Secure under HTTPS and omits it under HTTP.

Middleware order and exemptions

UseCors → UseDefaultFiles → UseStaticFiles → [auth] → UseSecretUnavailableMapping → endpoints → SPA fallback

Authentication goes after static files (they are unprotected) and before the secret-unavailable mapping — decide whether they may come in, then handle business exceptions on the inside.

Exactly six endpoints are exempt: the two health probes, login, logout, auth status, and the SPA fallback. They are marked per endpoint, never on the group.

Rationale. An endpoint added to /api/auth later would otherwise silently inherit anonymous access. The list is pinned by tests.

Health probes must be exempt, or docker healthcheck and any orchestrator probe get a 401, the container is judged unhealthy, and it restarts in a loop — an availability failure caused directly by "improving security". But the exemption covers reachability, not information: the 200/503 of the readiness probe is unchanged, while its database and keyring booleans are returned only when authenticated. Otherwise any anonymous prober could read "this instance is in keyring recovery mode".

Brute-force resistance

  • Password comparison uses CryptographicOperations.FixedTimeEquals over UTF-8 bytes.
  • A failed login sleeps about one second, and that delay is serialised process-wide.

Rationale — why serialised. Sleeping one second per request independently does not work: with N requests in flight the amortised cost per attempt approaches zero. Serialised, N failures take N seconds of real time. Only the failure path serialises; a successful login never queues.

  • Every failed login logs a Warning with the source IP and never the submitted password.
  • No account lockout. On a single-user tool, lockout means locking yourself out.

The frontend

GET /api/auth/status decides one of three renderings: the main UI (no password set), the login page alone, or the main UI plus a Log out control. While unauthenticated, main-UI components are not mounted rather than covered by an overlay — components under an overlay still issue requests, producing a burst of 401 noise. Any 401 flips the app back to the login page, which covers a cookie expiring mid-use.

The API client sets credentials: 'include' throughout — a superset of the same-origin default, so same-origin behaviour is unchanged, while a cross-origin dev setup can log in at all.

Deployment note. Production should sit behind an HTTPS reverse proxy. Over plain HTTP both the password and the cookie travel in the clear; this gate stops people who do not know the password, not people who can sniff the traffic.

The local path boundary

Backup__Root constrains every local path operation — backup, restore, repair, browse.

  • Unset or empty → no boundary, behaviour identical to having none.
  • Set → the root's own real path is resolved once at startup and cached.

Resolving the root itself first is mandatory: if /nas is a symlink to /mnt/disk1, comparing the literal string against resolved real paths would reject every legitimate path.

The root is a security filter only. It does not rewrite paths, truncate them, or serve as the base for relative paths — storage, display and logs all carry the full original path.

Judging the boundary, segment by segment

  1. Normalise with Path.GetFullPath, removing .., . and repeated separators.
  2. Expand symlinks one segment at a time to reach the real path.
  3. Compare against the resolved root on segment boundaries, so /nasty does not pass by prefix-matching /nas. Equality with the root counts as inside.
  4. Cap the depth for symlink cycles; exceeding it is judged out of bounds, not an exception and not an infinite loop.
  5. For a path that does not exist, judge its nearest existing ancestor — a restore target may be a directory not yet created, and "does not exist yet" is not grounds for rejection.

Rationale — why not Directory.ResolveLinkTarget. .NET has no realpath, and that method resolves only the last segment: if /nas/link points at /etc, querying /nas/link/passwd returns null, because passwd itself is not a link. Relying on it misses every case where an intermediate segment is a symlink — precisely the shape most easily exploited. And "use symlinks to gather scattered directories into one place" is exactly the usage this feature is aimed at, so symlinks cannot simply be refused.

Where it is validated

Every operation, not just on save: creating a configuration, starting a backup, check, repair or cleanup, starting a restore (including when the target falls back to the local root), and the browse API for both the requested path and every child returned.

Rationale. The boundary means "no configuration may cross it regardless of where it came from", and configurations can come from an older version, a hand-edited database, or an arbitrary container imported through /import.

Existing out-of-bounds configurations are kept, not deleted. They still appear in the list, while backup, restore, check and repair all return 409 with path_outside_root, naming both the root and the rejected path. Startup is not blocked.

Browsing

GET /api/system/browse?path=... returns direct children only, lazily. Both directories and files come back; only directories are selectable, because both the local root and a restore target are directories by definition — while being able to see files is how you confirm you picked the right place.

Out-of-bounds children are returned with an outsideRoot flag rather than filtered out.

Rationale. If /nas/link → /etc simply did not appear, the user would be confused about a directory entry they can plainly see elsewhere. Returning it with a flag explains why it cannot be used.

A child that cannot be read is skipped while the rest are still returned, so one failure does not fail the request. Results are paged, and truncation is stated explicitly, never silently short.

Restore's own path-traversal defence is independent of this boundary and applies even when it is unset — see check-restore-repair.md.

Key ring loss and recovery

The Data Protection key ring under /keys encrypts three fields: the account key, the proxy password, and the backup password. Losing it makes all three undecryptable.

The design principle: store ciphertext, decrypt at the chokepoints.

Rationale — the root cause it replaced. A ValueConverter decrypted unconditionally at entity materialisation, regardless of whether the caller wanted the field. Listing accounts to read nothing but their names still called decrypt on every row's key. Once the key ring was gone that threw, no path caught it, and the account list and backup list both returned 500 wholesale — the user could not reach the UI at all, let alone repair anything.

The key observation is that the places which genuinely read these fields are very few; everything else is transport, and transporting ciphertext is exactly as good as transporting plaintext.

Consumer Chokepoint
account key, proxy password the blob client factory — the sole entry to every cloud operation
backup password the request mapper's password accessor, and one shared helper in the config endpoints

The three properties carry a Protected suffix while HasColumnName pins the original column names, so existing data needs no migration — what the ValueConverter wrote was already ciphertext.

Rationale for the rename. The compile errors from renaming are the list of call sites to audit.

Two things needed adjusting:

  1. "Is this an encrypted backup?" tested with !string.IsNullOrEmpty(Password) still works: non-empty ciphertext ⟺ non-empty plaintext.
  2. Comparing update.Password != existing.Password had to change. Data Protection uses a random IV, so the same plaintext encrypts differently each time and ciphertexts cannot be compared. That line meant "the password cannot be changed after creation", which is now enforced directly: the ordinary PUT rejects any non-empty password, and resets go through a dedicated endpoint.

The canary

A single-row table holds the ciphertext of a known constant, read and written with no converter, using explicit protect/unprotect — otherwise the canary itself would be swallowed by the degradation logic and lose all diagnostic value. A singleton holds Healthy | Lost, judged once at startup.

Canary row Probe source Conclusion
present, decrypts itself Healthy
present, fails, undecryptable ciphertext remains full scan Lost
present, fails, no undecryptable ciphertext remains full scan rebuild the canary, Healthy
absent lowest-id account's key decrypts → write canary, Healthy; fails → Lost
absent, no accounts lowest-id encrypted backup config as above
absent, neither exists brand-new database → write canary, Healthy

Rationale — the "absent" row is the mandatory branch for upgrading an older database. Blindly writing a new canary and declaring Healthy would miss "the key ring was already lost at upgrade time" — and miss it forever, since the new canary is written by the new key ring and will always decrypt. The probe deliberately takes the lowest-id row: FirstOrDefault without OrderBy has undefined ordering in EF, which would make the judgement irreproducible.

Rationale — the third row is a startup backstop that cannot be omitted. Consider "key ring lost → the user gives up and deletes every account and encrypted configuration". If the delete endpoints did not get to finish, there would be no ciphertext left in the database while a stale canary pins the status to Lost: readiness permanently 503, the scheduler skipping everything, every action 409, and the banner reading "0 credentials need to be re-entered" — with no way out until a restart passes through here.

What Lost mode allows

  • The scheduler skips every task, logging one summary Warning per tick, not one per task.
  • Manual backup, restore, check and cleanup return 409 with code keyring_lost.
  • The account and backup configuration lists still return, carrying secretsUnavailable: true.
  • Readiness returns 503 degraded.
  • The only permitted write is a credential reset.

The pending count and per-row flags are computed from each record's actual decryptability, never from the global status.

Rationale — this is what stops the recovery flow deadlocking. "Lost means nothing decrypts" holds only at the instant of loss. Recovery necessarily passes through a state where every account has been reset while backup passwords are still old ciphertext. The global status must still be Lost there, but the account pending count must already have reached zero — because the UI keeps backup-password resets disabled until accounts reach zero. Counting from the global status would make that count permanently non-zero: the button would never enable, the password could never be reset, and the status could never flip.

When Healthy this short-circuits to zero, so the list endpoints still trigger no decryption at all.

The reset flow

POST /api/accounts/{id}/reset-secrets       { accountKey, proxyPassword? }
POST /api/backup-configs/{id}/reset-password { password }

Verify before persisting. Accounts use the existing connection test. Backup passwords are verified by fetching the encrypted info file from the cloud and decrypting it.

Rationale. The info file of an encrypted backup is itself a 7z encrypted with that password. It is the metadata root of the whole backup, the smallest encrypted object in the container, and touching it neither reads data packs nor triggers Archive retrieval fees.

Verification must use the plain read path, not the tracked store's seed-from-cloud method: it is an operation that may fail and be retried repeatedly, and it must have no side effects.

Recovery order is accounts first, then backup configurations, enforced by the UI — verifying a backup password requires the cloud, and reaching the cloud requires the account key.

The completion check probes every record holding ciphertext and only rebuilds the canary once all succeed. Flipping on the first successful reset would be wrong; the rest still do not decrypt.

The login gate sits outside the keyring gate

Password comparison reads the plaintext environment variable and never touches the key ring, so login still works while the ring is Lost. The cookie is signed by the ring, so losing /keys invalidates existing sessions and requires one fresh login.

Rationale. Putting the login gate after the keyring guard, or making login depend on the ring, creates a deadlock: recovery requires logging in, and logging in requires recovery. The correct sequence is: ring lost → log in again → enter the system → see the recovery banner → reset credentials one by one.

No escape hatch for a forgotten backup password

There is no "abandon history and use a new password", and no re-encryption migration of historical packs. A forgotten password means deleting that configuration and starting over.

7-Zip CPU priority

Compression and extraction are the only things this program does that saturate a CPU, and it runs on a NAS that is also running a media library, a photo indexer and somebody else's containers. A backup is background work: nobody notices it being slower, everybody notices the machine stalling.

public enum SevenZipCpuPriority { Lowest = 0, BelowNormal = 1, Normal = 2 }
Value ProcessPriorityClass Linux nice
Lowest (default) Idle 19
BelowNormal BelowNormal 10
Normal Normal 0

There is no "above normal": raising priority on Linux requires privileges, and letting compression outrank the web UI has no upside for a background backup program.

Rationale — why priority and not just thread count. -mmt=N was already adjustable through an environment variable, but that requires a container restart, and capping threads reduces parallelism, not scheduling weight under contention. One saturated thread can still make the UI stutter.

Rationale — Lowest must be 0 in the enum. The EF migration fills existing rows with 0, so an upgraded database lands on "lowest" naturally, matching the default. The counter-example is StagedLimitBytes and ProcessingMaxAttempts, whose valid defaults are not 0 — which is why the settings service still carries a "if it reads 0, substitute the default" patch. Defining Lowest as 0 avoids incurring that debt again.

The setting is passed as a delegate rather than a value, so saving a change applies to the next 7z process without restarting the container. It reaches every 7z invocation: backup compression including the streaming path, restore extraction, deep check, repair, dead-weight compaction, and index encoding/decoding.

Two traps that must stay in the comments.

Failing to set priority is swallowed unconditionally. The process may already have exited in those few microseconds, and the platform may refuse. Not being able to lower priority is not a compression failure and must never take a backup down with it.

On Linux, nice is a per-thread attribute. setpriority(PRIO_PROCESS, pid) lands on the main thread only, and 7z's LZMA workers inherit the nice value of the thread that created them, at creation time. Setting it the instant Process.Start returns means 7z is still dynamically linking and parsing arguments with no workers created yet, so in practice they all inherit it. The worst case — losing that race — is that some threads stay at the old priority: no effect on correctness, only on effectiveness.

This setting is about the CPU and nothing else. nice reaches the CPU scheduler; it has no bearing on the block-IO queue. The opening paragraph of this section — "everybody notices the machine stalling" — describes a symptom that in practice is usually disk contention rather than CPU contention, and for that this knob does nothing. See Disk priority below.

Disk priority

Backup__IoPriority (Normal (default) / Low / Idle) lowers the block-IO priority of the whole process. It exists because the stall an operator actually reports — a directory listing over SMB taking seconds while a backup runs — is contention for the disk, and the CPU knob above cannot touch it.

Whole process, not the 7z child. The reads competing for the array are four different things on three threads: the diff, the dedup probe's whole-file reads, the uploaders reading out of the staging pool, and 7z itself. Lowering only the child would leave three of the four at full priority. 7z is forked from a pool thread and inherits along with everything else, so it needs nothing of its own.

That forces the shape. IO priority is per-thread and inherited from the creating thread, exactly like nice, so the only moment it can be made to cover the thread pool is before the pool has any threads — the call therefore runs before WebApplication.CreateBuilder. Which in turn is why the level is an environment variable and not a GlobalSettings row: at that point there is no database, and a value changed later would reach none of the threads already reading.

Lowering the uploaders' reads along with everything else is accepted rather than worked around: both classes below only yield when something else wants the disk, so an otherwise idle array charges nothing for it.

It is a request, and two common situations make it a no-op. Neither is detectable from inside the process, which is why the outcome is written to the startup log every start rather than assumed.

Only BFQ acts on it. Under mq-deadline, kyber or none the syscall succeeds and the value is never consulted again. cat /sys/block/<dev>/queue/scheduler shows the one in force, in brackets, and whether BFQ is offered at all — many NAS and virtualised kernels do not ship it.

It is block-device IO only. Reads from an SMB/CIFS or NFS mount never enter a block-IO queue on this machine, so no IO priority can rank them; the same applies to a virtual disk in a VM, where the guest merely orders requests among themselves before they funnel through one device to a host that schedules by its own rules.

Where neither holds, the lever that does work regardless of scheduler is an application-level limit on how fast this process reads — which costs backup throughput by construction, and is not built.

Implementation note. glibc exports no ioprio_set wrapper, so the call goes through syscall(2) with an architecture-dependent number (x86-64 251, arm64 and the other generic-table architectures 30). An architecture not on the list gets nothing rather than a guessed number — a wrong syscall number is not a failed call, it is a different call. Neither level needs privileges: lowering your own priority is always allowed, and IOPRIO_CLASS_IDLE dropped its privilege requirement in 2.6.25.

Temp space

Backup__TempPath is where everything a run stages lands: the compression intermediates and staged volumes, the 7z codec's extraction directories, the index staging the info store and the catalogs' cloud import share, and the per-run work databases. Everything under it is scratch — a normal finish deletes its own, and startup sweeps whatever a killed process left.

The part that scales with the backup is the run's work database, at roughly 500 bytes per scanned file. Budget about twice that while a run is in flight: the diff reads the scan through a cursor, and that cursor pins a read snapshot for as long as it is open, so SQLite cannot restart the write-ahead log underneath it. Every draft row the diff writes therefore accumulates in -wal alongside the scan rows already in the file, and the pair only collapses back to one copy when the diff's cursor closes. Both go when the run ends, whichever way it ends.

Memory

A run's memory is bounded by the pipeline's width rather than by the size of the backup: version indexes are answered as queries against the container's catalog, and everything the run itself accumulates — the scan, the draft of the new version, its dedup reservations, an adopted journal's records, the pack leader map — lives in scratch databases on disk (storage-format.md). What is left in memory that still grows with the file count is small, transient, and measured.

The one deliberately sized buffer is the upload path's: a volume small enough is read whole into memory to be hashed and sent from that buffer, one per upload stream. The global Upload memory limit (1 GB by default) caps that product per task — each backup, repair and compaction splits it across its own streams, and a volume past its share is hashed from disk and re-read for the send instead (volume-identity.md, "Writing the label").

Measured with one backup run per row over a synthetic tree of unique-content files, sampling Process.WorkingSet64 and the managed heap every two seconds (MemoryBenchmarkTests, which the suite runs only under ASB_BENCH=1). These columns cover the backend process only — the compressor is a child process and is sized separately below. Two runs at each size, shown as first / second:

Files in the run Peak working set Peak managed heap Live heap (forced collection) Working set after the run
100,000 385.6 / 371.7 MB 47.5 / 38.9 MB 32.5 / 32.7 MB 308.1 / 306.1 MB
200,000 478.4 / 479.6 MB 94.3 / 58.2 MB 47.0 / 47.5 MB 378.9 / 389.4 MB

…and again after the work database's write channel was bounded (one run at each size):

Files in the run Peak working set Peak managed heap Live heap (forced collection) Working set after the run
100,000 375 MB 39 MB 32 MB 288 MB
200,000 501 MB 92 MB 39 MB 379 MB

The two heap columns answer different questions. Peak managed heap is whatever the heap happened to measure, garbage the collector had not got to included. Live heap is read immediately after a full blocking collection, so it is what the run is genuinely holding — and it is the column any claim about scaling has to be judged on. It is sampled every ten seconds rather than continuously, so it catches the peak only approximately: differences of a few MB between rows are the instrument, not the build. It still rises with the file count, and the write channel turned out to be about half of it. On the unbounded channel the live heap grew 14.5 MB per additional 100,000 files — roughly 150 bytes per file once the process's ~18 MB fixed floor is subtracted. With the channel bounded at 16k operations the same measurement gives 7 MB per additional 100,000 files, about 70 bytes per file: the backlog of queued write closures was real per-file live data at the moment of peak, and capping it removed roughly half the residue. What is left is someone else's, and unattributed. Read the difference for what it is — one run at each size against a sampler that fires every ten seconds, so a few MB either way is the instrument; the direction and the halved slope are the finding, not the third decimal. Post-run heap and post-run working set landed inside the spread of the earlier pairs. Peak working set at 200,000 files did not: 501 MB against 478.4 / 479.6 MB before. Peak working set is the noisiest column here (it counts whatever the allocator had not returned yet, and the 100,000-file row moved the other way), but it is the one the 400 MB post-run budget's headroom is judged from, so it is worth a second look if it stays high.

Nothing survives the run. Post-run managed heap sits at 18–21 MB regardless of file count, so whatever holds the per-file share at peak is transient. The working set after a run is higher than that because of native residue — the SQLite page caches and the allocator's own leftovers from a few hundred thousand inserts — and it is the figure worth watching: ~380 MB after 200,000 files, against a 400 MB budget for this benchmark, is a margin of 11–21 MB.

The compressor is a second process, and it is the larger one

Every figure above is the backend process. Compression runs in a separate 7zz process, so none of it appears in those columns — and it shares the container's cgroup, so it counts in full against mem_limit and against whatever the OOM killer reads. Size the container as the working set above plus the figure below, never as the working set alone.

7-Zip reduces its dictionary to the size of the input it is handed, so a pack costs in proportion to what is in it, up to -mx9's 64 MB dictionary. Measured with /usr/bin/time -v against 7zz 26.02 (x64, 8 threads), invoked as SevenZipCompressor.CompressAsync invokes it — one pack, members passed as relative paths in argv:

Members Pack bytes 7zz peak RSS
500 2 MB 43 MB
2,000 9 MB 133 MB
6,000 29 MB 346 MB
14,000 68 MB 771 MB

Past 64 MB of input the dictionary stops growing and the peak settles with it, so the default GroupCapBytes of 100 MB (packing.md) puts every full pack at roughly 800 MB. Per-member metadata is the small term: at ~1.3 KB each it is 18 MB at the 14,000 members above, and MaxPackMembers caps it near 26 MB.

The single-file route arrives at the same ceiling by a different road — CompressStreamAsync sizes -md from the length it stat'ed and caps that at 64 MB — so the peak is no different for a large file. What differs is how often it is paid. A large file holds one such allocation for the length of its compression; a run over many small files takes a fresh one per pack, back to back, for as long as the run lasts. On a host already near its memory ceiling that is the difference between one reclaim and a few thousand, and it is the first thing to weigh when a small-file backup makes the whole machine slow while a large-file one does not.

Rationale — why this is written down rather than capped. The dictionary is what makes packing worth doing: a 64 MB window is how one solid block finds matches across its members, which is the entire reason small files are merged. Lowering it trades compression ratio for resident bytes, and which side of that trade is right depends on the host, not on us. Backup__SevenZipMethodArgs is the lever — an explicit -md= is honoured by both compression paths, the streaming one deliberately standing aside for it — so the default is left where 7-Zip puts it. This is a number to know before setting mem_limit, not a default to change.

Rationale — why the process runs workstation GC and forces one collection per run. A backup's peak is not its steady state: the scan, the diff and the index write each touch a great deal of memory briefly and let go of it, and the large object heap fragments badly under that pattern. An idle process never collects on its own, so a container that finished — or suspended — a backup an hour ago would sit at its high-water mark, which is what the operator sees in the process list and what the OOM killer counts. BackupRunner.ReleaseRunMemory therefore runs one aggressive, compacting, blocking collection at the end of every run, at the single moment the run's own working set is provably dead. Aggressive mode also decommits, which is the half that moves the number outside the runtime and not merely inside it. Server GC is off for the same reason: this process shares a NAS with everything else on it.

Rationale — the trade the current design makes. Against the build before the catalog, peak managed heap at 100,000 files is 47.5 MB where it was 322.4 MB, and at 200,000 files 94.3 MB where it was 550.0 MB — five to nine times lower depending on the run, and none of what is left is a cross-version index. The working set after a run went the other way: that build settled at 176.6 / 191.4 MB, because live managed data has been traded for native residue. The last step of that trade is measured directly — moving the pack leader map out of the heap and into its own database cut the live heap's growth from +55.6 to +14.5 MB per 100,000 files and removed ~70 MB of live data at peak, at the price of 55–66 MB more post-run native residue, measured as an A/B of the two builds in one session at 200,000 files. Any future change to native allocation on this path should re-measure the last column rather than assume the margin is still there.

The pack leader store's page cache stays at 16 MiB. Sweeping it over 4, 16 and 64 MiB moved run duration not at all (73.7 / 73.9 / 73.7 s at 200,000 files — the access pattern is one probe per file over keys with no locality, and a cache four times too small misses at nearly the rate of one sixteen times too small), while the post-run working set tracked it upwards and reached 415.0 MB at 64 MiB, over the budget above. Below 16 MiB the gain is inside the noise.

Durations in this benchmark are not comparable across sessions. The same build measured ~10 s apart at both file counts in two different sessions on the same machine, while two builds differing only by the leader-map change measured within noise of each other when run in the same session. Any duration question has to be answered by a same-session A/B; the memory readings, by contrast, reproduce to within a few MB.

7-Zip binary

The image fetches the official 7zz binary for the target architecture at build time, not the distro package.

Rationale. p7zip and 7-Zip 23.01 write a zero attribute for -si stdin input, which makes single-file blobs unrestorable. This was measured, not assumed.

Commands: 7zz a -p{pwd} -mhe=on -v{size} out.7z ... for AES-256 with header encryption and volume splitting; 7zz x out.7z.001 to extract.

Settings storage

SQLite holds accounts, groups, schedules, defaults and logs.

Rationale. Logs need filtering by level, time and source, and schedules need querying — a database fits better than files, and the skeleton already had EF Core plus SQLite.

The info file is a separate thing, stored in the Azure container, not in the local database.

Secrets are stored reversibly encrypted with the Data Protection key ring; everything else is stored in the clear.

Environment variables

Variable Effect
Auth__Password the UI password gate; unset means open
Backup__Root the local path boundary; unset means unrestricted
Backup__SevenZipMethodArgs extra 7z arguments, e.g. -mmt=N
Backup__IoPriority block-IO priority for the whole process; see Disk priority above, including the two cases where it does nothing
Scheduler__Enabled whether the background scheduler runs
ASB_FCNTL_TRACE 1 preloads a shim that writes every refused fcntl byte-range lock (any file) and every lock call on a catalog.db to the container log, with the errno; diagnostic only, see SQLite locks below

Azure credentials are not configured through environment variables — each storage account is added in the UI and its key is encrypted at rest.

Backup__Root constrains paths inside the container, so it works together with volume mounts: mount every host directory you want to back up beneath that root.

SQLite locks

The catalogs and a run's work database are opened through SQLite's unix-excl VFS: no -shm file, no byte-range locks on one, the WAL index in the process's heap (storage-format.md § The catalog). That is the fix for a failure that could not be explained from outside the process: on a QNAP QuTS hero NAS (kernel 6.6.32-qnap, ZFS) every 2026.9.8.1 run failed at the same catalog import with SQLite Error 15: 'locking protocol', the writer holding the WAL write lock for ten seconds while the kernel refused exclusive locks on the read-slot bytes of catalog.db-shm that /proc/locks showed nobody holding. The same library, directory and statement sequence succeeded from a python process in the same container, and a plain fcntl replay of SQLite's lock sequence succeeded too, so the refusal is specific to something in the application process that no probe has reproduced. Since the NAS is offline, the errno of the refused call was never captured.

If the error ever comes back, start the container with ASB_FCNTL_TRACE=1: the image carries a small LD_PRELOAD shim (docker/fcntlspy.c) that logs each refused lock call as fcntlspy fd=… cmd=… type=… start=… len=… rc=-1 errno=… <path> — the one number this investigation was missing. It costs a readlink per lock call and is off by default.

app.db (the application database, through EF Core) is not affected by any of this; it still uses the default VFS and its -shm file, and has never shown the failure.

Shutdown timing

docker stop_grace_period 45s  >  HostOptions.ShutdownTimeout 30s  >  waiting for runs to flush 20s

The three form one chain, and changing any one means revisiting the other two. The reasoning is in run-lifecycle.md.

See also