Everything about running the container: what protects it, what constrains it, and how it recovers when the machine it runs on loses something.
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.
HttpOnlyputs 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 handfetcha 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.
Never hard-coded to Always.
Rationale. The image listens on HTTP by default. Forcing
Securemeans 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.SameAsRequestaddsSecureunder HTTPS and omits it under HTTP.
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/authlater 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".
- Password comparison uses
CryptographicOperations.FixedTimeEqualsover 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.
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.
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.
- Normalise with
Path.GetFullPath, removing..,.and repeated separators. - Expand symlinks one segment at a time to reach the real path.
- Compare against the resolved root on segment boundaries, so
/nastydoes not pass by prefix-matching/nas. Equality with the root counts as inside. - Cap the depth for symlink cycles; exceeding it is judged out of bounds, not an exception and not an infinite loop.
- 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 norealpath, and that method resolves only the last segment: if/nas/linkpoints at/etc, querying/nas/link/passwdreturns null, becausepasswditself 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.
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.
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 → /etcsimply 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.
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:
- "Is this an encrypted backup?" tested with
!string.IsNullOrEmpty(Password)still works: non-empty ciphertext ⟺ non-empty plaintext. - Comparing
update.Password != existing.Passwordhad 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 ordinaryPUTrejects any non-empty password, and resets go through a dedicated endpoint.
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
Healthywould 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:FirstOrDefaultwithoutOrderByhas 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.
- 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. "
Lostmeans 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 beLostthere, 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.
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.
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.
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.
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=Nwas 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 —
Lowestmust 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 isStagedLimitBytesandProcessingMaxAttempts, whose valid defaults are not 0 — which is why the settings service still carries a "if it reads 0, substitute the default" patch. DefiningLowestas 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 instantProcess.Startreturns 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.
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,kyberornonethe syscall succeeds and the value is never consulted again.cat /sys/block/<dev>/queue/schedulershows 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_setwrapper, so the call goes throughsyscall(2)with an architecture-dependent number (x86-64251, arm64 and the other generic-table architectures30). 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, andIOPRIO_CLASS_IDLEdropped its privilege requirement in 2.6.25.
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
-sistdin 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.
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.
| 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 |
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.
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.
- run-lifecycle.md — graceful shutdown and automatic resume
- check-restore-repair.md — restore's own path-traversal defence
- configuration.md — what is configured per backup rather than per deployment
- web-ui.md — the login page and the recovery banner