Skip to main content

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() (or core.ts) and types from models/.
  • Views only know types from models/ and talk to /api/* via fetch. No imports from services/ or controllers/ in views/.
  • System access exclusively through the feature's system interface (<F>System) and exec().

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.

PartPath
types/DTOssrc/models/<feature>.ts (plus own repositories src/models/<Feature>*.ts)
contractsrc/services/<feature>/contract.ts (service interface fixed, system interface free to extend)
servicesrc/services/<feature>/Default<Feature>Service.ts
system accesssrc/services/<feature>/Linux<Feature>System.ts, Mock<Feature>System.ts, pure parsers/generators next to them
factorysrc/services/<feature>/index.ts (create<Feature>Service(ctx))
controllersrc/controllers/<Feature>Controller.ts
routessrc/app/api/<feature>/**
pagessrc/app/(admin)/<feature>/** (dashboard: src/app/(admin)/page.tsx)
viewssrc/views/<feature>/**, fetch client src/views/<feature>/api.ts, own styles as CSS modules
textssrc/i18n/messages/{de,en}/<feature>.ts, keys start with <feature>.
teststest/<feature>/*.test.ts
docsdocs/<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/shadow as root, refuses locked (!, *), empty and expired accounts and compares the password with crypt(3) of the system libxcrypt via perl -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/group and /etc/passwd.
  • Mock (MockAuthAdapter): admin / admin.
  • Sessions (DefaultAuthService): 32 random bytes as token, cookie bb_session=<token>.<hmac> (HMAC-SHA256 with data/session-secret, 600), HttpOnly, SameSite=Strict, Path=/, Secure behind HTTPS. Stored server-side in data/sessions.json under 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 security feature registers a SecondFactorPolicy with the AuthService. When it requires a second factor, login creates a session with pendingSecondFactor; until POST /api/auth/2fa/verify (calls completeSecondFactor()) it can only reach SECOND_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 from X-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/health and static files. Everything else needs a valid session: pages redirect to /login?next=…, API routes answer 401. The proxy removes a client-supplied header x-bunkerbox-user and 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 as x-bunkerbox-peer and the protocol as x-bunkerbox-proto (client-supplied values are overwritten). clientIp()/isHttps() in src/controllers/http.ts only honour X-Forwarded-* when that peer is listed in BUNKERBOX_TRUSTED_PROXIES (src/services/auth/clientAddress.ts).
  • CSRF: for changing API requests (not GET/HEAD/OPTIONS), Origin must match the host (or X-Forwarded-Host); without Origin only with Sec-Fetch-Site: same-origin. In addition readJson() requires Content-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-peer and x-bunkerbox-proto on 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 through serverControl().applyTls() (src/services/gateway/contract.ts) without a restart. Invalid certificates keep the previous state. With redirectHttp port 80 redirects to HTTPS (308), except for loopback clients (health check of update.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.

APIMeaning
runtime.gateway.register({ name, open(socket, request) })register channel /api/ws/<name> (in the feature's start())
WsRequestuser, ip, locale, params (query, unvalidated), secure, userAgent
WsSocketsend(string | Uint8Array), sendJson, close(code, reason), onMessage, onClose, onDrain, bufferedAmount
errors in open()ServiceError -> { type: "error", error, key, params } and close code 4000 + status
browseropenChannel(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>" }) in src/controllers/http.ts writes an entry after every mutating request (POST, PUT, PATCH, DELETE): user, address, action, target, result ok/failed/denied, status, error key. The target defaults to the route params; auditedJson(request, result, status) takes result.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.ts requires the option on every mutating route. Other actions (terminal sessions, second factor) call runtime.audit.record(input). Never put secrets into target or details.
  • Notifications: runtime.notifications.notify({ type, severity, key, subject, body, details }) is fire and forget (never throws, never blocks). Event types are NOTIFICATION_EVENT_TYPES (src/models/notifications.ts); key is the dedupe key (e.g. pool-degraded:tank). Subject and body are Msg values rendered in the configured language. SMTP account, recipients and toggles live in data/notifications.json, the password in data/secrets/smtp-password (both 600), not in bunkerbox.env.

API (core)​

MethodPathDescription
POST/api/auth/loginLoginRequest -> MeResponse + cookie; 401, 429
POST/api/auth/logoutends the session, clears the cookie
GET/api/auth/meMeResponse
GET/api/healthpublic: HealthResponse { ok, version, mock, activeOperations }
GET/PUT/api/settingsSettingsResponse; PUT with partial settings (language, autoUpdate, sessionIdleMinutes 5..1440, sessionAbsoluteHours 1..168)
GET/api/updateUpdateResponse { update, activity, autoUpdate }
POST/api/updateStartUpdateRequest -> 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​

MethodPathDescription
GET/api/storageStorageOverview (ZFS available, disks, pools, ARC)
GET/api/storage/disksDisk[]
POST/api/storage/disks/refreshread 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,bLayoutSuggestion[]
GET/POST/api/storage/poolsPool[] / CreatePoolRequest -> 201 Pool
GET/DELETE/api/storage/pools/[name]Pool / { confirm }
POST/api/storage/pools/[name]/exportexport
POST/api/storage/pools/[name]/vdevsAddVdevRequest
POST/api/storage/pools/[name]/replaceReplaceDiskRequest
POST/api/storage/pools/[name]/offline, …/onlineDeviceStateRequest
POST/api/storage/pools/[name]/scrub`{ action: "start"
PUT/api/storage/pools/[name]/scrub-schedule`{ schedule: ScrubSchedule
GET/POST/api/storage/importImportablePool[] / 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/rollbackRollbackSnapshotRequest
GET/PUT/api/storage/arcArcOverview / 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​

MethodRouteResult
GET / POST/api/usersNasUser[] / 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/groupsNasGroup[] / 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​

MethodPathDescription
GET/api/sharesShare[] (with status)
POST/api/sharesCreateShareRequest -> 201 Share
GET/PATCH/api/shares/[name]Share; PATCH with UpdateShareRequest
DELETE/api/shares/[name]{ confirm: <name> }; the data stays
POST/api/shares/[name]/permissionswrite ACLs recursively in the background -> 202 (activity shares-acl:<name>)
POST/api/shares/applyrewrite the configuration, reload the services -> ShareServicesStatus
GET/api/shares/servicesShareServicesStatus (Samba, NFS)
GET/api/shares/pathsSharePathOption[]: mounted datasets (storage.listDatasets()) and remote mounts
GET/api/shares/connectionsShareConnections (smbstatus --json, /proc/fs/nfsd/clients)
GET/api/shares/previewShareConfigPreview: generated include and exports file
  • Storage: data/share-definitions.json (600) through JsonShareRepository, plus the ACL rules last written per share.
  • Pure generators in services/shares/: smbConfig.ts (include, include line, 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 -> testparm on a copy of smb.conf with 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.
  • SharesService additionally has applyPermissions(name) and preview().

Remote mounts​

MethodPathDescription
GET/api/mountsRemoteMount[] (state, error, usedByShares)
POST/api/mountsCreateRemoteMountRequest -> 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]/mountmount; 503 with a journal excerpt on failure
POST/api/mounts/[name]/unmountunmount; 409 when the mount is busy
  • Storage: data/remote-mounts.json (600, without passwords) through JsonRemoteMountRepository; passwords only in data/credentials/<name> (600).
  • Pure parts in services/mounts/: units.ts (unit names like systemd-escape --path, .mount/.automount, options, credentials file), validation.ts, parsers.ts (/proc/mounts, systemctl show).
  • State from /proc/mounts and systemctl show, never by touching the mount point.

Apps​

MethodPathDescription
GET/api/appsAppStack[] (directories below /appdata with a compose file, containers via docker compose ps)
POST/api/appsCreateStackRequest -> 201 AppStackDetail; 409 if it exists, 422 for an invalid configuration
GET/api/apps/dockerDockerStatus
GET/api/apps/summaryAppsSummary (for the dashboard)
POST/api/apps/validateValidateStackRequest -> 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).
  • .env is 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 --since is inclusive, equal timestamps are dropped.
  • Pure parsers in services/apps/parsers.ts, .env helpers (also used by the editor in the browser) in models/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​

MethodPathDescription
GET/api/virtualization/statusVirtualizationStatus (installed, reachable, pool, ISO dataset, networks, bridge, host)
POST/api/virtualization/setupSetupVirtualizationRequest -> VirtualizationStatus (pool, ISO dataset, NAT network; existing parts stay)
GET/api/virtualization/summaryVirtualizationSummary (dashboard)
GET/POST/api/virtualization/instancesInstance[] / 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]/sharesInstanceShareMount -> 201 InstanceDetail
DELETE/api/virtualization/instances/[name]/shares/[device]-> InstanceDetail
GET/POST/api/virtualization/instances/[name]/snapshotsInstanceSnapshot[] / { 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/isosIsoImage[] 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)
  • LinuxVirtualizationSystem talks to the Incus REST API through IncusClient (node:http with socketPath): /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 as VirtualizationOperation in 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 of images.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 are disk devices (containers with shift=true). Sources are checked with realpath and must lie below a share, a mounted dataset or /appdata (not /appdata/bunkerbox, not the instance pool).
  • Console: the channel incus-console (registered in start()) runs incus exec or incus console through runtime.terminal.spawn() and connects it with terminal.bridge() (audit terminal.open with target incus-console:<name>). The UI uses TerminalPane from views/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_NOFOLLOW and moved to its name with link() (never overwriting).
  • Backup: stopForBackup()/startAfterBackup() and dataDatasets() (dataset of the storage pool).
  • Mock: MockVirtualizationSystem keeps raw Incus objects in memory, so the same mappings run; operations finish after a few steps.

Backup​

MethodPathDescription
GET/api/backup/summaryBackupSummary (dashboard)
GET/POST/api/backup/jobsBackupJob[] / CreateBackupJobRequest -> 201 BackupJob
GET/PATCH/DELETE/api/backup/jobs/[id]BackupJob / UpdateBackupJobRequest (secret omitted = keep, "" = remove) / { confirm: <name> }
POST/api/backup/jobs/[id]/runstart a run -> 202 BackupRun; 409 while the job runs
POST/api/backup/jobs/[id]/testBackupTargetTest (connection without transfer)
GET/api/backup/jobs/[id]/runs?limit=BackupRun[] (newest first, at most 50)
POST/api/backup/runs/[id]/cancelcancel a run
GET/api/backup/runs/[id]/logBackupRunLog (lines as Text: Msg for own steps, raw text for tool output)
GET/POST/api/backup/ssh-keyBackupSshKey / { regenerate? } generates the ed25519 key
GET/api/backup/sourcesBackupSourceOptions for the editor
GET/api/backup/appdata/snapshotsAppdataSnapshot[]
POST/api/backup/appdata/restoreRestoreAppdataRequest (stacks = copy app directories, otherwise rollback) -> 202 BackupRun
  • Storage in data/backup/ (700) through JsonBackupRepository: jobs.json (jobs, runs, schedule anchors), secrets/<id>.json and logs/<run>.json (600 each). Temporary rclone config and rsync password file in data/backup/tmp/, SSH key and known_hosts in data/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/nextSlot from services/storage/schedule.ts), running jobs as activity backup:<id>, failures as notify("backup-failed") with key backup-failed:<id>. Appdata runs use apps.list/action/operation, virtualization.stopForBackup/startAfterBackup and storage.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-keygen through args arrays; rsync exit 24 (vanished files) is a warning. MockBackupSystem simulates transfers with real output (hosts containing "fail" or "offline" fail).

Notifications​

MethodPathDescription
GET/PUT/api/notifications/settingsNotificationSettings / UpdateNotificationSettingsRequest (smtp.password omitted = keep, "" = remove)
POST/api/notifications/testtest mail to all recipients -> TestNotificationResult; 409 when not set up
GET/api/notifications/history?limit=1..200NotificationLogEntry[]
GET/api/notifications/summaryNotificationsSummary (dashboard)
  • DefaultNotificationsService.notify() decides with the pure functions in policy.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) and data/secrets/smtp-password (600) through JsonNotificationsRepository; history (200 entries) and dedupe state in data/notifications-state.json (600, JsonNotificationStateStore).
  • Delivery without third-party packages: smtp.ts (SMTP client on node: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). MockNotificationsSystem does not send but collects the mails.
  • HttpUpdateChecker raises update-available with the key update-available:<version>.

UPS​

MethodPathDescription
GET/api/upsUpsOverview (config, status, services)
GET/api/ups/summaryUpsSummary (dashboard; status null without a UPS)
GET/api/ups/scanDetectedUps[] (nut-scanner -U -q); 503 without NUT
PUT/api/ups/configUpdateUpsConfigRequest -> UpsConfig (netServer.password omitted = keep, "" = remove); 409 without a device, 503 when NUT does not start
  • DefaultUpsService polls upsc ups@localhost every 5 s (parsers.ts), remembers the start of a power failure and raises ups-on-battery, ups-low-battery and ups-shutdown (keys contain the start of the outage, so once per outage). Changes are serialized.
  • nutConfig.ts generates nut.conf, ups.conf, upsd.conf, upsd.users, upsmon.conf, upssched.conf and bunkerbox-upssched-cmd and checks every value again (validation.ts). The shutdown (LB or the upssched timer -> upsmon -c fsd) runs independently of the Node process.
  • LinuxUpsSystem: files below /etc/nut (root:nut, 640), systemctl for nut-driver-enumerator, nut-driver@ups, nut-server, nut-monitor. MockUpsSystem: one simulated USB UPS (setOnBattery() for tests).
  • Storage: data/ups.json (600) through JsonUpsRepository, passwords in data/secrets/ups-monitor-password (random) and ups-net-password (600).

Security​

  • DefaultSecurityService: HTTPS setting in data/server.json (ServerConfigFile), applied via SecuritySystem.applyTls() (Linux: serverControl().applyTls() of the preload; on failure the previous file is written back and applied again). Certificates in data/tls/ (700): selfsigned.* (openssl, deploy/setup.d/10-tls.sh or regenerateSelfSigned()), 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 with X509Certificate/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 the SecondFactorPolicy (enrolled or required). Code checks are limited per user (5 failures -> 15 min, LoginRateLimiter). /api/auth/2fa/** (in SECOND_FACTOR_PATHS) verify or enroll and then call completeSecondFactor().
  • recordLogin() (from AuthController.login without a second step, or after it) raises login-new-ip for unknown addresses.
  • MockSecuritySystem: files and HTTPS listener in memory (binds no port in mock mode), example certificate bunkerbox.local.

Audit log​

  • DefaultAuditService: record() is synchronous and buffers; a single writer appends JSON lines to data/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 by record(), 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 module journal.ts: normalizeQuery() (unit names UNIT_NAME_PATTERN, cursor pattern, priority 0–7, times as @epoch, search text without control characters), journalArgs() (--reverse --lines, --after-cursor to follow, --reverse --cursor to page back, --grep with escaped special characters and --case-sensitive=false), parseJournalLine() (MESSAGE as text or byte array, ANSI and control characters removed, 16 KiB), unit list from journalctl --field=_SYSTEMD_UNIT and systemctl list-units.
  • LinuxLogsSystem: exit code 1 without output = no matches; without pattern support it reads a ten times larger window without --grep and 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​

MethodPathDescription
GET/api/templates?refresh=1TemplateCatalog (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]/iconIcon of the template (delivered through Bunkerbox, sandbox CSP)
POST/api/templates/[id]/createCreateFromTemplateRequest -> 201 AppStackDetail
  • The source is <BUNKERBOX_UPDATE_URL>/api/templates (homepage, site/server/server.mjs reads site/templates/<id>/, /app/templates in the image). LinuxTemplatesSystem fetches with a 10 s timeout and size limits (index 1 MiB, files as for apps 256/64 KiB, icons 256 KiB); MockTemplatesSystem reads site/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() calls apps.create() (validated with docker compose config, .env 600), then creates the relative folders below /appdata/<name>/ component by component without following symlinks (chown to PUID:PGID only for the folders it created) and starts with apps.action(name, "up") when requested.
  • node scripts/template-docs.mjs generates the template list in the docs; test/templates/site.test.ts checks the templates and the list.

Files​

MethodPathDescription
GET/api/files/rootsFileRoot[] (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/principalsFilePrincipals from /etc/passwd and /etc/group
GET/api/files/downloadFile 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, renameCreateFolderRequest, 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 with lstat (no symlink on the way) and compares the realpath of the parent. Final components are never followed: reads with O_NOFOLLOW and fstat, writes into new partial files with O_CREAT | O_EXCL | O_NOFOLLOW, then link() (never overwrites) or rename() (replacing a regular file), lchown, chmod through an open handle.
  • Roots: services/files/paths.ts (isAllowedRootPath forbids / and system paths, mergeRoots removes duplicate paths). /appdata/bunkerbox and 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 a ReadableStream with pull.
  • Upload: src/proxy.ts leaves out /api/files/upload because the proxy buffers bodies and cuts them off at 10 MB. FilesController.upload therefore checks Origin, upload header, session and second factor itself and writes the audit entry files.upload itself (route with { audit: false }).
  • MockFilesSystem extends LinuxFilesSystem and maps the host paths into data/mock-files/ (example content on first access); owner and mode are simulated.

Terminal​

MethodPathDescription
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/sessionsTerminalSessionInfo[] of all terminal and console sessions
DELETE/api/terminal/sessions/[id]end a session (close 1001, the pty is killed); 404 when unknown
  • DefaultTerminalService registers the channel terminal with the gateway. bridge(socket, request, pty, target) connects pty and socket (output binary, input/size as JSON, backpressure above 4 MiB with pause()/resume(), audit terminal.open/terminal.close) and is reused by the Incus console; closeSession(id) ends sessions.
  • ScriptPty (services/terminal/pty.ts): script -qfec with validated arguments quoted for /bin/sh, its own process group (SIGHUP, SIGKILL after 3 s), size via stty -F /dev/pts/N of the first child. Start failures (missing script, 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. TerminalView adds tabs (up to 4) and the session list.

Network​

MethodPathDescription
GET/api/networkNetworkOverview (interfaces, configuration, routes, DNS, apply state)
GET/api/network/interfacesNetInterface[]
GET/api/network/stateNetworkApplyState
POST/api/network/previewNetworkConfig -> NetworkPreview (files, removed files, expected addresses, hints); 422 for an invalid configuration
POST/api/network/applyNetworkConfig -> 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/rollbackroll 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 units bunkerbox-network-apply.service, bunkerbox-network-rollback.service, bunkerbox-network-rollback.timer with systemd quoting).
  • DefaultNetworkService: serialized operations, state in data/network-state.json (600, written at once), activation 1.5 s after the response through a blocking systemctl start of the apply unit, token compared with timingSafeEqual, 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() reports network-apply (blocks updates). bridgeAvailable() checks /sys/class/net/br0/bridge and operstate.
  • LinuxNetworkSystem only writes allow-listed paths (units to /etc/systemd/system, staging below /var/lib/bunkerbox-network on the root filesystem, 700/600; the rollback unit has RequiresMountsFor= on it, and every restore line is prefixed with - so the networkd restart and ifup always run); MockNetworkSystem produces ip -j JSON from a simulated configuration and rolls back driven by the clock (no real timers).

Spindown (extension of storage)​

MethodPathDescription
GET/api/storage/spindownSpindownOverview (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]/standbyhdparm -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)​

MethodPathDescription
GET/api/storage/burninBurnInRun[] (newest first, with history)
POST/api/storage/burninStartBurnInRequest { 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/marksBurnInMark[]: disks whose last burn-in passed and whose serial number matches
GET/api/storage/burnin/[id]BurnInRun
POST/api/storage/burnin/[id]/cancelcancel, waits for the end -> BurnInRun; audit storage.burnin.cancel
GET/api/storage/burnin/[id]/logBurnInLog (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.

BlockClassesComponent
pagepage, page-header, page-subtitle, page-actions, stack, stack-sm, row, spacer, grid grid-2/3/4, span-2PageHeader
cardcard, card-header, card-title, card-subtitle, card-actions, card-body, card-body-flush, card-footer, card-section-titleCard, Stat
figurestat, stat-label, stat-value, stat-sub, kv (dl)Stat
tabletable-wrap, table, table-compact, num, actions–
buttonsbtn, btn-primary, btn-danger, btn-ghost, btn-small, btn-block, btn-icon, btn-group–
formform, form-grid, form-actions, field, field-label, field-hint, field-error, field-invalid, input, select, textarea, code-editor, input-group, input-addon, checkbox, switchField, Switch
statusbadge + tone-ok/warning/bad/info/active/muted, dot, chip, note note-info/ok/warning/bad, txt-ok/warning/bad, muted, small, monoBadge, Note, ErrorNote
progressprogress (+ progress-small, progress-indeterminate, tone class), progress-bar, spinnerProgressBar, Spinner
tabstabs, tab (aria-selected), tab-badgeTabs
dialogdialog (+ dialog-danger, dialog-small, dialog-large), dialog-header, dialog-body, dialog-actionsDialog, ConfirmDialog (typed confirmation)
emptyempty, empty-title, empty-actionEmptyState

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​

VariableDefaultMeaning
PORT80 (systemd), 3000 (npm run dev)HTTP port
BUNKERBOX_DATA_DIR./datapersistence (systemd: /appdata/bunkerbox/data)
BUNKERBOX_MOCK–1 = simulated hardware (development without Linux/root)
BUNKERBOX_UPDATE_URLhttps://bunkerbox.znbw.debase URL of the update interface
BUNKERBOX_UPDATE_CHECKon0 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.ts first.
  • 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-secret 600; credentials for remote mounts live in data/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).