Architecture
Next.js 16 (App Router) as a single process, runs as root natively on Debian 13 (systemd service bunkerbox, installed under /appdata/bunkerbox, port 80). Docker is only used for the users' apps.
MVC layers
src/
app/ Routing only. Pages and route.ts are thin and only call controllers or views.
login/page.tsx -> views/auth/LoginView (public)
(admin)/layout.tsx session check + views/shell/AdminShell (sidebar, header, footer)
(admin)/page.tsx -> views/dashboard/DashboardView
(admin)/<section>/** -> views/<section>/**
api/**/route.ts -> controllers/* (through handler() from controllers/http.ts)
proxy.ts access control for everything (session, Origin check)
instrumentation.ts builds the runtime when the server starts
models/ M: domain types, DTOs and persistence
types.ts core types (Msg, Settings, UpdateStatus, ActivityItem, auth DTOs)
<feature>.ts types of one feature (storage, users, shares, mounts, apps, dashboard)
validation.ts strict name and path patterns for all features
JsonStore.ts atomic JSON writes (tmp + fsync + rename)
JsonSettingsRepository data/settings.json (600)
JsonSessionRepository data/sessions.json (600), key = sha256 of the token
services/ domain logic and system access (part of the model layer)
contracts.ts core interfaces, ServiceError, Runtime, ServiceContext
core.ts singletons for settings and authentication (also used by the proxy)
runtime.ts composition root, singleton on globalThis
auth/ login: AuthService, Linux/mock adapters, sessions, rate limit, Origin check
<feature>/ contract.ts, Default<F>Service, Linux<F>System, Mock<F>System, index.ts (factory)
system/exec.ts starts programs (args arrays only, never a shell)
HttpUpdateChecker update check and start
controllers/ C: validation, mapping, HTTP errors
views/ V: React client components, talk to /api/* via fetch
i18n/ translations (de, en) and t/msg/render
Rules:
app/contains no logic.- Controllers only know
getRuntime()(orcore.ts) and types frommodels/. - Views only know types from
models/and talk to/api/*viafetch. No imports fromservices/orcontrollers/inviews/. - System access exclusively through the feature's system interface (
<F>System) andexec().
Feature modules (feature convention)
Every feature is its own module. The shared registration points (runtime, sidebar, i18n index, contracts) are pre-wired; a feature only changes its own files.
| Part | Path |
|---|---|
| types/DTOs | src/models/<feature>.ts (plus own repositories src/models/<Feature>*.ts) |
| contract | src/services/<feature>/contract.ts (service interface fixed, system interface free to extend) |
| service | src/services/<feature>/Default<Feature>Service.ts |
| system access | src/services/<feature>/Linux<Feature>System.ts, Mock<Feature>System.ts, pure parsers/generators next to them |
| factory | src/services/<feature>/index.ts (create<Feature>Service(ctx)) |
| controller | src/controllers/<Feature>Controller.ts |
| routes | src/app/api/<feature>/** |
| pages | src/app/(admin)/<feature>/** (dashboard: src/app/(admin)/page.tsx) |
| views | src/views/<feature>/**, fetch client src/views/<feature>/api.ts, own styles as CSS modules |
| texts | src/i18n/messages/{de,en}/<feature>.ts, keys start with <feature>. |
| tests | test/<feature>/*.test.ts |
| docs | docs/<page>.md and site/i18n/en/docusaurus-plugin-content-docs/current/<page>.md |
Features: storage (with the extensions spindown and burnin in src/services/storage/spindown|burnin/), users (users & groups), shares, mounts (remote mounts), apps (Docker), dashboard, virtualization (Incus), backup, notifications (e-mail), ups (NUT), security (HTTPS, 2FA), audit (audit log), logs (system log), templates (app templates), files (file manager), terminal (browser terminal), network.
Dashboard cards of the newer features live in src/views/<feature>/DashboardCard.tsx (they fetch their own data from /api/<feature>/summary); features report events and failures through notifications.notify().
Every service extends FeatureService: start() starts background tasks (once, idempotent), activity() reports long running operations (ActivityItem). While runtime.activity() is not empty, the interface and update.sh refuse updates. A service reaches other features through ctx.runtime() (only inside methods, never in the constructor); e.g. the dashboard uses storage.overview(), shares.connections() and apps.summary().
Expected errors are ServiceError(msg("<feature>.error.<reason>", params), status); handler() turns them into { error, key, params } in the request language. Everything else is logged as 500.
Authentication and access control
- Only members of the Linux group
bunkerbox-admin(ADMIN_GROUP) can log in; normal NAS users never get into the interface or the API. - Password check (
LinuxAuthAdapter): the service reads/etc/shadowas root, refuses locked (!,*), empty and expired accounts and compares the password withcrypt(3)of the system libxcrypt viaperl -e(yescrypt on Debian 13, but any scheme the system supports). Hash and password are passed on stdin, never as arguments (arguments are visible to everyone via/proc/<pid>/cmdline). Reasons: perl is present on every Debian, no native Node modules, no extra PAM helper. Group membership (supplementary group or primary GID) comes from/etc/groupand/etc/passwd. - Mock (
MockAuthAdapter):admin/admin. - Sessions (
DefaultAuthService): 32 random bytes as token, cookiebb_session=<token>.<hmac>(HMAC-SHA256 withdata/session-secret, 600),HttpOnly,SameSite=Strict,Path=/,Securebehind HTTPS. Stored server-side indata/sessions.jsonunder the sha256 of the token. Idle timeout (default 30 min) and absolute lifetime (default 12 h) from the settings.logoutUser(name)ends all sessions of a user (password change, role revoked, disabled, deleted). - Second factor (TOTP): the
securityfeature registers aSecondFactorPolicywith theAuthService. When it requires a second factor, login creates a session withpendingSecondFactor; untilPOST /api/auth/2fa/verify(callscompleteSecondFactor()) it can only reachSECOND_FACTOR_PATHS(/login,/api/auth/me,/api/auth/logout,/api/auth/2fa,/api/auth/2fa/verify,/api/auth/2fa/enroll,/api/auth/2fa/confirm). Proxy, admin layout and gateway refuse such sessions everywhere else. - Rate limit: 5 failures per client address or 10 per user name within 15 min block for 15 min (429 with
Retry-After). The address comes fromX-Forwarded-For; since it can be forged behind a reverse proxy, the per-user-name limit applies as well. Every failure waits 400 ms. A wrong password and missing admin rights give the same message. - Proxy (
src/proxy.ts, Node.js runtime): public are only/login,/api/auth/login,/api/auth/logout,/api/healthand static files. Everything else needs a valid session: pages redirect to/login?next=…, API routes answer 401. The proxy removes a client-supplied headerx-bunkerbox-userand sets it to the logged-in user (currentUser(request)in controllers). The admin layout checks the session again. - Client address: route handlers cannot see the socket. The service therefore preloads
server/preload.cjs(node --require ./preload.cjs server.js), which passes the peer of every request on asx-bunkerbox-peerand the protocol asx-bunkerbox-proto(client-supplied values are overwritten).clientIp()/isHttps()insrc/controllers/http.tsonly honourX-Forwarded-*when that peer is listed inBUNKERBOX_TRUSTED_PROXIES(src/services/auth/clientAddress.ts). - CSRF: for changing API requests (not GET/HEAD/OPTIONS),
Originmust match the host (orX-Forwarded-Host); withoutOriginonly withSec-Fetch-Site: same-origin. In additionreadJson()requiresContent-Type: application/json(forces a CORS preflight). - Security headers:
X-Frame-Options: DENY,frame-ancestors 'none',nosniff,Referrer-Policy: same-origin.
Server layer: preload, WebSocket gateway, HTTPS
server/preload.cjs (no dependencies, shipped next to server.js; in development loaded through NODE_OPTIONS by scripts/dev-mock.mjs) wraps the Next.js HTTP server:
- sets
x-bunkerbox-peerandx-bunkerbox-protoon every request, - hands WebSocket upgrades below
/api/ws/to the gateway (globalThis[Symbol.for("bunkerbox.ws.upgrade")], registered by the runtime); other upgrades (HMR) stay with Next.js, - optionally starts an HTTPS listener on a second port serving the same app. Configuration
data/server.json(ServerConfigFile:https.enabled,port,certFile,keyFile,redirectHttp), read at start and switchable at runtime throughserverControl().applyTls()(src/services/gateway/contract.ts) without a restart. Invalid certificates keep the previous state. WithredirectHttpport 80 redirects to HTTPS (308), except for loopback clients (health check ofupdate.sh).
Gateway (src/services/gateway/): DefaultGateway checks every upgrade like the proxy does: Origin must be present and match the host, a valid admin session (bb_session) without a pending second factor, a known channel, at most 16 connections per user. Open connections are re-validated against the session every 30 s (logout or expiry closes with 4401). frames.ts (RFC 6455, pure, tested) and socket.ts (ping/pong, fragmentation, 1 MiB per message, heartbeat) need no third-party packages.
| API | Meaning |
|---|---|
runtime.gateway.register({ name, open(socket, request) }) | register channel /api/ws/<name> (in the feature's start()) |
WsRequest | user, ip, locale, params (query, unvalidated), secure, userAgent |
WsSocket | send(string | Uint8Array), sendJson, close(code, reason), onMessage, onClose, onDrain, bufferedAmount |
errors in open() | ServiceError -> { type: "error", error, key, params } and close code 4000 + status |
| browser | openChannel(name, params) from src/views/ws.ts |
Terminal protocol (src/models/terminal.ts, also used by the Incus console): output as binary frames, input as text frames { type: "input", data } and { type: "resize", cols, rows }, control messages ready, exit, error. Pseudo terminals without native modules: script -qfec (util-linux), size via stty -F /dev/pts/N; in mock mode an echo terminal (MockPty, also on Windows). Other features use runtime.terminal.spawn(spec) and runtime.terminal.bridge(socket, request, pty, target).
Audit log and notifications (cross-cutting)
- Audit:
handler(fn, { audit: "<feature>.<object>.<action>" })insrc/controllers/http.tswrites an entry after every mutating request (POST, PUT, PATCH, DELETE): user, address, action, target, resultok/failed/denied, status, error key. The target defaults to the route params;auditedJson(request, result, status)takesresult.name,auditBodyTarget(request, body, field)a body field,auditTarget(request, target, details)sets it directly.{ audit: false }only for pure checks and routes that record themselves (login).test/core/audit.test.tsrequires the option on every mutating route. Other actions (terminal sessions, second factor) callruntime.audit.record(input). Never put secrets intotargetordetails. - Notifications:
runtime.notifications.notify({ type, severity, key, subject, body, details })is fire and forget (never throws, never blocks). Event types areNOTIFICATION_EVENT_TYPES(src/models/notifications.ts);keyis the dedupe key (e.g.pool-degraded:tank). Subject and body areMsgvalues rendered in the configured language. SMTP account, recipients and toggles live indata/notifications.json, the password indata/secrets/smtp-password(both 600), not inbunkerbox.env.
API (core)
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login | LoginRequest -> MeResponse + cookie; 401, 429 |
| POST | /api/auth/logout | ends the session, clears the cookie |
| GET | /api/auth/me | MeResponse |
| GET | /api/health | public: HealthResponse { ok, version, mock, activeOperations } |
| GET/PUT | /api/settings | SettingsResponse; PUT with partial settings (language, autoUpdate, sessionIdleMinutes 5..1440, sessionAbsoluteHours 1..168) |
| GET | /api/update | UpdateResponse { update, activity, autoUpdate } |
| POST | /api/update | StartUpdateRequest -> 202; 409 for a wrong version, not installable, running update or activity |
Errors: JSON { error, key, params }. error is translated into the request language (cookie bb_lang, else Accept-Language, else Settings.language).
Feature APIs
Every feature maintains only its own section.
Storage
| Method | Path | Description |
|---|---|---|
| GET | /api/storage | StorageOverview (ZFS available, disks, pools, ARC) |
| GET | /api/storage/disks | Disk[] |
| POST | /api/storage/disks/refresh | read SMART now -> Disk[] |
| GET | /api/storage/disks/[id] | DiskDetail with SMART attributes |
| POST | /api/storage/layout/check | { layout, pool? } -> LayoutCheck (with pool: as an extension of that pool) |
| GET | /api/storage/layout/suggest?disks=a,b | LayoutSuggestion[] |
| GET/POST | /api/storage/pools | Pool[] / CreatePoolRequest -> 201 Pool |
| GET/DELETE | /api/storage/pools/[name] | Pool / { confirm } |
| POST | /api/storage/pools/[name]/export | export |
| POST | /api/storage/pools/[name]/vdevs | AddVdevRequest |
| POST | /api/storage/pools/[name]/replace | ReplaceDiskRequest |
| POST | /api/storage/pools/[name]/offline, …/online | DeviceStateRequest |
| POST | /api/storage/pools/[name]/scrub | `{ action: "start" |
| PUT | /api/storage/pools/[name]/scrub-schedule | `{ schedule: ScrubSchedule |
| GET/POST | /api/storage/import | ImportablePool[] / ImportPoolRequest -> 201 Pool |
| GET/POST | /api/storage/datasets?pool= | Dataset[] / CreateDatasetRequest -> 201 |
| GET/PATCH/DELETE | /api/storage/datasets/[...name] | Dataset / UpdateDatasetRequest / DeleteDatasetRequest |
| PUT | /api/storage/snapshot-schedules/[...name] | `{ schedule: SnapshotSchedule |
| GET/POST/DELETE | /api/storage/snapshots?dataset= | Snapshot[] / CreateSnapshotRequest / { name } |
| POST | /api/storage/snapshots/rollback | RollbackSnapshotRequest |
| GET/PUT | /api/storage/arc | ArcOverview / ArcConfigRequest -> ArcConfig |
Structure: DefaultStorageService (rules, typed confirmation, 2 min SMART cache, schedules every minute, ARC applied at start), LinuxStorageSystem (lsblk, /dev/disk/by-id, smartctl -j -n standby, zpool/zfs with -j and -Hp text fallback, arcstats, sysfs, modprobe.d), MockStorageSystem (example hardware in memory). Pure modules: parsers.ts, layout.ts (check, suggestion, zpool arguments), disks.ts (usage, system disk, by-id mapping), datasets.ts (properties), schedule.ts, arc.ts. Schedules and the ARC override live in data/storage-state.json (600, JsonStorageRepository). Before destroying or exporting, the service asks shares.sharesBelow().
Users & groups
| Method | Route | Result |
|---|---|---|
| GET / POST | /api/users | NasUser[] / new NasUser (201) |
| GET / PATCH / DELETE | /api/users/[name] | NasUser; DELETE with { confirm: name } |
| PUT | /api/users/[name]/password | { ok: true }, sets the Linux and the Samba password |
| GET / POST | /api/groups | NasGroup[] / new NasGroup (201) |
| GET / PATCH / DELETE | /api/groups/[name] | NasGroup; DELETE with { confirm: name } |
DefaultUsersService works on a snapshot of the account databases (AccountTables: passwd, group, locks from shadow, Samba accounts from pdbedit -L -v, group descriptions from data/groups.json). The pure functions in services/users/accounts.ts build NasUser/NasGroup from it and compute group lists that keep unmanaged groups (sudo, docker). LinuxUsersSystem runs useradd -M -N -g users -d /nonexistent -s /usr/sbin/nologin, usermod, userdel, groupadd, gpasswd -M, groupdel, chpasswd and smbpasswd -s with args arrays, passwords only on stdin. MockUsersSystem keeps the same tables in memory. Changes run one after another; deleting checks access rules through SharesService.list(). The controller prevents deleting, disabling and demoting yourself.
Shares
| Method | Path | Description |
|---|---|---|
| GET | /api/shares | Share[] (with status) |
| POST | /api/shares | CreateShareRequest -> 201 Share |
| GET/PATCH | /api/shares/[name] | Share; PATCH with UpdateShareRequest |
| DELETE | /api/shares/[name] | { confirm: <name> }; the data stays |
| POST | /api/shares/[name]/permissions | write ACLs recursively in the background -> 202 (activity shares-acl:<name>) |
| POST | /api/shares/apply | rewrite the configuration, reload the services -> ShareServicesStatus |
| GET | /api/shares/services | ShareServicesStatus (Samba, NFS) |
| GET | /api/shares/paths | SharePathOption[]: mounted datasets (storage.listDatasets()) and remote mounts |
| GET | /api/shares/connections | ShareConnections (smbstatus --json, /proc/fs/nfsd/clients) |
| GET | /api/shares/preview | ShareConfigPreview: generated include and exports file |
- Storage:
data/share-definitions.json(600) throughJsonShareRepository, plus the ACL rules last written per share. - Pure generators in
services/shares/:smbConfig.ts(include,includeline, testparm evaluation),nfsExports.ts,acl.ts(setfacl/find commands),parsers.ts,validation.ts. Generators validate every share again before they output anything. - Flow of every change (serialized): validation -> store ->
testparmon a copy ofsmb.confwith the candidate -> write include ->smbcontrol smbd reload-config-> write exports ->exportfs -ra-> ACLs of the share directory. On failure the previous definition is restored and applied again. SharesServiceadditionally hasapplyPermissions(name)andpreview().
Remote mounts
| Method | Path | Description |
|---|---|---|
| GET | /api/mounts | RemoteMount[] (state, error, usedByShares) |
| POST | /api/mounts | CreateRemoteMountRequest -> 201 RemoteMount (mounted right away) |
| GET/PATCH | /api/mounts/[name] | RemoteMount; PATCH with UpdateRemoteMountRequest (password omitted = keep, "" = remove) |
| DELETE | /api/mounts/[name] | { confirm: <name> }; 409 while shares use the mount |
| POST | /api/mounts/[name]/mount | mount; 503 with a journal excerpt on failure |
| POST | /api/mounts/[name]/unmount | unmount; 409 when the mount is busy |
- Storage:
data/remote-mounts.json(600, without passwords) throughJsonRemoteMountRepository; passwords only indata/credentials/<name>(600). - Pure parts in
services/mounts/:units.ts(unit names likesystemd-escape --path,.mount/.automount, options, credentials file),validation.ts,parsers.ts(/proc/mounts,systemctl show). - State from
/proc/mountsandsystemctl show, never by touching the mount point.
Apps
| Method | Path | Description |
|---|---|---|
| GET | /api/apps | AppStack[] (directories below /appdata with a compose file, containers via docker compose ps) |
| POST | /api/apps | CreateStackRequest -> 201 AppStackDetail; 409 if it exists, 422 for an invalid configuration |
| GET | /api/apps/docker | DockerStatus |
| GET | /api/apps/summary | AppsSummary (for the dashboard) |
| POST | /api/apps/validate | ValidateStackRequest -> StackValidation (docker compose config without saving) |
| GET/PUT | /api/apps/[name] | AppStackDetail; PUT with UpdateStackRequest validates before saving |
| DELETE | /api/apps/[name] | DeleteStackRequest (confirm = name, optional removeData, removeVolumes) |
| POST | /api/apps/[name]/actions | { action } (up, down, restart, pull) -> 202 AppOperation; 409 while an operation runs |
| GET | /api/apps/operations/[id] | AppOperation with output |
| GET | /api/apps/[name]/logs | ?tail=1..5000&since=<cursor>&service= -> StackLogs |
- System access in
LinuxAppsSystem:docker compose --project-name <name> --project-directory /appdata/<name> -f <file>, always as an args array. Actions stream their output (--progress plain) into the operation (last 1000 lines, in memory, last 50 operations). - Validation writes the unsaved content to a temporary directory (700) and runs
docker compose config --format json;analyzeCompose()returns warnings (privileged, host namespaces, Docker socket, system paths). .envis written atomically with mode 600, the compose file with 644. Deleting the directory refuses symbolic links and mount points (own datasets).- Log cursor: newest timestamp (9 fraction digits); docker's
--sinceis inclusive, equal timestamps are dropped. - Pure parsers in
services/apps/parsers.ts,.envhelpers (also used by the editor in the browser) inmodels/apps.ts.
Dashboard
GET /api/dashboard -> DashboardSnapshot. DefaultDashboardService reads host values through DashboardSystem (raw texts from /proc, /sys/class/net, sensors -j, /sys/class/hwmon) and evaluates them with the pure parsers in services/dashboard/parsers.ts. MockDashboardSystem produces the same raw texts, so mock mode runs the same parsers. CPU usage and network throughput are deltas to the previous sample (DeltaSampler: background sample every 5 s, samples less than 1 s apart reuse the last result). Storage, shares and apps are queried only through their contracts (storage.overview, shares.connections, apps.summary), each with an 8 s time limit; failures yield empty parts instead of a failed snapshot. Display rules (temperature limits, memory split) live in views/dashboard/derive.ts.
Virtualization
| Method | Path | Description |
|---|---|---|
| GET | /api/virtualization/status | VirtualizationStatus (installed, reachable, pool, ISO dataset, networks, bridge, host) |
| POST | /api/virtualization/setup | SetupVirtualizationRequest -> VirtualizationStatus (pool, ISO dataset, NAT network; existing parts stay) |
| GET | /api/virtualization/summary | VirtualizationSummary (dashboard) |
| GET/POST | /api/virtualization/instances | Instance[] / CreateInstanceRequest -> 202 VirtualizationOperation |
| GET/PATCH/DELETE | /api/virtualization/instances/[name] | InstanceDetail / UpdateInstanceRequest / { confirm } |
| POST | /api/virtualization/instances/[name]/actions | { action } (start, stop, restart, force-stop) -> 202 |
| POST | /api/virtualization/instances/[name]/shares | InstanceShareMount -> 201 InstanceDetail |
| DELETE | /api/virtualization/instances/[name]/shares/[device] | -> InstanceDetail |
| GET/POST | /api/virtualization/instances/[name]/snapshots | InstanceSnapshot[] / { name } -> 201 |
| POST | /api/virtualization/instances/[name]/snapshots/[snapshot]/restore | { confirm } -> 202 |
| DELETE | /api/virtualization/instances/[name]/snapshots/[snapshot] | { confirm } |
| GET | /api/virtualization/operations/[id] | VirtualizationOperation |
| GET | /api/virtualization/images?server= | RemoteImage[] (IMAGE_SERVERS only) |
| GET | /api/virtualization/isos | IsoImage[] with usedBy |
| DELETE | /api/virtualization/isos/[name] | { confirm }; 409 while attached |
| POST | /api/virtualization/isos/uploads | { name, sizeBytes } -> 201 IsoUpload |
| PUT/DELETE | /api/virtualization/isos/uploads/[id]?offset= | chunk (application/octet-stream, at most ISO_CHUNK_BYTES) / abort |
| POST | /api/virtualization/isos/uploads/[id]/complete | -> IsoImage |
| WS | /api/ws/incus-console?instance=&mode=&cols=&rows= | console (mode = shell or console, terminal protocol) |
LinuxVirtualizationSystemtalks to the Incus REST API throughIncusClient(node:httpwithsocketPath):/1.0/instances?recursion=2,/1.0/instances/<name>(PUT with the complete writable part),/state,/snapshots,/1.0/operations/<id>/wait,/1.0/storage-pools,/1.0/networks. Asynchronous Incus operations are tracked asVirtualizationOperationin memory (progress from the metadata, the last 100);activity()reports running operations and ISO uploads. Instances being created appear as placeholders until Incus lists them.- Pure modules:
incus.ts(raw objects <-> models, request bodies, sizes, progress),simplestreams.ts(image index ofimages.linuxcontainers.org, 6 h cache),validation.ts(limits, network, share directories),console.ts(console commands). - Instances are created without profiles, with own devices
root,eth0,install-iso; share directories arediskdevices (containers withshift=true). Sources are checked withrealpathand must lie below a share, a mounted dataset or/appdata(not/appdata/bunkerbox, not the instance pool). - Console: the channel
incus-console(registered instart()) runsincus execorincus consolethroughruntime.terminal.spawn()and connects it withterminal.bridge()(auditterminal.openwith targetincus-console:<name>). The UI usesTerminalPanefromviews/terminal. - ISO uploads go in chunks because the proxy buffers request bodies and cuts them at 10 MB. The partial file is created with
O_EXCL|O_NOFOLLOWand moved to its name withlink()(never overwriting). - Backup:
stopForBackup()/startAfterBackup()anddataDatasets()(dataset of the storage pool). - Mock:
MockVirtualizationSystemkeeps raw Incus objects in memory, so the same mappings run; operations finish after a few steps.
Backup
| Method | Path | Description |
|---|---|---|
| GET | /api/backup/summary | BackupSummary (dashboard) |
| GET/POST | /api/backup/jobs | BackupJob[] / CreateBackupJobRequest -> 201 BackupJob |
| GET/PATCH/DELETE | /api/backup/jobs/[id] | BackupJob / UpdateBackupJobRequest (secret omitted = keep, "" = remove) / { confirm: <name> } |
| POST | /api/backup/jobs/[id]/run | start a run -> 202 BackupRun; 409 while the job runs |
| POST | /api/backup/jobs/[id]/test | BackupTargetTest (connection without transfer) |
| GET | /api/backup/jobs/[id]/runs?limit= | BackupRun[] (newest first, at most 50) |
| POST | /api/backup/runs/[id]/cancel | cancel a run |
| GET | /api/backup/runs/[id]/log | BackupRunLog (lines as Text: Msg for own steps, raw text for tool output) |
| GET/POST | /api/backup/ssh-key | BackupSshKey / { regenerate? } generates the ed25519 key |
| GET | /api/backup/sources | BackupSourceOptions for the editor |
| GET | /api/backup/appdata/snapshots | AppdataSnapshot[] |
| POST | /api/backup/appdata/restore | RestoreAppdataRequest (stacks = copy app directories, otherwise rollback) -> 202 BackupRun |
- Storage in
data/backup/(700) throughJsonBackupRepository:jobs.json(jobs, runs, schedule anchors),secrets/<id>.jsonandlogs/<run>.json(600 each). Temporary rclone config and rsync password file indata/backup/tmp/, SSH key andknown_hostsindata/backup/ssh/. - Pure modules in
services/backup/:validation.ts(names, hosts, paths against allowed roots, endpoints, secrets without line breaks),commands.ts(rsync/rclone arguments, rclone config, deleting version folders with an empty directory and--include),parsers.ts(rsync--info=stats2,progress2,--list-only, rclone stats,lsf,ssh-keygen -l),retention.ts(time stamps, snapshot names, last/daily/weekly/monthly retention). DefaultBackupService: schedules every minute (isDue/nextSlotfromservices/storage/schedule.ts), running jobs as activitybackup:<id>, failures asnotify("backup-failed")with keybackup-failed:<id>. Appdata runs useapps.list/action/operation,virtualization.stopForBackup/startAfterBackupandstorage.createSnapshot/listSnapshots/deleteSnapshot/rollbackSnapshot; the restart also happens after failures. A rollback of the dataset holding the own data directory is refused.LinuxBackupSystem:/usr/bin/rsync,/usr/bin/rclone,ssh-keygenthrough args arrays; rsync exit 24 (vanished files) is a warning.MockBackupSystemsimulates transfers with real output (hosts containing "fail" or "offline" fail).
Notifications
| Method | Path | Description |
|---|---|---|
| GET/PUT | /api/notifications/settings | NotificationSettings / UpdateNotificationSettingsRequest (smtp.password omitted = keep, "" = remove) |
| POST | /api/notifications/test | test mail to all recipients -> TestNotificationResult; 409 when not set up |
| GET | /api/notifications/history?limit=1..200 | NotificationLogEntry[] |
| GET | /api/notifications/summary | NotificationsSummary (dashboard) |
DefaultNotificationsService.notify()decides with the pure functions inpolicy.ts(decide,remember): dedupe per key, per-event toggles, hourly limit (critical always passes), failed deliveries retried after 15 min at the earliest. Mails to send run one after another in a queue (at most 50).- Storage:
data/notifications.json(600, without password) anddata/secrets/smtp-password(600) throughJsonNotificationsRepository; history (200 entries) and dedupe state indata/notifications-state.json(600,JsonNotificationStateStore). - Delivery without third-party packages:
smtp.ts(SMTP client onnode:net/node:tls: TLS, STARTTLS, AUTH PLAIN/LOGIN, authentication only when encrypted, 30 s timeout),mime.ts(message with UTF-8, base64, RFC 2047 subject, dot-stuffing),compose.ts(texts in the configured language).MockNotificationsSystemdoes not send but collects the mails. HttpUpdateCheckerraisesupdate-availablewith the keyupdate-available:<version>.
UPS
| Method | Path | Description |
|---|---|---|
| GET | /api/ups | UpsOverview (config, status, services) |
| GET | /api/ups/summary | UpsSummary (dashboard; status null without a UPS) |
| GET | /api/ups/scan | DetectedUps[] (nut-scanner -U -q); 503 without NUT |
| PUT | /api/ups/config | UpdateUpsConfigRequest -> UpsConfig (netServer.password omitted = keep, "" = remove); 409 without a device, 503 when NUT does not start |
DefaultUpsServicepollsupsc ups@localhostevery 5 s (parsers.ts), remembers the start of a power failure and raisesups-on-battery,ups-low-batteryandups-shutdown(keys contain the start of the outage, so once per outage). Changes are serialized.nutConfig.tsgeneratesnut.conf,ups.conf,upsd.conf,upsd.users,upsmon.conf,upssched.confandbunkerbox-upssched-cmdand checks every value again (validation.ts). The shutdown (LBor theupsschedtimer ->upsmon -c fsd) runs independently of the Node process.LinuxUpsSystem: files below/etc/nut(root:nut, 640),systemctlfornut-driver-enumerator,nut-driver@ups,nut-server,nut-monitor.MockUpsSystem: one simulated USB UPS (setOnBattery()for tests).- Storage:
data/ups.json(600) throughJsonUpsRepository, passwords indata/secrets/ups-monitor-password(random) andups-net-password(600).
Security
DefaultSecurityService: HTTPS setting indata/server.json(ServerConfigFile), applied viaSecuritySystem.applyTls()(Linux:serverControl().applyTls()of the preload; on failure the previous file is written back and applied again). Certificates indata/tls/(700):selfsigned.*(openssl,deploy/setup.d/10-tls.shorregenerateSelfSigned()),uploaded.*(600). Changes are serialized.- Pure modules:
totp.ts(RFC 6238, Base32, recovery codes as sha256),qr.ts(QR encoder, byte mode, versions 1–40, SVG; no third-party package, so the secret never leaves the machine),certificates.ts(validation withX509Certificate/createPrivateKey, SAN, openssl arguments). - Two-factor state in
data/security-state.json(600,JsonSecurityRepository, written immediately): secrets, last time step (replay protection), recovery code hashes, pending enrollments (15 min), the requirement for all admins, the last 20 login addresses per admin. start()registers theSecondFactorPolicy(enrolled or required). Code checks are limited per user (5 failures -> 15 min,LoginRateLimiter)./api/auth/2fa/**(inSECOND_FACTOR_PATHS) verify or enroll and then callcompleteSecondFactor().recordLogin()(fromAuthController.loginwithout a second step, or after it) raiseslogin-new-ipfor unknown addresses.MockSecuritySystem: files and HTTPS listener in memory (binds no port in mock mode), example certificatebunkerbox.local.
Audit log
DefaultAuditService:record()is synchronous and buffers; a single writer appends JSON lines todata/audit/audit.log(600) and rotates from 5 MiB on (audit.log.1..5). Queries run on an in-memory copy (loaded from all files on first use, then kept current byrecord(), at most 200,000 entries).- Pure module
auditLog.ts: normalization and truncation of the input, (de)serialization (broken lines are skipped), filters, cursor (at|id, base64url),facets. LinuxAuditSystem(files, rotation),MockAuditSystem(in memory, with an example history).
System log
DefaultLogsService->LogsSystem.read(JournalQuery)-> JSON lines. Pure modulejournal.ts:normalizeQuery()(unit namesUNIT_NAME_PATTERN, cursor pattern, priority 0–7, times as@epoch, search text without control characters),journalArgs()(--reverse --lines,--after-cursorto follow,--reverse --cursorto page back,--grepwith escaped special characters and--case-sensitive=false),parseJournalLine()(MESSAGE as text or byte array, ANSI and control characters removed, 16 KiB), unit list fromjournalctl --field=_SYSTEMD_UNITandsystemctl list-units.LinuxLogsSystem: exit code 1 without output = no matches; without pattern support it reads a ten times larger window without--grepand filters itself.MockLogsSystem: deterministic journal of the last three days that grows with the clock (follow mode works), in the same JSON format.
App templates
| Method | Path | Description |
|---|---|---|
| GET | /api/templates?refresh=1 | TemplateCatalog (cached 1 h, refresh reloads; failures keep the last state and set error) |
| GET | /api/templates/[id] | TemplateFiles (meta, compose.yaml, .env) |
| GET | /api/templates/[id]/icon | Icon of the template (delivered through Bunkerbox, sandbox CSP) |
| POST | /api/templates/[id]/create | CreateFromTemplateRequest -> 201 AppStackDetail |
- The source is
<BUNKERBOX_UPDATE_URL>/api/templates(homepage,site/server/server.mjsreadssite/templates/<id>/,/app/templatesin the image).LinuxTemplatesSystemfetches with a 10 s timeout and size limits (index 1 MiB, files as for apps 256/64 KiB, icons 256 KiB);MockTemplatesSystemreadssite/templates/directly. - Pure parts in
services/templates/catalog.ts:parseTemplateIndex/parseTemplateMeta(strict checks of the foreign data),interpolate(compose variables),relativeBindDirs(relative bind-mount folders of a compose file),envOwner(PUID/PGID, never root). create()callsapps.create()(validated withdocker compose config,.env600), then creates the relative folders below/appdata/<name>/component by component without following symlinks (chownto PUID:PGID only for the folders it created) and starts withapps.action(name, "up")when requested.node scripts/template-docs.mjsgenerates the template list in the docs;test/templates/site.test.tschecks the templates and the list.
Files
| Method | Path | Description |
|---|---|---|
| GET | /api/files/roots | FileRoot[] (shares, datasets, remote mounts, /appdata; cached 10 s) |
| GET | /api/files/list, /api/files/stat | ?root=&path= -> DirectoryListing (at most 5000 entries) or FileEntry |
| GET | /api/files/principals | FilePrincipals from /etc/passwd and /etc/group |
| GET | /api/files/download | File as a stream or folder as a ZIP stream (application/octet-stream or application/zip, attachment, sandbox CSP) |
| POST | /api/files/upload | ?root=&path=&name=&overwrite=1, raw body, header x-bunkerbox-upload: 1 -> 201 FileEntry |
| POST | /api/files/mkdir, rename | CreateFolderRequest, RenameRequest |
| POST | /api/files/move, copy, delete, chown, chmod | -> 202 FileOperation (background, activity files-<kind>:<id>) |
| GET | /api/files/operations, /api/files/operations/[id] | Operations (the last 50, in memory) |
- Paths are always (root id, relative path).
DefaultFilesService.resolve()checks every component (splitRelPath,isSafeFileName), walks from the root through every component withlstat(no symlink on the way) and compares therealpathof the parent. Final components are never followed: reads withO_NOFOLLOWandfstat, writes into new partial files withO_CREAT | O_EXCL | O_NOFOLLOW, thenlink()(never overwrites) orrename()(replacing a regular file),lchown,chmodthrough an open handle. - Roots:
services/files/paths.ts(isAllowedRootPathforbids / and system paths,mergeRootsremoves duplicate paths)./appdata/bunkerboxand the data directory are protected (hidden, 404). - Recursive operations (copy, delete, chown, chmod) never follow symlinks; delete refuses trees containing other filesystems (
st_dev), chown/chmod skip them. New folders and files take over owner, group and mode of the parent folder. services/files/zip.ts: ZIP writer without dependencies (stored, data descriptors, ZIP64 where needed), backpressure through aReadableStreamwithpull.- Upload:
src/proxy.tsleaves out/api/files/uploadbecause the proxy buffers bodies and cuts them off at 10 MB.FilesController.uploadtherefore checks Origin, upload header, session and second factor itself and writes the audit entryfiles.uploaditself (route with{ audit: false }). MockFilesSystemextendsLinuxFilesSystemand maps the host paths intodata/mock-files/(example content on first access); owner and mode are simulated.
Terminal
| Method | Path | Description |
|---|---|---|
| WS | /api/ws/terminal?cols=&rows= | root shell (/bin/bash -l), terminal protocol from models/terminal.ts; at most MAX_ROOT_SHELLS (8) |
| GET | /api/terminal/sessions | TerminalSessionInfo[] of all terminal and console sessions |
| DELETE | /api/terminal/sessions/[id] | end a session (close 1001, the pty is killed); 404 when unknown |
DefaultTerminalServiceregisters the channelterminalwith the gateway.bridge(socket, request, pty, target)connects pty and socket (output binary, input/size as JSON, backpressure above 4 MiB withpause()/resume(), auditterminal.open/terminal.close) and is reused by the Incus console;closeSession(id)ends sessions.ScriptPty(services/terminal/pty.ts):script -qfecwith validated arguments quoted for/bin/sh, its own process group (SIGHUP,SIGKILLafter 3 s), size viastty -F /dev/pts/Nof the first child. Start failures (missingscript, working directory) report an exit instead of ending the service; the exit is reported after the last output (close).- UI:
views/terminal/TerminalPane.tsx(xterm.js with the fit addon, loaded dynamically) is the shared component for the shell and consoles (<TerminalPane channel params title actions onStatus height />): status, reconnect, font size, full screen, special keys for mobile devices, copy with Ctrl+Shift+C.TerminalViewadds tabs (up to 4) and the session list.
Network
| Method | Path | Description |
|---|---|---|
| GET | /api/network | NetworkOverview (interfaces, configuration, routes, DNS, apply state) |
| GET | /api/network/interfaces | NetInterface[] |
| GET | /api/network/state | NetworkApplyState |
| POST | /api/network/preview | NetworkConfig -> NetworkPreview (files, removed files, expected addresses, hints); 422 for an invalid configuration |
| POST | /api/network/apply | NetworkConfig -> 202 NetworkApplyState (pending, confirmToken, deadline); 409 while a change waits |
| POST | /api/network/confirm | { token } -> NetworkApplyState; public (allowed in the proxy, as is the page /network-confirm), 403 wrong code, 409 not active yet or already rolled back |
| POST | /api/network/rollback | roll back now |
- Pure modules in
services/network/:parsers.ts(ip -j -d address, default routes, resolv.conf,/etc/network/interfaces,systemctl show,deriveConfig()before the takeover),validation.ts(addresses, gateways, bridge, MTU, hints, expected addresses),networkd.ts(.network/.netdev, drop-in, resolv.conf, hosts, ifupdown stub;isManagedPath()as allow list),plan.ts(staged files, backups and the unitsbunkerbox-network-apply.service,bunkerbox-network-rollback.service,bunkerbox-network-rollback.timerwith systemd quoting). DefaultNetworkService: serialized operations, state indata/network-state.json(600, written at once), activation 1.5 s after the response through a blockingsystemctl startof the apply unit, token compared withtimingSafeEqual, rollback detected by the marker/var/lib/bunkerbox-network/rolled-back; if the timer is 30 s overdue, the service starts the rollback itself. While a change is open,activity()reportsnetwork-apply(blocks updates).bridgeAvailable()checks/sys/class/net/br0/bridgeandoperstate.LinuxNetworkSystemonly writes allow-listed paths (units to/etc/systemd/system, staging below/var/lib/bunkerbox-networkon the root filesystem, 700/600; the rollback unit hasRequiresMountsFor=on it, and every restore line is prefixed with-so the networkd restart andifupalways run);MockNetworkSystemproducesip -jJSON from a simulated configuration and rolls back driven by the clock (no real timers).
Spindown (extension of storage)
| Method | Path | Description |
|---|---|---|
| GET | /api/storage/spindown | SpindownOverview (policies, power: state, supported, blockedBy, idle time, last spindown) |
| PUT | /api/storage/spindown/[diskId] | SetSpindownPolicyRequest { idleMinutes: 5..720 | null, method: "monitor" | "hdparm" } -> SpindownPolicy; audit storage.spindown.policy |
| POST | /api/storage/spindown/[diskId]/standby | hdparm -y now -> DiskPowerInfo; 409 during scrub, resilver or burn-in; audit storage.spindown.standby |
Structure (src/services/storage/spindown/): DefaultSpindownService (monitor every minute: states, idle times, hdparm -S again for new kernel names, spindown when idle), rules.ts (pure: hdparm -S encoding, parsers for hdparm -C, smartctl -i -n standby and /sys/block/<dev>/stat, IdleTracker, blockedReason, shouldSpinDown), LinuxSpindownSystem (smartctl, hdparm, sysfs; only kernel names matching BLOCK_NAME_PATTERN), MockSpindownSystem. Disks and pools come from storage.listDisks()/listPools(), running burn-ins from burnin.runningDiskIds(). Policies in data/spindown-state.json (600, JsonSpindownRepository), keyed by Disk.id.
Burn-in (extension of storage)
| Method | Path | Description |
|---|---|---|
| GET | /api/storage/burnin | BurnInRun[] (newest first, with history) |
| POST | /api/storage/burnin | StartBurnInRequest { diskId, confirmSerial, phases? } -> 201 BurnInRun; 404, 409 (system disk, in use, already running, badblocks active), 422 (no serial number); audit storage.burnin.start (target diskId) |
| GET | /api/storage/burnin/marks | BurnInMark[]: disks whose last burn-in passed and whose serial number matches |
| GET | /api/storage/burnin/[id] | BurnInRun |
| POST | /api/storage/burnin/[id]/cancel | cancel, waits for the end -> BurnInRun; audit storage.burnin.cancel |
| GET | /api/storage/burnin/[id]/log | BurnInLog (at most 400 lines) |
Structure (src/services/storage/burnin/): DefaultBurnInService (eligibility, sequence SMART short test -> badblocks -> SMART long test, cancel, verdict, history, event burnin-finished, activity() per running burn-in), parsers.ts (pure: badblocks progress, block size for > 16 TiB, ATA/NVMe self-test state from smartctl -j -a, SMART counters and changes, overall progress, end estimate, verdict), LinuxBurnInSystem (smartctl -t/-X/-j -a, badblocks -w -s -v -b -c through spawn with an args array, running badblocks from /proc/*/cmdline), MockBurnInSystem (about four minutes, sdl fails). Right before badblocks, serial number, size and usage are checked again through storage.listDisks(); device paths only matching DEVICE_PATH_PATTERN. Runs and logs in data/burnin-history.json (600, JsonBurnInRepository, at most 50 finished runs), progress is written at most every 30 s; on start, runs still marked running are marked interrupted. storage.createPool, addVdev and replaceDisk refuse disks with a running burn-in (storage.error.burnInRunning).
Storage events (src/services/storage/health.ts, pure, evaluated by the storage service every 5 min): pool-degraded, pool-faulted, device-errors, scrub-errors, smart-failing, smart-warning, disk-temperature, each with a dedupe key (e.g. pool-degraded:tank, smart-warning:<id>:<counters>).
Interface (design system)
src/app/globals.css defines colors (light/dark via prefers-color-scheme) and all basic building blocks; features use these classes and put only special styles into CSS modules.
| Block | Classes | Component |
|---|---|---|
| page | page, page-header, page-subtitle, page-actions, stack, stack-sm, row, spacer, grid grid-2/3/4, span-2 | PageHeader |
| card | card, card-header, card-title, card-subtitle, card-actions, card-body, card-body-flush, card-footer, card-section-title | Card, Stat |
| figure | stat, stat-label, stat-value, stat-sub, kv (dl) | Stat |
| table | table-wrap, table, table-compact, num, actions | – |
| buttons | btn, btn-primary, btn-danger, btn-ghost, btn-small, btn-block, btn-icon, btn-group | – |
| form | form, form-grid, form-actions, field, field-label, field-hint, field-error, field-invalid, input, select, textarea, code-editor, input-group, input-addon, checkbox, switch | Field, Switch |
| status | badge + tone-ok/warning/bad/info/active/muted, dot, chip, note note-info/ok/warning/bad, txt-ok/warning/bad, muted, small, mono | Badge, Note, ErrorNote |
| progress | progress (+ progress-small, progress-indeterminate, tone class), progress-bar, spinner | ProgressBar, Spinner |
| tabs | tabs, tab (aria-selected), tab-badge | Tabs |
| dialog | dialog (+ dialog-danger, dialog-small, dialog-large), dialog-header, dialog-body, dialog-actions | Dialog, ConfirmDialog (typed confirmation) |
| empty | empty, empty-title, empty-action | EmptyState |
Components live in src/views/components/. Data comes from usePoll(fn, intervalMs) (pauses while the tab is hidden), texts and formatting from useT() (t, text for Msg, fmt.bytes decimal, fmt.ibytes binary, fmt.rate, fmt.percent, fmt.duration, fmt.dateTime, fmt.relative). Fetch clients build on request, send, query, seg from src/views/api.ts; a 401 leads to the login page.
Translations
Texts come exclusively from src/i18n/messages/<de|en>/*.ts with identical keys in both languages: common.ts, server.ts (error.*), ui.ts (ui.*) and per feature <feature>.ts (only keys <feature>.*, errors as <feature>.error.*). The server produces Msg (msg(key, params)); the interface renders it in the viewer's language. test/core/i18n.test.ts checks identical keys and placeholders, feature prefixes and that every key used in the code exists.
Update check
HttpUpdateChecker queries <BUNKERBOX_UPDATE_URL>/api/latest every 6 h (first request 15 s after start, after errors after 30 min) and compares via SemVer. installable is true when not in mock mode, the working directory lies under <base>/releases/<version> and systemd-run exists. POST /api/update starts systemd-run --unit=bunkerbox-update … deploy/update.sh <version>; refused while there is activity. With Settings.autoUpdate the checker installs new versions by itself as soon as activity() is empty. See update contract.
Environment variables
| Variable | Default | Meaning |
|---|---|---|
PORT | 80 (systemd), 3000 (npm run dev) | HTTP port |
BUNKERBOX_DATA_DIR | ./data | persistence (systemd: /appdata/bunkerbox/data) |
BUNKERBOX_MOCK | – | 1 = simulated hardware (development without Linux/root) |
BUNKERBOX_UPDATE_URL | https://bunkerbox.znbw.de | base URL of the update interface |
BUNKERBOX_UPDATE_CHECK | on | 0 disables the update check |
NODE_ENV | – | production in operation |
Mock mode
With BUNKERBOX_MOCK=1 (or npm run dev:mock, platform independent) every factory uses Mock<Feature>System instead of Linux<Feature>System and the login uses MockAuthAdapter (admin/admin; the normal user anna/anna is refused as in real operation). The mock simulates the example hardware: 1 system SSD (240 GB), 2 × 16 TB HDD, 5 × 5 TB HDD, 2 × 1 TB SSD, pools tank and apps, datasets, shares, users, Docker stacks and metrics. Controllers, service rules and views run unchanged; only system access is replaced. The interface shows "Mock mode" in the header. Updates are never installable in mock mode.
Safety rules
- All system access goes through the feature's system interface; external programs are always started with args arrays (
exec()), never through a shell. Passwords go through stdin (ExecOptions.input). - Everything that ends up in command lines, generated configuration (smb.conf, exports, systemd units, compose) or paths is checked with
src/models/validation.tsfirst. - Destructive actions require the typed confirmation (
requireConfirm) and are checked in the service, not only in the interface. System disks and disks in use are never offered or accepted. data/has mode 700,settings.json,sessions.json,session-secret600; credentials for remote mounts live indata/credentials/<name>(600).- JSON files are written atomically (tmp + fsync + rename) and flushed when the service stops.
Automated tests
npm test (Vitest, test/) runs without hardware, root or network. test/core/ checks translations, session tokens and expiry, rate limit, Origin check, AuthService, shadow/passwd/group parsers, settings, SemVer and the name patterns. Every feature puts its tests into test/<feature>/ (parsers, generators, rules, service with mock system).