A self-contained, container-based web service that takes files from users, virus-scans them, fingerprints them with SHA-256 and hands them to a data diode's watched folder.
Design spec: Diode Upload Service: v1 design spec
| Stage | Scope | State |
|---|---|---|
| 1 | Upload page, resumable uploads, SHA-256, ClamAV scanning, outbox handoff, audit log, hardened compose stack | this commit |
| 2 | Admin site: history, quarantine list, release/delete/rescan, quarantine switch, retention | next PR |
| 3 | Rate limiting, Prometheus/Zabbix /metrics, startup oversize self-test, remaining hardening |
PR after that |
- Wiki: how each flow works, deployment and upgrade, troubleshooting.
- Interface Control Document, Rev 0.3 (PDF): every interface the service has, with formats, limits and open items. Draft for review, written against v0.5.3-beta.
browser ──TLS──▶ nginx ──▶ upload-api (Go) ──▶ /data/incoming/<id>
│ │
│ zSCAN by path ▼
└────────────▶ clamd (read-only incoming/ and tmp/ only)
│
passed / unscanned ──▶ /data/outbox/ ◀─────┘ (atomic rename + .sha256 + batch manifest)
infected ──▶ /data/quarantine/ (or deleted when quarantine is off)
scan error / limit ──▶ /data/quarantine/ (held) ──operator release──▶ /data/outbox/
- The page loads with a session cookie (
__Host-sid) and a CSRF token. - The user enters a username and drops or picks files. The page creates a batch, then uploads each file with the tus 1.0.0 protocol in 64 MiB chunks. A dropped connection resumes from the server's offset.
- The server computes SHA-256 as chunks arrive. After every chunk the hasher's internal state is saved with the upload record, so even a service restart mid-upload does not require re-reading data.
- A completed file is scanned by clamd by path from a read-only mount, so clamd's stream size limit does not apply.
- The result decides the end state (see below). Passed files are moved into
the outbox in one atomic rename; the
.sha256sidecar follows; the batch manifest is written last. - Every step is written to a hash-chained audit log.
| Scan result | End state | Where the file goes |
|---|---|---|
OK, file at or under CLAMD_MAX_SCAN_SIZE |
passed | outbox |
OK but file larger than CLAMD_MAX_SCAN_SIZE |
held (policy hold, the default) or unscanned (policy pass) |
quarantine/ until released, or outbox flagged in the manifest |
Heuristics.Limits.Exceeded.MaxFileSize / .MaxScanSize |
held (policy hold) or unscanned (policy pass) |
quarantine/ until released, or outbox flagged in the manifest |
Heuristics.Limits.Exceeded.MaxRecursion / .MaxFiles / .MaxScanTime |
held | quarantine/ |
any other FOUND (including Heuristics.Encrypted.*) |
quarantined, or deleted if QUARANTINE_ENABLED=false |
quarantine/ or removed |
ERROR, timeout, unexpected reply |
held | quarantine/ |
ClamAV cannot scan a single file over 2 GiB (2047 MiB here). With the default
OVERSIZE_POLICY=hold, such files never reach the outbox until an operator
releases them (see Held files). Held files can be released;
infected (quarantined) files cannot.
The service never scans until clamd has passed an EICAR self-test, which is repeated hourly. If clamd is unreachable, files wait in the queue instead of being held.
By default each file keeps the name it had on the sender's computer:
outbox/
Quarterly Report (final).pdf
Quarterly Report (final).pdf.sha256 # "<hex> <name>", works with sha256sum -c
20261103T141502Z-jsmith-a3f9c1.manifest.json
If the sender ticks Add the batch ID and my username to file names, names
start with the batch ID and a sequence number instead:
20261103T141502Z-jsmith-a3f9c1_001_Quarterly Report (final).pdf. The batch ID,
username and original name are always in the manifest and the audit log.
Names are changed only where they must be:
- any directory part is dropped
- characters illegal on Linux or Windows (control characters and
< > : " / \ | ? *) become_ - leading dots and spaces, trailing dots and spaces, are removed
- Windows device names (
CON,NUL,COM1...) get a leading_ - invisible and direction-changing Unicode characters (format characters
such as the right-to-left override, zero-width spaces and the byte-order
mark) become
_, soinvoice<RLO>fdp.execannot pass for a PDF - names over 180 bytes are shortened, keeping the extension
- names ending in
.sha256or.manifest.jsonget a trailing_, so an upload can never pass for a sidecar or manifest the service wrote
Spaces, accented and non-Latin letters, brackets and other punctuation are kept, so scripts on the receiving side must quote file names.
Without the prefix, two uploads can have the same name. A file is never
written over another: if a file with the same name, or its .sha256, is
still in the outbox, the new file waits (checked every 10 seconds) until the
diode has collected both. If they are still there after an hour, the new
file is held instead. The receiving side decides what happens when a second
file with the same name arrives there.
The manifest lists every passed file with its outbox name, original name,
size, SHA-256, scan_status (clean, unscanned or released) and
signature database. Withheld files are only counted; their names never
cross the diode. audit_head holds the sequence number and hash of the
newest audit event when the manifest was written: keep manifests on the
receiving side and a later rewrite of the upload host's audit log can be
detected.
A file released after its batch manifest has already gone gets a manifest of
its own, <batch>.release-<id>.manifest.json, with a release block saying
who released it, why, and why it was held.
- A Linux host (written for Ubuntu Server) with at least 4 CPU cores and 6 GB RAM (the compose limits add up to about 5 GB, mostly clamd), plus disk for in-flight uploads and quarantine on the same filesystem as the diode's watched folder.
- Docker Engine with the Compose plugin from Docker's own packages.
docker compose versionmust report v2.
Do not use the Docker snap (
snap install docker, or the "docker" option offered by the Ubuntu Server installer). The snap is sandboxed to your home folder: it cannot readcompose.yamlin/opt,/srvor/root, and it cannot bind-mountDIODE_ROOT. The usual symptom isno configuration file provided: not foundeven when you run it from the folder that holdscompose.yaml.which dockerprinting/snap/bin/dockermeans the snap is in use.
# 1. Remove the snap if it is installed (this also deletes anything it stored).
sudo snap remove --purge docker
# 2. Add Docker's apt repository.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list
# 3. Install Engine, CLI, Buildx and Compose.
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 4. Check.
which docker # /usr/bin/docker
docker compose version # Docker Compose version v2.x
sudo docker run --rm hello-worldIf a step fails, Docker's current instructions are at https://docs.docker.com/engine/install/ubuntu/.
To run docker without sudo, add your user to the docker group
(sudo usermod -aG docker "$USER", then log out and back in). Membership
of that group is equivalent to root on the host, so grant it only to
administrators of this server.
The diode's watched folder and the staging folders must be on one filesystem so moves are atomic. The service refuses to start otherwise, and also refuses if a staging folder is inside the outbox.
/srv/diode-upload/ # DIODE_ROOT, mounted at /data in the app
outbox/ # the folder the diode watches (2-second settle time)
incoming/ # files being received and scanned (clamd reads these)
quarantine/ # held and infected files
tmp/ # scratch space, emptied at startup (clamd reads these)
Create all four before the first start. clamd mounts incoming/ and tmp/
directly; if they do not exist Docker creates them owned by root and the app
cannot write to them.
sudo install -d -m 0750 -o 65532 -g 65532 /srv/diode-upload \
/srv/diode-upload/incoming /srv/diode-upload/quarantine /srv/diode-upload/tmp
sudo install -d -m 2775 -o 65532 -g <diode-group> /srv/diode-upload/outboxThe app runs as uid 65532. Staging and quarantine files are mode 0640 and
their folders 0750: clamd reads them through group 65532, and nothing else
on the host can. Files, sidecars and manifests in outbox/ are mode 0644 so
the diode can read them; the diode needs write access to outbox/ to remove
what it has collected.
Install Docker Engine first (see Prerequisites), then from the repository folder:
cp .env.example .env # set PUBLIC_ORIGIN and DIODE_ROOT
head -c 48 /dev/urandom | base64 > secrets/csrf_key && chmod 600 secrets/csrf_key
# put upload.crt / upload.key in deploy/tls/ (scripts/dev-cert.sh for a local test)
docker compose up -d --build
docker compose logs -f app # wait for "clamd self-test passed"clamd downloads its signatures on first start, which can take several minutes; uploads are accepted meanwhile and scanned once it is ready.
Useful commands:
curl -s http://127.0.0.1:9100/readyz # 200 once clamd is proven and disk is above reserve
docker compose exec app diode-upload version # running version
docker compose exec app diode-upload verify-audit # check the audit hash chain
sudo scripts/smoke-test.sh https://upload.example.internal /srv/diode-upload
# send a clean file and EICAR end to endsmoke-test.sh leaves one small smoke-*.txt file, its .sha256 and its
manifest in the outbox. Add --insecure-tls before the address when testing
with a self-signed certificate.
A file is held when clamd could not give a clear answer: it is too large to
scan (with OVERSIZE_POLICY=hold), an archive exceeds the nesting or
file-count limits, or the scan failed. Held files wait in quarantine/
and the sender's page shows "Held for administrator review". Until the admin
site arrives, operators handle them from the host:
docker compose exec app diode-upload held
# ID STATE USER SIZE RECEIVED NAME REASON
# 3f1c... held jsmith 6442450944 2026-11-04 10:12Z backup.tar file is larger than ClamAV can scan ...
docker compose exec app diode-upload release 3f1c... --by "A. Admin" --reason "vendor image, checked with offline AV"
docker compose exec app diode-upload delete 3f1c... --by "A. Admin" --reason "sender asked to drop it"releasereads the file again and refuses if its SHA-256 no longer matches what the user sent. It then moves the file to the outbox under the name it would have had, with its.sha256. If the batch manifest has already gone, a release manifest follows.- Infected (
quarantined) files can only be deleted, never released. --byand--reasonare required and recorded in the audit log (upload.released,upload.discarded).- The commands talk to the running service over a unix socket inside the
app container (
/tmp/diode-upload.sock, mode 0600, only accepted from the service's own user), so they need Docker access on the host and nothing is exposed on the network.
To stop one client tying up the staging disk, the service caps what a browser session, and a client address across all its sessions, may have in progress at once:
| Per session | Per address | |
|---|---|---|
| Batches not yet finished | 10 | 30 |
| Files still uploading | 20 | 60 |
| Bytes still to arrive | 100 GiB (2 × MAX_FILE_SIZE) |
300 GiB |
A request over a cap gets 429 Too Many Requests. The upload page waits and
retries on its own ("Waiting for your earlier uploads to finish"). An upload
that receives nothing for UPLOAD_EXPIRY (1 hour) is dropped, and a batch
with no uploads is deleted after the same time. nginx also limits upload
creation to 2 per second per address (burst 20). If many users share one
address (a terminal server or NAT), raise the IP_MAX_* settings.
| Symptom | Cause and fix |
|---|---|
no configuration file provided: not found |
The Docker snap is in use. See Prerequisites. |
state store: mkdir /state/batches: permission denied |
The state volume was created by an image from before v0.5.1, so it is owned by root. If nothing has been uploaded yet: docker compose down, then docker volume rm diode-upload_state, then docker compose up -d --build. To keep existing data instead: docker run --rm -v diode-upload_state:/state busybox chown -R 65532:65532 /state |
Upload page says Could not start: request did not come from this site |
PUBLIC_ORIGIN does not exactly match the address in the browser (scheme, host name and port all count). docker compose logs app | grep "request rejected" shows the origin the browser sent next to expected_origin. In the browser's developer console, location.origin prints the value to use. Set PUBLIC_ORIGIN to it in .env (no trailing slash), run docker compose up -d app, then reload the page. Users must all use that one address. |
Upload page says reload the page and try again |
The page's session cookie or CSRF token is missing or no longer valid, for example because secrets/csrf_key was changed while the page was open, or the browser blocked the cookie. Reload the page. The log line request rejected shows reason no_session or bad_csrf. |
storage layout: ... permission denied or outbox: ... no such file or directory |
DIODE_ROOT or its outbox/ is missing or not writable by uid 65532. See Host layout. |
storage layout: ... (the folder must belong to uid 65532) |
incoming/, quarantine/ or tmp/ under DIODE_ROOT was created by Docker or by root. sudo chown 65532:65532 "$DIODE_ROOT"/{incoming,quarantine,tmp}, then docker compose up -d. |
| A file shows "Waiting for your earlier uploads to finish" | A limit is reached for that browser or computer. It starts by itself when earlier uploads finish. docker compose logs app | grep "refused: limit" shows which cap. |
| A file shows "Held for administrator review" | See Held files. |
| Uploads stay at "Scanning" for a long time after the first start | clamd is still downloading signatures. docker compose logs -f clamd; scanning starts after the app logs clamd self-test passed. |
| Variable | Default | Notes |
|---|---|---|
PUBLIC_ORIGIN |
(required) | Exact origin users browse to; checked against Origin on every write |
TRUSTED_PROXY_CIDRS |
none | Peers allowed to set X-Real-IP |
DATA_DIR / OUTBOX_DIR / STATE_DIR |
/data / $DATA_DIR/outbox / /state |
|
MAX_FILE_SIZE |
50GiB |
|
CHUNK_SIZE |
64MiB |
nginx client_max_body_size for the uploads path must be this + 1 MiB |
DISK_RESERVE |
10GiB |
Free space kept beyond all unfinished uploads |
UPLOAD_EXPIRY |
1h |
An upload that receives nothing for this long is dropped; so is a batch with no activity |
CLAMD_ADDR |
clamd:3310 |
|
CLAMD_PATH_PREFIX |
$DATA_DIR |
Where clamd sees DATA_DIR (for an external clamd) |
CLAMD_MAX_SCAN_SIZE |
2047M |
Largest file clamd fully scans; must match MaxFileSize in clamd.conf (libclamav's limit is 2 GiB) |
SCAN_CONCURRENCY / SCAN_TIMEOUT |
2 / 2h |
|
OVERSIZE_POLICY |
hold |
hold (operator releases), pass (sent flagged unscanned) or reject (refused at upload) |
QUARANTINE_ENABLED |
true |
false deletes infected files instead |
MAX_BATCH_FILES |
500 |
|
SESSION_MAX_OPEN_BATCHES / _OPEN_UPLOADS / _RESERVED |
10 / 20 / 2 × MAX_FILE_SIZE |
Per browser session, see Limits; 0 = no cap |
IP_MAX_OPEN_BATCHES / _OPEN_UPLOADS / _RESERVED |
3 × the session values | Per client address |
CONTROL_SOCKET |
/tmp/diode-upload.sock |
Socket for the held-file commands; off disables them |
INSTRUCTIONS_FILE |
/config/instructions.md |
Headings, lists, paragraphs and **bold**; everything is HTML-escaped |
CSRF_KEY_FILE |
/run/secrets/csrf_key |
At least 32 bytes |
HEALTH_ADDR |
0.0.0.0:9100 |
/healthz, /readyz (metrics join in stage 3) |
- No third-party Go modules. The tus server, clamd client, store and audit log use only the standard library, which keeps the supply chain to the Go toolchain and two base images.
- CSRF:
SameSite=Strict__Host-session cookie, an HMAC token inX-CSRF-Tokenon every write (including tus chunks), exactOriginmatch,Sec-Fetch-Sitecheck, no CORS. - Isolation between users: batches and uploads belong to the browser session that created them; anything else answers 404.
- Injection: client file names never touch the filesystem (files are
stored by random ID; outbox names keep only characters safe on Linux and
Windows, see Outbox format);
tus metadata keys are whitelisted; no shell, no SQL; the page uses no
innerHTMLand runs under a strict CSP with Trusted Types. - Containers: read-only root filesystem,
cap_drop: ALLwith nothing added back,no-new-privileges, non-root users, memory, CPU and process limits, rotated logs, and internal networks for app↔clamd and proxy↔app. clamd runs as the image'sclamavuser through its unprivileged entrypoint, reads uploads through group 65532, and mounts onlyincoming/andtmp/, read-only. - Limits: per-session and per-address caps on work in progress (see Limits) and rate limits at the proxy.
- No authentication yet: anyone who can reach the page can upload. Restrict port 443 to approved client subnets with the host firewall.
- No admin site yet: held files are handled with the commands in Held files.
- No audit or quarantine retention pruning yet.
- If a browser declares more files than it ever creates (for example the page
is closed early), the batch manifest is written once nothing has happened
in the batch for
UPLOAD_EXPIRY, withmissing_countset. Files themselves cross as soon as they pass.
Go 1.27 or later.
go test -race ./... # unit tests + end-to-end tests against a fake clamd
go vet ./...