FileES / canonical system manual
Architecture, operation and recovery
A file-centric synchronization system built around explicit ownership, durable artifacts, bounded publication and recoverable failure.
Behaviour is implemented and covered by relevant native, integration, regression or chaos tests.
Code exists, but some target-platform acceptance or later integration remains incomplete.
The contract and boundaries are approved; implementation is incomplete or intentionally deferred.
Direction is recorded, but it is not part of the current operational contract.
Chapter 1: Mechanics and Architecture
1.1 Protocol Overview
FileES is a file-synchronisation daemon that keeps local working directories in sync with a remote SVN repository. SVN is used here purely as a transport and storage layer — revision-control semantics are secondary. The target user experience is a silent tray icon that keeps files synchronised without the user ever needing to know that SVN is involved.
The system is designed around the following principles:
- Convergence, not journalling. The periodic scanner captures end-state between ticks. Several filesystem operations between two scans may collapse into a single SVN commit.
- No data loss. The commit staging map is written atomically to disk after every event. If the daemon dies between a commit being accepted by the server and the working-copy metadata being updated, the next restart reconciles by querying SVN status and skipping already-accepted entries.
- HEAD wins. Conflicts are resolved by keeping the server version. The local conflicting version is preserved in a timestamped
!kolizje/directory beforesvn resolve --accept theirs-fullis called. - Push deploy. Client identity (an Ed25519 key pair) is provisioned by the server through a reverse SSH tunnel initiated by the client. The server never holds a private key.
System topology
The diagram is organised by ownership boundary, not by Go package. Packages may move; the authority and communication rules must not.
Truth and artifact map
FileES has several explicit states, but each state has one owner. Derived views may be discarded and rebuilt; secrets never cross into SVN.
Onboarding lifecycle
The verified activation vertical reaches active. Each boundary is persisted before the next external effect, allowing expiry or fail-closed recovery without inferring hidden process state.
sshd.1.2 Client Architecture
For each configured repository the daemon runs an independent pipeline:
Scanner (watcher) --events--> Commit Service --svn--> SVN server
|
HEAD Poller
|
Reconciliation
Scanner (pkg/watcher)
Periodically walks the working-copy tree and compares current state with the manifest stored in .filees/state/manifest.json. Change detection uses mtime, size, and MD5 hash. Files under 64 MiB are hashed inline within a per-scan budget; larger files are queued in a persistent MD5 backlog processed one-at-a-time by a background worker that always picks the smallest pending file. This enables rename detection even for large files.
Deletion events are debounced for 5 minutes (equal to the new-file latency) so that a file that is deleted and immediately re-created does not produce a spurious delete commit. During an active commit (commit.busy flag) the scanner switches to lightweight mode, watching only .filees/tickets/.
watch_interval, and scanning consumes predictable I/O.Commit Service (pkg/commit)
Coalesces scanner events per path and, on each commit tick, produces batches bounded by max_batch_files (default 100) and max_batch_mib (default 512 MiB). A single file larger than the MiB limit gets its own batch so it cannot starve the queue. If total staged bytes reach backlog_flush_mib (default 1024 MiB) and at least one entry is outside the new-file latency window, a flush is forced without waiting for the regular interval.
Before any SVN operation, each path is re-classified by svn status --xml --verbose to confirm its actual state in the working copy. Empty commits are prohibited.
Event coalescing rules:
| Sequence | Result |
|---|---|
Added + Modified | Added |
Modified + Deleted | Deleted |
Deleted + Added | Added (file came back) |
Renamed with unversioned source | Degraded to Added |
HEAD Poller and Reconciliation
Every poll_interval (default 30 s) the daemon fetches the server HEAD revision. If HEAD exceeds the local revision, svn update is called. Before any update, a full recursive status is run; a missing entry defers the update so that local deletes and rename sources are not reconstructed from the server. Conflicts found after update are handled by copying the local version to !kolizje/<timestamp>_lokalne/ with a .meta sidecar, then calling svn resolve --accept theirs-full and emitting a RECON-3002 error.
!kolizje/ first.IPC Server (pkg/ipcserver) and Client (pkg/ipcclient)
The daemon exposes a Unix domain socket at \$XDG_RUNTIME_DIR/filees.sock (fallback: ~/.filees/daemon.sock). The protocol is filees.contract/v1 — versioned JSON Lines, one object per line. Normal requests are request/response; events.subscribe switches the connection to a server-push stream. Currently implemented commands: system.hello, system.status, update.status, update.plan, update.apply, activation.begin, activation.finish, repo.list, repo.status, repo.activity, repo.create_request, repo.attach_intent, repo.attach_approve, repo.relocate, repo.lifecycle_status, repo.lock, repo.unlock, error.list, events.subscribe. repo.pause, repo.sync_now, repo.publish, conflict.list, conflict.get, conflict.decide, notice.list and notice.ack are declared in the contract but not yet wired into the daemon dispatcher.
.filees or execute SVN.Runtime Gates (pkg/runtime)
HostGate limits the number of concurrent commits across all daemon instances on the host (directory-based locking). RepoMutex enforces at most one commit at a time per repository. Both use atomic directory operations with PID, creation timestamp, and a unique owner token. On Linux/POSIX (S3 correction), the owner file carries an OS-managed flock so that PID recycling cannot produce a false “lock is still held” result.
1.3 Operation States
Onboarding operations (from pkg/onboarding/types.go) progress through the following states:
| State constant | String value | Meaning |
|---|---|---|
OperationAwaitingTunnel | awaiting_tunnel | Ticket claimed; waiting for OTP and reverse tunnel |
OperationTunnelAuthorized | tunnel_authorized | BSD Auth validated OTP; forward not yet established |
OperationTunnelStarted | tunnel_started | filees-entry confirmed reverse port; worker about to start |
OperationHelperAnnounced | helper_announced | Worker pinned the client helper |
OperationIdentityGenerated | identity_generated | Ed25519 key pair generated on client; public key stored in bundle |
OperationAccessStaged | access_staged | Installation public key is present in the generated SSH/SVN runtime authorisation |
OperationPossessionProved | possession_verified | System sshd authenticated the installation key and the server forced command wrote a matching receipt |
OperationActive | active | Client record and first projection were committed to the service repository |
OperationOTPExhausted | otp_exhausted | Too many wrong OTP attempts |
OperationExpired | expired | Operation TTL elapsed |
Repository provisioning states (from pkg/provisioning):
local_validated
-> storage_preflight_requested
-> storage_preflight_failed -> storage_preflight_requested
-> storage_approved
-> provisioning_requested
-> repository_request_failed -> provisioning_requested
-> repository_ready
-> initial_commit_in_progress
-> initial_commit_failed -> initial_commit_in_progress
-> initial_snapshot_published
-> active
A storage_preflight_failed terminus (e.g. the worker's STORAGE_INSUFFICIENT refusal when the physical filesystem lacks room) is durable but not itself self-healing: the GUI polls repo.lifecycle_status by operation_id and surfaces the outcome as a second notification, since a repository that never reaches active never gets a registered repo_id and is therefore invisible to repo.list/error.list.
1.4 Bundle Layout and Filesystem Protocol
The server side uses a filesystem-based protocol with no hidden database or background daemon. A single committed filees.onboarding-bundle/v4 JSON file is the atomic record of an onboarding operation. It contains three sub-objects: Operation, Outbox (delivery state retained, OTP and address redacted after terminal delivery), and Audit (append-only history). One rename(2) publishes all three atomically.
fsync.Server-side directory layout (root is configurable, default /var/filees/onboarding):
/var/filees/onboarding/ mode 0700
├── .toolchain.lock mode 0600 (flock for command serialisation)
├── tickets/
│ └── <sha256(email)>.json mode 0600 (ticket named by email hash)
├── operations/
│ ├── .claim-<req_id>.json mode 0600 (transient claim; crash-visible)
│ └── <req_id>.json mode 0600 (committed bundle)
└── audit/
└── <time>-<obj>-<event>.json
Client-side state directory (inside each working copy):
<wc>/
├── !kolizje/ (conflict copies; scanner-ignored)
│ └── YYYY.MM.DD@HH.MM_lokalne/
│ ├── <relpath> (local file before conflict)
│ └── <relpath>.meta (JSON: orig_rel, timestamp, size)
└── .filees/
├── state/
│ ├── manifest.json (mtime, size, MD5 per file)
│ ├── commit.busy (flag; TTL 10 min)
│ ├── head.rev (last committed/updated revision)
│ ├── client.uuid (stable per-installation UUID)
│ ├── daemon.pid
│ └── md5.backlog.json (large-file MD5 queue)
├── commit_cache/
│ └── cache.json (staging map; survives restarts)
├── tickets/ (outgoing notification tickets)
├── logs/
│ └── errors.jsonl (structured error log, JSON Lines)
└── locks/ (HostGate and RepoMutex dirs)
1.5 OTP Mechanism
The one-time password for tunnel authorisation has 120 bits of entropy encoded as Base32 in the format LLLLLLLL-SSSSSSSSSSSSSSSS. The 40-bit prefix is a locator that identifies the operation bundle without exposing the operation ID. The full value is verified as HMAC-SHA-256 against a server-side private pepper (minimum 256 bits) stored at the path given by otp_pepper_file in server.json.
Each wrong attempt atomically decrements a counter stored in the bundle. On expiry, OTP exhaustion, or successful use the hash and locator are erased from the bundle. The plaintext OTP exists only in the pending mail outbox entry until the mail is accepted by the smarthost (250 after DATA), at which point the delivery address and OTP are atomically redacted.
1.6 SSH Transport: entry → worker → helper
FileES uses the system sshd as its only network endpoint. Three separate protocol accounts are created on the server:
| Account | Auth method | Forced command | Forwarding |
|---|---|---|---|
_filees-onboard | Public key (compiled bootstrap Ed25519) | filees-bootstrap-entry | None (DisableForwarding yes) |
_filees-tunnel | keyboard-interactive:bsdauth via login_-filees style | filees-entry | Remote TCP on 127.0.0.1:42000 only |
_filees-client | Per-installation Ed25519 public key | Per-key filees-client-entry <operation_id> <client_id> | None (DisableForwarding yes) |
inetd service or long-running socket daemon.The push deploy and activation flow:
- Entry A — onboarding request. The client SSH-connects with a compiled bootstrap Ed25519 key to
_filees-onboard. The boundedfilees-bootstrap-entryreads one versionedfilees.onboard-request/v1frame, runs the atomic onboarding take, and then performs exactly one SMTP outbox submission. Connection then closes; no FileES mail daemon remains running. - Entry B — OTP and reverse tunnel. After receiving the OTP by email, the client runs:
The OTP is supplied through a FIFO-based askpass (ssh -F /dev/null -l _filees-tunnel -T \ -R 127.0.0.1:42000:127.0.0.1:<local-helper-port> \ <server> "filees tunnel-v1"SSH_ASKPASS_REQUIRE=force). BSD Auth validates the OTP by reading thelogin_-fileesstyle and atomically advances the bundle totunnel_authorized.filees-entryconfirms theSSH_ORIGINAL_COMMANDis exactlyfilees tunnel-v1, claims the tunnel grant (advancing totunnel_started), thenexecsfilees-worker deploy. - Worker — identity generation.
filees-workerconnects through the reverse tunnel to the client-side helper using a pinned Ed25519 worker key. The helper accepts onlyfilees-deploy/v1. Forgenerate_identity, the client generates an Ed25519 key pair locally; only the public key and fingerprint cross back to the worker. The private key never leaves the client machine. - Access staging and possession proof. The worker stages a restricted line in
/var/filees/activation/authorized_keysand read-only SVN authz. It then asks the same helper to performprove_service_access. The client opens a new SSH connection as_filees-clientwith the installation private key and the fixed commandsvnserve -t. After system sshd authenticates the key,filees-client-entrywrites a server-side receipt and execs the configuredsvnserve. A helper response alone is never accepted as proof. - Activation publication. After validating the receipt, the worker commits the service format, realm, client record, first client view and audit event in one SVN revision, then advances the operation to
active. The active key retains read-only access only to its own client subtree.
The client-side helper is a minimal SSH server listening only on loopback with an ephemeral host key. It refuses shell, PTY, subsystems, global requests, and any command other than filees-deploy/v1. Responses are size-limited to 64 KiB using io.LimitReader before decoding (S3 hardening).
1.7 pledge / unveil Sandbox Model
On OpenBSD every server-side tool calls pledge(2) and unveil(2) with per-command minimal profiles. The process flow is:
parse argv / verb
-> pledge("stdio rpath wpath unveil") # bootstrap
-> load and canonicalise config
-> read-only preflight of private layout (before unveil lock)
-> unveil(exact paths, exact perms)
-> unveil(NULL, NULL) # lock
-> pledge(runtime promises) # reduce
-> open prepared repo handle and executesetrlimit.Per-tool promise and path matrix:
| Tool / verb | Runtime promises | Unveiled areas |
|---|---|---|
filees-admin ticket list | stdio rpath wpath flock | tickets (r), lock (rw) |
filees-admin ticket create/revoke | stdio rpath wpath cpath fattr flock | tickets+audit (rwc), lock (rw) |
filees-admin operation inspect | stdio rpath wpath flock | operations (r), lock (rw) |
filees-onboard take | stdio rpath wpath cpath fattr flock | tickets+operations+audit (rwc), lock (rw) |
filees-operation recover | stdio rpath wpath cpath fattr flock | all areas (rwc), lock (rw) |
filees-mail send | stdio rpath wpath cpath fattr flock inet dns | operations (rwc), lock (rw), /etc/resolv.conf, /etc/hosts (r) |
filees-entry | stdio proc exec | fixed worker image and runtime loader |
filees-worker deploy | stdio rpath wpath cpath fattr flock inet proc exec | operations, worker key, activation root, service WC/repository and fixed SVN binary |
filees-client-entry | stdio rpath wpath cpath fattr flock proc exec | activation receipt root, service repository, authz and fixed svnserve binary |
filees-admin client revoke | stdio rpath wpath cpath fattr flock proc exec | activation root, service WC/repository and fixed SVN binary |
login_-filees (BSD Auth style) | stdio rpath wpath cpath flock | operation root, pepper file |
proc exec boundary for a fixed executable; no promise or executable path is supplied by a network request.The filees-entry binary is setuid to _filees-state. filees-worker is mode 0555 and inherits the effective UID through exec. This split is required because OpenBSD refuses to execute a setuid/setgid image when execpromises are set.
1.8 Service Repository
The service repository (also called the technical repository) is a private SVN repository that acts as the versioned control plane. It does not store synchronised user files. Each monitored directory has its own separate data repository.
The implemented activation vertical stores the following non-secret persistent state:
- Realms and their immutable identifiers
- Client instances,
client_id, public keys, fingerprints - Per-client read-only views
- Activation and revocation audit events
- Service format version
Implemented directory layout:
/
├── format.json
├── admin/
│ ├── realms/<realm_id>.json
│ ├── clients/<client_id>.json
│ └── audit/<operation_id>.json or <operation_id>-revoke.json
└── clients/
└── <client_id>/
└── view.json
The activation manager is the only writer in this vertical. Each active client has read-only SVN access to its own /clients/<client_id>/ subtree and no visibility into admin/ or other clients. One SVN commit publishes the client record, realm if new, projection and audit event atomically. The broader catalogue for data repositories, grants, roles, invitations and general domain operations remains designed but is not yet implemented.
Chapter 2: User Guide
2.1 Client Installation
Linux (bundle from build-gui.sh)
# Unpack the filees-client-linux-amd64 bundle
chmod +x install-user.sh
./install-user.sh # installs to ~/.local/
ENABLE_AUTOSTART=1 ./install-user.sh # install + enable autostart
The installer copies the daemon binary to ~/.local/bin/filees, the GUI binary to ~/.local/bin/filees-gui, an icon, and a .desktop file. It installs a systemd --user unit (filees.service) but does not enable an inactive service without explicit request; an already active service is restarted by default (set RESTART_DAEMON=0 to suppress that restart). An existing config.json is never overwritten on upgrade.
To start the daemon as a user service:
systemctl --user enable --now filees.service
Windows GUI artifact (MSI)
Run build-msi.ps1 in PowerShell on a machine with the WiX Toolset wix command (verified with WiX v7), then install the generated .msi. A first WiX v7 build needs the one-time command wix eula accept wix7. Installation is per-user; no UAC prompt is required. The Start Menu shortcut has System.AppUserModel.ID = ATMProjekt.FileES, enabling toast notifications. This artifact does not constitute acceptance or packaging of the complete Windows sync daemon.
filees-gui --autostart disable first. The installer does not automatically remove the HKCU\...\Run registry value that the application creates.Requirements
| Platform | Requirements |
|---|---|
| Linux daemon | svn and OpenSSH ssh in PATH, an activated FileES installation identity and pinned service host key; Go 1.24+ only when building from source |
| Linux GUI | SNI-compatible desktop (KDE, XFCE, MATE); GNOME requires AppIndicator extension; zenity or kdialog for file picker; xdg-open; notify-send (optional) |
| Windows GUI | Windows 10+; PowerShell; MSI install for toast notifications (AUMID registration) |
2.2 Configuration
The daemon reads config.json from the working directory (or the path given by --config). SSH transport identity belongs to the client installation and is shared by its repository projections:
{
"transport": {
"identity_file": "/home/user/.local/share/filees/identity/id_ed25519",
"known_hosts": "/home/user/.local/share/filees/known_hosts"
},
"update": {
"enabled": true,
"repo_url": "https://releases.example/FILESS-BIN",
"channel": "stable",
"component": "desktop",
"platform": "linux-amd64",
"state_path": "/home/user/.local/state/filees/update.json",
"stage_root": "/home/user/.local/state/filees/update-stage"
},
"repositories": [
{
"id": "projectA",
"repo_url": "svn+ssh://_filees-client@server/repo/trunk",
"local_path": "/home/user/projectA",
"commit_interval": "1m",
"watch_interval": "30s",
"poll_interval": "30s",
"max_batch_files": 100,
"max_batch_mib": 512,
"backlog_flush_mib": 1024,
"shutdown_commit_timeout": "10m",
"lock_first": false,
"edit_passports": false,
"edit_passport_ttl": "15m",
"edit_passport_heartbeat": "5m",
"edit_passport_max_session": "24h",
"edit_passport_close_grace": "5m",
"shout_patterns": ["\\.psd$", "\\.blend$"],
"rate_limit_shout": "5m",
"commit_tiers": [
{"max_mb": 1, "interval": "2m"},
{"max_mb": 10, "interval": "5m"},
{"max_mb": 50, "interval": "15m"},
{"max_mb": 0, "interval": "24h"}
]
}
]
}
Key configuration fields:
| Field | Description |
|---|---|
id | Unique repository identifier (used in logs and state paths) |
transport.identity_file | Absolute path to the Ed25519 installation key created during activation |
transport.known_hosts | Absolute path to the pinned service host key file |
update.enabled | Enable the signed desktop updater; disabled by default |
update.repo_url | Release repository URL using SVN, SVN+SSH or HTTPS, without password, query or fragment |
update.channel | Release channel identifier; defaults to stable |
update.component | Must be desktop for this client |
update.platform | Must match the running GOOS-GOARCH; defaults to that value |
update.state_path | Absolute private path for the durable anti-rollback high-water state |
update.stage_root | Absolute private directory used for verified bundle staging |
repo_url | Restricted svn+ssh://_filees-client@host/... URL; other transports are rejected |
local_path | Absolute path to the working copy. Must not overlap with other configured roots. |
commit_interval | Base commit window, e.g. 1m, 30s |
watch_interval | Filesystem scan interval |
poll_interval | How often to check server HEAD and pull updates (default 30s) |
max_batch_files | Max files per commit (directories added as parents do not count) |
max_batch_mib | Target max size per commit; a single oversized file gets its own batch |
backlog_flush_mib | Force publish when staged total exceeds this (must be ≥ max_batch_mib) |
shutdown_commit_timeout | Max drain time on controlled shutdown (default 10m) |
lock_first | Try svn lock before every commit (mutually exclusive with edit_passports) |
edit_passports | Enable edit-passport mode (opt-in; see section 2.5) |
commit_tiers | Size-adaptive intervals; last entry with max_mb: 0 is the catch-all |
shout_patterns | Regex list; matching files produce a notification ticket |
All durations use Go format: 30s, 5m, 1h30m.
local_path must be absolute. Paths must not be identical or nested in either direction. Validation resolves symlinks on existing directories and aborts daemon startup with a hard error before any state is created.To validate configuration without starting the daemon:
filees config-check --config /path/to/config.json
2.3 First Run and Onboarding
The onboarding flow provisions the client’s identity and activates its first service-repository projection. It requires an administrator to create a one-time ticket. On the client, choose Activate client on a new server…, enter a local alias, the FileES server address and the ticket email, then paste the OTP when prompted. The GUI only presents the flow; the daemon owns all network and state transitions.
- Administrator creates a ticket with an email delivery signal and an approved realm:
filees-admin ticket create --email you@example.com --realm-id <uuid>. - The daemon submits the email through the compiled bootstrap-key SSH entry. The server consumes the ticket and makes one SMTP submission attempt.
- The GUI prompts for the delivered OTP; the daemon supplies it through FIFO askpass and establishes the fixed reverse tunnel to its loopback helper.
- The helper generates an Ed25519 installation key locally. The worker stages access, asks the helper for service proof, and validates the receipt written after system-sshd authentication.
- The worker publishes the client record and first view in the service repository and marks the operation
active.
svn info passed on the persistent OpenBSD 7.9 reference VM. Release engineering still has to provide production pins, release-bound bootstrap-key rotation and a signed-payload policy. Windows bootstrap remains unsupported. See PUSH_DEPLOY_CLIENT_READINESS.md and concepts/GUI_REDEPLOY_OPENBSD_E2E_2026-07-19.md.Adding a local directory
The local provisioning state machine drives the following data-repository process against the authenticated server-side repository worker (pkg/repoworker), which is connected and verified end to end — including its refusal path:
STORAGE_PREFLIGHT— the client sends an authenticated ticket with the local snapshot's total size; the worker checks it against the physical filesystem's available capacity (optionally through a reservation ledger that accounts for other in-flight operations) and refuses withSTORAGE_INSUFFICIENT(exact required/available bytes, rendered in human-readable units) if there isn't room.CREATE_REPOSITORY— the client sends an authenticated ticket to the server worker, which creates the SVN data repository, sets the owner grant and ACL, and returns a canonicalrepo_idandrepo_url.INITIAL_COMMIT— the client checks out the empty repository root into the local directory, performs a controlledsvn addrespecting ignore patterns, publishes the initial snapshot in bounded batches, confirmsINITIAL_COMMIT, and only then starts the normal sync pipeline.
The local state machine preserves operation_id across retry, and each ticket type is idempotent on the worker side (a repeated request with the same operation_id replays the durable result rather than re-executing). After successful repository creation the client records the returned repository identity durably before starting its import, so a restart or retry resumes that import instead of creating a duplicate server repository. A failure at any of these three durable boundaries is reported back to the GUI via repo.lifecycle_status polling rather than left silent.
2.4 Day-to-Day Sync
Once active, the daemon operates without user interaction:
- Local file changes are detected within one
watch_intervaltick. New files wait 5 minutes before their first commit. - Commits are grouped by the tier matching the current staged size (
commit_tiers). - Server changes are pulled every
poll_interval. If a conflict is detected the local copy is preserved in!kolizje/and the server version wins. - The daemon recovers from network outages automatically with backoff. Staged changes accumulate in
cache.jsonand are published when the connection is restored. - On controlled shutdown (
SIGINT/SIGTERM) the daemon drains the staging in bounded batches withinshutdown_commit_timeout. Any remaining changes are written tocache.jsonand resumed on next start.
File ignore patterns
Built-in ignores (cannot be overridden): ~\$* (MS Office), .~lock.*# (LibreOffice/OpenOffice), *.tmp, *.bak, .DS_Store, Thumbs.db, desktop.ini, .vscode/, .idea/, *.swp, *.swo, node_modules/, __pycache__/, *.o, *.pyc, .git/.
User-defined ignores: place patterns in .filees/user_ignore.cfg (hot-reloaded on every scan). Lines starting with ! are hard ignores that skip entire subtrees. Glob syntax supports ** for any depth.
2.5 Edit Passports
Edit passports are an opt-in feature ("edit_passports": true) that provides explicit per-file locking with heartbeat renewal. When enabled, lock_first is automatically disabled.
Behaviour under passport mode:
- All existing regular files are migrated to carry
svn:needs-lockon first start (the daemon refuses to start migration if the working copy has local content changes, deletes, or missing files). - A file without an active passport is locally read-only. For a repository whose owner realm is not this client's own realm, it becomes writable only after a successful
repo.lock/filees lockcall, including the Lock files… action in the repository tray submenu. - Autolock (owner realm): for a repository this client's own realm owns, no manual lock action is needed at all. Right after every
svn up, the daemon makes its own read-only files locally writable immediately — before the real SVN lock is acquired. The real lock is only requested at the moment of the next commit attempt for that path: if the repository is genuinely free (or the lock is already held by another of this same realm's own instances, which is taken over silently, no dialog), the commit proceeds; if a different realm already holds the lock, the commit is held back and the change stays staged for a later retry — it is never stolen, and the local edit is never lost. - New files do not require a passport before their first publication; the daemon assigns
svn:needs-lockbefore the commit. - Commits use
svn commit --no-unlockso that SVN does not bypass the close-grace period. - After a confirmed commit, the daemon waits for the close-grace period (
edit_passport_close_grace, default 5 m) before unlocking and returning the file to read-only.
OwnerRealmID) — the same realm identity described in §5.5. Since cross-realm repository sharing is still a designed, not yet implemented, extension (§5.5), a repository has exactly one realm with write access today, so autolock and "the repository's own realm" currently mean the same thing. Once cross-realm sharing exists, autolock priority is designed to generalize to per-path ownership (whoever's realm first committed a given path) without changing the mechanics described here.Passport timing parameters:
| Parameter | Default | Meaning |
|---|---|---|
edit_passport_heartbeat | 5 m | How often the daemon renews the token |
edit_passport_ttl | 15 m | Validity of a single renewal; heartbeat must be shorter |
edit_passport_max_session | 24 h | Hard session limit; cannot be extended by heartbeats |
edit_passport_close_grace | 5 m | Silence period after confirmed commit before unlock |
pre-lock hook or authoritative worker, which is not yet deployed. The previous_token field in the lock comment is already transmitted so the server hook can be added without protocol changes.2.6 GUI Tray
filees-gui is a separate process and a thin UX layer over the daemon IPC contract. It does not import any sync engine packages. Closing the GUI does not stop the daemon.
Icon states (priority order)
| Icon | State | Condition |
|---|---|---|
| dimmed / absent | Disconnected | Cannot reach daemon, or protocol mismatch |
| red | Attention required | interaction_required, degraded, conflict, or actionable error |
| grey | Offline | At least one repository has no server connection |
| orange | Busy | Commit, update, initialisation, or baselining in progress |
| green | Active | All repositories synchronised and online |
Reconnect behaviour
If the daemon is unreachable, the GUI retries with exponential backoff: 1 s → 2 s → 5 s → 10 s → 30 s. The last known snapshot remains visible but is marked stale. “Reconnect” in the menu forces an immediate retry.
Initialisation sequence
- Send
system.hello, store capabilities. - Send
events.subscribe(if capability present) before fetching snapshots. - Fetch
system.status,repo.list, andrepo.statusfor each repository. - On reconnect, sequence gap, or unknown event: perform full resync.
Events are cache-invalidation signals. The GUI never builds state purely by applying events; each event triggers a fresh repo.status fetch. Mutation actions (lock/unlock) are shown only when the daemon reports them as capabilities and the connection state is Connected (not Stale).
Server activations
Each active server is listed by its user-chosen alias and groups its repositories underneath. The technical host is not used as the primary label. Server information… shows the address, SSH port, client ID and effective client role. A transport configuration without an activation does not create a synthetic default server. If at least one activation exists, the tray header says Client activated.
Activate client on a new server… asks for an alias, FileES server address and email. A plain IP address or FQDN uses SSH port 22; an explicit host:port selects a non-default port. After the daemon begins onboarding, the GUI displays Enter the OTP code…. On success the daemon emits activation.changed, which triggers a full snapshot refresh; reconnecting the GUI manually is not part of the successful flow.
Autostart
filees-gui --autostart enable # write autostart entry for current executable path
filees-gui --autostart disable # remove entry
filees-gui --autostart status # enabled / enabled-stale / disabled
enabled-stale means the entry points to a different path or arguments than the current executable. Re-run --autostart enable from the installed location to fix it. On Linux this writes an XDG .desktop file to ~/.config/autostart/; on Windows it writes to HKCU\Software\Microsoft\Windows\CurrentVersion\Run.
2.7 Signed Desktop Updates
Desktop updates are opt-in and fail closed. A production client build embeds only the trusted OpenBSD signify public key and its canonical key_id; the configuration cannot replace that key or disable signature verification. The daemon advertises update.status, update.plan and update.apply only when the complete signed update service has been registered.
The signed release envelope v2 binds the release ID, monotonic sequence, security_epoch, expiry time, component and platform to a separately signed artifact manifest. The client verifies the signify format internally with standard Ed25519, then checks the bundle's exact size and SHA-256 before extraction. Its private, fsynced high-water state rejects sequence rollback, security-epoch rollback and a different release reusing the same sequence.
User flow
- A verified newer release adds a New update available badge to the tray menu.
- Show what will change… performs a fresh signed dry run. On Linux it compares the daemon, GUI and icon, reports regenerated desktop/service entries and explicitly marks an existing configuration as unchanged.
- Update and restart… resolves and verifies the release again, then shows a native confirmation dialog with the plan.
- On approval the existing platform installer runs from the verified bundle. Linux disables installer-internal autostart and daemon restart and preserves the user's configuration.
- After successful apply and durable high-water update, the GUI shuts down its loops, releases the single-instance lock and only then launches the newly installed executable with the original arguments.
Release operators must use the offline signing workflow in tools/RELEASE_PUBLISHING.md. Private release keys must never enter the source repository, build host, client bundle or test VM.
2.8 CLI Commands
All CLI sub-commands communicate with the running daemon through the Unix socket and never invoke svn directly:
filees status # state of all repositories
filees lock <file>... # acquire SVN lock
filees unlock <file>... # release SVN lock
filees log [N] # last N error log entries (default 20)
filees config-check # validate config without starting daemon
filees help
lock and unlock accept multiple files and group them by repository automatically. Relative paths are resolved to absolute and validated as lying inside a working copy.
Daemon log level (environment variable):
FILEES_LOG=debug ./filees
FILEES_LOG=trace ./filees # includes svn invocations
# Available: silent error warn info (default) debug trace
2.9 Troubleshooting
| Symptom | Action |
|---|---|
| GUI icon does not appear (GNOME) | Install the AppIndicator and KStatusNotifierItem Support extension from extensions.gnome.org; log out and back in |
| “Disconnected” but daemon is running | Check socket path: filees status; run filees-gui --socket /path/to/daemon.sock; check filees-gui --autostart status for enabled-stale |
| File picker does not open (Linux) | Install zenity or kdialog: apt install zenity / dnf install zenity |
| Toast notifications missing (Windows) | Ensure installation was via MSI (registers AUMID). Running filees-gui.exe without the installer will not register the AUMID. |
| Multiple tray icons | A per-user lock prevents a second instance from initialising the tray. If two icons appear, one may be from an older version. Use “Close GUI” on each, then restart the current installation. |
Conflict files accumulating in !kolizje/ | These are preserved local versions of files where a server change won. Review them manually. The directory is scanner-ignored and will never be committed. |
Chapter 3: Server Installation and Deploy
3.1 Prerequisites
The FileES server toolchain targets OpenBSD 7.9/amd64 as the primary and security-reference platform. A Linux/amd64 server bundle is also buildable; distribution-specific hardening remains an administrator responsibility because Linux cannot provide the same pledge/unveil contract. FileES adds no network listener: the system sshd is its only endpoint.
External dependencies:
- SVN server —
svnserve, independently installed and configured. This is the only supported access path: FileES reaches it as tunnel-modesvnserveover SSH. Apache/mod_dav_svnis not a FileES target and has never been tested. - SMTP smarthost — for OTP delivery; FileES does not run an MTA. Only submission-style relay (
host:port) is supported. - Go 1.24+ — only needed to build from source. Pre-built bundles are provided.
Required accounts to be created by the installer on OpenBSD:
| Account | Purpose |
|---|---|
_filees-state | Owns /var/filees/ and /etc/filees/; all server tools run as this user |
_filees-onboard | Receives bootstrap SSH connections; ordinary shell exists only so sshd can run the forced command; no real home directory |
_filees-tunnel | Receives OTP SSH connections with one fixed reverse tunnel; forced command only |
_filees-client | Receives installation-key SSH connections; per-key forced command permits only tunnel-mode svnserve |
3.2 Running install-server.sh
The generic installer script is packaging/server/install-server.sh (also included in the bundle at dist/filees-server-openbsd-amd64/).
# As root or with doas:
./install-server.sh
What the installer does:
- Installs
filees-adminandfilees-operationto\$PREFIX/sbin/(default/usr/local/sbin). - Installs
filees-onboard,filees-bootstrap-entry,filees-mail,filees-entry,filees-worker, andfilees-client-entryto\$PREFIX/libexec/filees/. - Creates
/etc/filees/(mode 0700) and copiesserver.example.jsonto/etc/filees/server.jsonif not already present. - Generates a 256-bit random OTP pepper:
/etc/filees/otp.pepper(mode 0600). - Generates an Ed25519 worker key pair:
/etc/filees/worker_ed25519(mode 0600) and/etc/filees/worker_ed25519.pub(mode 0644). - Creates the private state directory:
/var/filees/onboarding/{tickets,operations,audit}/(mode 0700). - Creates the private activation skeleton at
/var/filees/activation/; the OpenBSD integration step assigns final ownership and initialises the service repository.
rc.d or systemd service and does not touch sshd_config. The sshd integration is the separate OpenBSD step below.OpenBSD sshd integration: install-ssh.sh
# As root, after the generic install:
./packaging/server/openbsd/install-ssh.sh
What install-ssh.sh does:
- Creates
_filees-stateand the three protocol accounts_filees-onboard,_filees-tunnel, and_filees-client. Protocol accounts use/bin/shonly to permit sshd forced commands; interactive shell, PTY and user RC remain disabled by sshd policy. - Installs the
login_-fileesBSD Authentication style binary to/usr/libexec/auth/. - Installs the
filees-tunnellogin class and rebuilds the database withcap_mkdb. - Writes a validated
Match Userfragment to/etc/ssh/sshd_config.d/filees.conf(creating thesshd_config.d/directory and updating the mainsshd_configInclude if needed). - Installs the bootstrap authorized key for
_filees-onboardto/etc/ssh/filees_bootstrap_authorized_keys. - Installs the set-id dispatchers, creates
/var/filees/activation, initialises/var/filees/service-repoand its working copy, and connects svnserve to the generated authz file. - Verifies the configuration with
sshd -tand reloads the existing sshd.
3.3 OpenBSD sshd Configuration
The effective Match User blocks applied by install-ssh.sh are:
Match User _filees-onboard
AuthenticationMethods publickey
PubkeyAuthentication yes
AuthorizedKeysFile /etc/ssh/filees_bootstrap_authorized_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
ForceCommand /usr/local/libexec/filees/filees-bootstrap-entry
DisableForwarding yes
PermitTTY no
MaxSessions 1
AllowStreamLocalForwarding no
PermitTunnel no
PermitUserRC no
X11Forwarding no
AllowAgentForwarding no
Match User _filees-tunnel
AuthenticationMethods keyboard-interactive:bsdauth
PubkeyAuthentication no
AuthorizedKeysFile none
ForceCommand /usr/local/libexec/filees/filees-entry
PasswordAuthentication no
KbdInteractiveAuthentication yes
PermitTTY no
AllowTcpForwarding remote
PermitListen 127.0.0.1:42000
PermitOpen none
AllowStreamLocalForwarding no
GatewayPorts no
PermitTunnel no
X11Forwarding no
AllowAgentForwarding no
PermitUserRC no
MaxSessions 1
MaxAuthTries 2
Match User _filees-client
AuthenticationMethods publickey
PubkeyAuthentication yes
AuthorizedKeysFile /var/filees/activation/authorized_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
DisableForwarding yes
AllowStreamLocalForwarding no
PermitTTY no
PermitTunnel no
PermitUserRC no
X11Forwarding no
AllowAgentForwarding no
MaxAuthTries 1
MaxSessions 1
Match all
The Match all sentinel is required before any Include directive in the main sshd_config to prevent match blocks from leaking into included files. The installer handles this automatically.
127.0.0.1:42000 by default; configurable as reverse_port_first/reverse_port_last in server.json). This serialises infrequent deploys. The base sshd_config cannot make PermitListen conditional on the BSD Auth result, so a dynamic per-operation port would require a custom listener that FileES deliberately avoids.3.4 server.json Reference
After running the installer, edit /etc/filees/server.json:
{
"schema": "filees.server-toolchain/v1",
"root": "/var/filees/onboarding",
"otp_pepper_file": "/etc/filees/otp.pepper",
"worker_private_key_file": "/etc/filees/worker_ed25519",
"worker_public_key_file": "/etc/filees/worker_ed25519.pub",
"activation": {
"root": "/var/filees/activation",
"authorized_keys_file": "/var/filees/activation/authorized_keys",
"authz_file": "/var/filees/activation/service.authz",
"service_working_copy": "/var/filees/service-wc",
"service_repository": "/var/filees/service-repo",
"repository_name": "filees-service",
"client_entry_path": "/usr/local/libexec/filees/filees-client-entry",
"svn_binary": "/usr/local/bin/svn",
"svnserve_binary": "/usr/local/bin/svnserve"
},
"operation_ttl": "30m",
"otp_attempts": 5,
"reverse_port_first": 42000,
"reverse_port_last": 42000,
"smtp": {
"address": "smtp.example.net:587",
"server_name": "smtp.example.net",
"client_name": "filees.example.net",
"from": "filees@example.net",
"message_id_domain": "filees.example.net",
"username": "",
"tls": "starttls",
"connect_timeout": "10s",
"command_timeout": "30s"
}
}
| Field | Description |
|---|---|
schema | Must be filees.server-toolchain/v1 |
root | Absolute path to the private state directory (tickets, operations, audit) |
otp_pepper_file | Absolute path to file containing at least 32 bytes of base64-encoded secret pepper |
worker_private_key_file | Absolute path to Ed25519 private key used by filees-worker (mode 0600) |
worker_public_key_file | Absolute path to the corresponding public key in authorized_keys format |
activation | Absolute private runtime paths, service repository/WC, repository name and fixed client-entry/SVN executable paths |
operation_ttl | Lifetime of each onboarding operation, e.g. 30m |
otp_attempts | Maximum wrong OTP attempts before exhaustion |
reverse_port_first | First (or only) permitted reverse tunnel port |
reverse_port_last | Last permitted port (use same value as first for a single static port) |
smtp.address | Smarthost address as host:port |
smtp.tls | starttls, implicit, or none (none only on loopback) |
smtp.username | SMTP AUTH username; leave empty for unauthenticated relay |
smtp.password_file | Absolute path to file with SMTP password (mode 0600); required when username is set |
Mode 0600 is recommended for the config file; the loader requires a regular file that is not writable by group or others. All path values must be absolute. After editing, validate the non-secret command path:
doas -u _filees-state filees-admin -config /etc/filees/server.json ticket list
3.5 Key Generation
The installer generates all required keys automatically. If you need to regenerate:
# Regenerate OTP pepper (invalidates all pending OTPs)
umask 077
openssl rand -base64 32 > /etc/filees/otp.pepper
# Regenerate worker key pair (distribute only the .pub to client policy)
umask 077
ssh-keygen -q -t ed25519 -N '' -C filees-worker-v1 \
-f /etc/filees/worker_ed25519
Service repository setup
The OpenBSD integration script performs the implemented activation setup:
- Creates
/var/filees/service-repoand the initial/proofpath. - Checks out
/var/filees/service-wcfor activation and revocation commits. - Connects svnserve to
/var/filees/activation/service.authz; the activation manager atomically regenerates authz and authorized keys. - Commits
format.json, realm, client, projection and audit on the first successful possession proof.
This is the completed activation vertical. General data-repository creation, storage preflight, grants and INITIAL_COMMIT orchestration are now also connected to a server-side control-plane worker (pkg/repoworker, dispatched over the same forced-command SSH session) and verified end to end, including the STORAGE_INSUFFICIENT refusal path on a physically constrained filesystem — see the provisioning state machine in §2.3.
Running administrative commands
Administrative and one-shot server commands that read or mutate private state are run as _filees-state:
doas -u _filees-state filees-admin ticket create --email user@example.com --realm-id <realm_uuid>
doas -u _filees-state filees-admin ticket list
doas -u _filees-state filees-admin operation inspect --operation-id <operation_uuid>
doas -u _filees-state filees-admin client revoke --client-id <client_uuid> --reason "lost device"
doas -u _filees-state filees-mail send
3.6 Managed Install and Upgrade: filees-install
filees-install is the manifest-driven replacement for the server shell installers, modelled on the ksefUCK upgrade channel. Binaries are distributed through a dedicated SVN repository (FILESS-BIN) with a channel/release structure: channels/<name>.json points at releases/<id>/<platform>/manifest.json, and every file carries a SHA-256 hash. Production operation requires the embedded OpenBSD signify release key and strict channel/manifest verification; configuration cannot disable these checks.
# Plan against the configured channel (no changes):
filees-install -check
# Full plan including config-contract drift, no payload download:
filees-install -dry-run
# Install or upgrade to the channel head (or a named release):
filees-install -apply [r7]
# Stage-1 rollback: restore the previously installed files:
filees-install -rollback
# Stage-2 rollback: remove files, sshd fragment, login class and
# the users this installer created; keeps /var/filees and /etc/filees:
filees-install -purge
# ... and wipe operational state too (irreversible):
filees-install -purge -wipe-state
Configuration lives in /etc/filees/install.conf (INI: [repo], [local], [install], [policy], [security]). Key behaviours:
- First install also performs the system tasks that
install-ssh.shcovers today: creates the protocol accounts (recording which ones it actually created), state directories and secrets, installs the sshd fragment Include and login class, and reloads sshd. Upgrades never repeat system tasks. - Backup before overwrite: every replaced file is copied (full mode bits, including setuid) into a timestamped backup dir recorded in
state.json;-rollbackrestores exactly the last install. - Sandbox profile by run class: file-only runs (check, dry-run, upgrade, rollback) execute under a full two-pass
unveilwith pledge reduced to file promises. System runs (first install, purge, upgrades touching the sshd fragment) skipunveil— its state is inherited by exec'd children, which would blinduseradd/rcctl/sshd -t— and keepproc execonly until the system phase ends. - Purge honours ownership: only users recorded as created by this installer are removed; a rollback that restores an sshd fragment prints a warning to reload sshd manually instead of exec'ing under the reduced pledge.
concepts/SERVER_INSTALL_CONCEPT.md, concepts/SERVER_INSTALL_CODE_REVIEW.md and its corrections document.Chapter 4: Administration
4.1 filees-admin
filees-admin is the administrative command-line tool. It must be run as _filees-state (e.g. via doas -u _filees-state) to satisfy its pledge/unveil profile. It writes structured JSON to stdout and diagnostics to stderr. Exit codes follow the sysexits range:
| Exit code | Constant | Meaning |
|---|---|---|
| 0 | ExitOK | Success |
| 64 | ExitUsage | Bad arguments |
| 65 | ExitData | Input data error |
| 69 | ExitUnavailable | Resource unavailable (ticket taken, port exhausted) |
| 70 | ExitSoftware | Internal logic error |
| 75 | ExitTempFail | Temporary failure (retry later; used by filees-mail for SMTP 4xx) |
| 78 | ExitConfig | Configuration error |
Ticket management
filees-admin ticket create --email <mailbox> --realm-id <realm_uuid> [--ttl 24h]
filees-admin ticket revoke --ticket-id <ticket_uuid>
filees-admin ticket list
A ticket is a one-time administrator grant authorising the start of one onboarding operation for a given email signal. The ticket file is named by sha256(canonical_email) so the file name does not reveal the address. create writes the ticket with a TTL; revoke removes it. Once claimed by filees-onboard take the ticket physically disappears and is replaced by an operation bundle.
Operation inspection
filees-admin operation inspect --operation-id <operation_uuid>
Displays the current state and audit trail of an onboarding operation. If an operation is in state awaiting_tunnel after the TTL has passed, the OTP is exhausted or expired, and the record can be cleaned up by filees-operation recover.
Client revocation
filees-admin client revoke --client-id <client_uuid> --reason "one-line reason"
Revocation first writes a durable revoking record and removes the client from generated SSH/SVN runtime access. Only then does it commit the revoked client record and audit event to the service repository. An identical retry is idempotent; a different reason is rejected.
Future service-repository administration (not yet implemented)
The following additional commands are specified but not yet implemented. They will send authenticated domain commands to the service worker rather than modifying SVN directly:
filees-admin realm create|inspect|list|disable
filees-admin client inspect|list|disable
filees-admin repo inspect|list|disable|reconcile
filees-admin grant create|change|revoke
filees-admin invitation create|revoke|inspect
filees-admin operation inspect|abort|retry
filees-admin audit list|verify
filees-admin projection rebuild|verify
4.2 filees-operation
filees-operation recover
filees-operation inspect --operation-id <operation_uuid>
filees-operation recover scans the operations directory for claim files (.claim-*.json) and finishes them. A claim may be in two forms:
- Claim-as-ticket — the ticket was renamed to
.claim-<req_id>.jsonbut not yet replaced by a bundle. Recovery generates the bundle and completes the final rename. - Claim-as-bundle — the bundle was written into the claim file but the final rename to
<req_id>.jsonwas not completed. Recovery performs only the rename.
Recovery also expires unfinished operations whose TTL elapsed, redacts their OTP and delivery address, and removes expired staged keys from generated SSH/SVN access. Active clients are not expired by the onboarding TTL. Recovery is idempotent and safe after an unexpected system crash.
4.3 Audit Log
The server-side audit log is stored as individual JSON files in the audit/ subdirectory of the state root (/var/filees/onboarding/audit/). Each event file is named <timestamp>-<object_type>-<event_type>.json and follows the filees.onboarding-audit/v1 schema:
{
"schema": "filees.onboarding-audit/v1",
"event": "tunnel_session_started",
"actor": "_filees-state",
"object_type": "operation",
"object_id": "<operation_id>",
"operation_id": "<operation_id>",
"at": "2026-07-16T09:15:02Z"
}
The operation bundle itself contains an inline audit array (Audit []AuditEvent) recording the complete history of that operation. SVN history of the service repository provides an additional independent evidence layer for service-repository audit events.
Recorded onboarding audit event types include: ticket_created, ticket_revoked, operation_claimed, otp_attempt_failed, otp_verified, otp_exhausted, tunnel_session_started, helper_announced, helper_verified, identity_generated, access_staged, possession_verified, and client_activated. The service repository independently records client_activated and client_revoked events.
The client-side error log is stored in <wc>/.filees/logs/errors.jsonl as JSON Lines:
{"ts":"2026-07-16T10:00:00Z","scope":"commit:projectA","code":"NET-4007",
"severity":"WARN","hint":"RETRY_BACKOFF","msg":"Network unreachable — retrying"}
The most recent entries can be viewed with:
filees log 50
4.4 Mail Outbox
The public SSH forced command is filees-bootstrap-entry. It atomically creates or resumes the onboarding operation and then invokes exactly one filees-mail send attempt. That command reads the pending outbox entry from the operation bundle and submits it to the configured SMTP smarthost. FileES does not maintain a background mailer process.
The outbox delivery state machine:
pending -> sending -> queued (250 after DATA; OTP and address redacted)
-> pending (4xx temporary failure; OTP preserved)
-> failed (5xx permanent failure; OTP redacted)
A stale sending entry (from a previous attempt that died without recording a result) is reclaimed with a new attempt ID before retrying. The stable Message-ID header (derived from CreatedAt, not time.Now()) makes retry safe if the smarthost accepted but the reply was lost.
To retry after a temporary failure:
doas -u _filees-state filees-mail -config /etc/filees/server.json send
Exit code 75 (ExitTempFail) means the smarthost returned a 4xx; the OTP is preserved and the command can be retried. Exit code 0 means the message was accepted (queued state). A permanent failure (5xx) sets state to failed and redacts the OTP; manual intervention is required.
4.5 Recovery
Interrupted onboarding claim
doas -u _filees-state filees-operation recover
The command finishes any .claim-* file, expires unfinished operations past TTL, redacts their OTP material and removes expired activation staging. Active clients remain active. Safe to run repeatedly.
Client recovery after SIGKILL or power loss
The client daemon recovers automatically on the next start:
- Loads the full staging cache from
cache.json. - Runs
svn cleanupand (if nomissingentries)svn update. - Re-classifies every cached entry by
svn status; entries already markednormal(accepted by server) are dropped without re-committing. - Publishes the remaining entries in bounded batches.
This idempotent reconciliation was verified in fault-injection tests (r91–r92) where a server accepted a commit but the daemon process was killed before it could update the working-copy metadata and clear the cache. The restart correctly published only the missing remaining entry.
Conflict copies
Files in !kolizje/ are preserved local versions from before a server-wins conflict resolution. Each subdirectory is timestamped (YYYY.MM.DD@HH.MM_lokalne) and contains the file contents plus a .meta JSON sidecar with original path, size, and timestamp. They can be inspected and manually merged. The directory is scanner-ignored and never committed.
Orphaned passport after network loss
If the daemon loses network connectivity while holding a passport, it does not publish changes after the local TTL expires. On reconnect, the passport manager checks the actual lock on the server: if the token still matches, the heartbeat resumes; if the token has been replaced (by a force-lock or server-side expiry), the passport is marked lost. The file is returned to read-only; no automatic force-lock or commit is attempted.
Service repository projection rebuild
The clients/ subtree of the service repository is derived data. A future administrator command is intended to deterministically rebuild projections from canonical admin/ records:
filees-admin projection rebuild # not yet implemented
This command and the broader canonical grant model are not implemented yet; the current activation vertical writes the first client view directly in the same SVN revision as its canonical client record.
4.6 Monitoring
FileES does not yet expose a metrics endpoint. The following sources are available for monitoring:
| Source | Contents |
|---|---|
.filees/logs/errors.jsonl | Structured error log; codes, severity, hints, timestamps. Query with standard JSON tools. |
filees status | Live snapshot of all repository states, connectivity, pending counts, and local/HEAD revisions |
filees log N | Last N error log entries, newest first |
/var/filees/onboarding/operations/ | Operation bundle files; inspect state field with jq |
/var/filees/onboarding/audit/ | Timestamped audit events |
/var/filees/activation/records/ | Staged, active, revoking, revoked and expired activation records |
/var/filees/activation/authorized_keys | Generated current installation-key access; never edit by hand |
commit.Service.RecoveryStats() | Internal Go API (not yet in IPC contract): resumed cache entries, entries skipped as already accepted, batches executed |
For CI or automated checks, make verify runs go test ./... (including race detector for key packages), go vet ./..., and a local SVN recovery smoke test (scripts/svn-recovery-smoke.sh) that creates a temporary svnadmin repository and does not require network access.
4.7 Repository Rotation: filees-rotate
filees-rotate performs an explicit generation change of a hot repository: the new generation gets a new UUID and starts at r1 = a complete dump of the old HEAD (versioned properties such as svn:needs-lock included), inherits conf/ and hooks/ explicitly, and is verified (svnadmin verify plus tree comparison) before an atomic swap. The old generation becomes a frozen, commit-blocked FSFS archive with a history manifest and a meta.json recording the UUID change — the signal for client reprovisioning, since existing working copies do not survive a generation change by design.
# Evaluate triggers only (size after pack, or age of r1's svn:date):
filees-rotate -repo /srv/svn/repo -archive /srv/svn/archive -dry-run
# Rotate when a trigger fires; thresholds are configurable and validated:
filees-rotate -repo ... -archive ... -size 25GiB -age-days 365
# Bounded backup dump of the last N revisions before HEAD (never a
# second full history copy; the frozen FSFS archive is canonical):
filees-rotate -repo ... -archive ... -force -dump-depth 50
Safety contract: an flock excludes concurrent runs; repo and archive must share a filesystem (the swap is a rename); commits are blocked by a temporary pre-commit hook for the duration; rotation refuses to run while active locks (live edit passports) exist unless -break-locks is passed explicitly; every failure before the swap restores the repository as found.
obliterate) — see the verdict in concepts/SVN_ROTATOR_CONCEPT_V2.md §7 before scheduling anything.Chapter 5: Security Appendix
This appendix summarises the design invariants that were established or hardened as a result of code reviews S1–S3, the client audit, the GUI audit, and the r61–r84 review. It is written for a security-aware deployer. It does not reproduce raw review findings; it captures what was decided and why.
5.1 Core Deployment Invariants
I-1. Private keys never leave their origin machine. The client generates its Ed25519 installation key locally using Go’s crypto/rand. Only the public key and SHA-256 fingerprint cross the wire back to the server worker. The worker records them in the operation bundle. The server never generates, stores, or transmits private keys.
I-2. OTP never appears in arguments, environment, logs, or SVN. The OTP is transmitted through a FIFO (SSH_ASKPASS_REQUIRE=force) that is created in a private directory (0700 in \$XDG_RUNTIME_DIR), opened with O_RDONLY|O_NOFOLLOW, and zeroed after use. The OTP is stored only in the operation bundle’s pending outbox entry until the mail is accepted, at which point both the delivery address and the OTP are atomically redacted. BSD Auth receives the OTP through file descriptor 3, not through the environment.
I-3. The bootstrap Ed25519 key is a public capability, not a secret. It is compiled into the client binary and its corresponding authorized-key entry on the server can only invoke the enumeration-free filees-bootstrap-entry forced command. That entry performs one atomic onboarding take followed by at most one outbox submission. The account has DisableForwarding yes. A copy of this key does not grant any privilege beyond triggering the onboarding protocol, which still requires a valid administrator ticket.
I-4. The reverse tunnel is strictly limited. Remote TCP forwarding is allowed only to 127.0.0.1:42000. All other forwarding types (local, dynamic, stream-local, X11, agent) are explicitly prohibited. The port is statically configured to serialise infrequent deploys rather than requiring a custom listener or dynamic per-operation allocation.
I-5. The client-side helper accepts exactly one command name. The loopback SSH helper rejects global requests, non-session channels, PTY, shell, subsystem, environment, and any exec other than filees-deploy/v1. The request decoder rejects unknown fields and trailing JSON. Response size is limited to 64 KiB by io.LimitReader before decoding — not as a post-hoc check — preventing OOM from a misbehaving server worker (S3 hardening, WORKER-001).
I-6. Pledge/unveil profiles are closed in code, not configuration. No server tool accepts pledge promises or unveil paths as configuration values. Profiles are hardcoded per verb in internal/servertool/ and applied through the native sandbox wrapper. The activation worker and client entry retain only the fixed proc exec composition required for configured SVN executables; those executables must be regular and not group/other-writable. Profiles have been exercised natively on OpenBSD 7.9.
I-7. The filesystem is the transaction log. No hidden database, Unix socket, or background daemon manages state. The claim file pattern (rename ticket → .claim → bundle → committed) with fsync after each step makes crash state fully observable and recoverable with standard filesystem tools. An administrator can determine the exact recovery action by inspecting the file system.
I-8. Secret files are validated before use. readPrivateSecret rejects symlinks (Lstat + !info.Mode().IsRegular()), world- or group-writable files, empty files, files larger than 4 KiB, and files containing NUL bytes. Configuration files must be mode 0600 or 0644 (no group/other write). Paths must be absolute.
I-9. SMTP TLS errors are classified before acting on them. A transient network error during TLS handshake (TCP RST, timeout, EOF) is classified as temporary and preserves the OTP and delivery address for retry. Only certificate validation failures (unknown authority, hostname mismatch, invalid record header) are classified as permanent and trigger OTP redaction. This prevents an attacker-controlled network failure from permanently discarding the OTP (S1 hardening, SMTP-002).
5.2 Transport Hardening
T-1. Host key pinning is enforced on every client-originated SSH connection. Bootstrap and OTP tunnel connections verify the configured Ed25519 server host key before transmitting protocol data. The later possession-proof connection uses the generated installation key and the same pinned service host policy. Host-key algorithms are restricted to Ed25519.
T-2. The worker pinning is mutual and sequential. filees-worker connects to the loopback helper using the worker’s Ed25519 private key, which the helper expects. The helper’s ephemeral host key is carried in the filees.tunnel-session/v1 frame over the OTP-authenticated outer SSH session and then pinned by the worker for the reverse-forward connection. The helper binds its first valid request to one operation and client ID; a later rebind is rejected.
T-3. Client-controlled dynamic data does not shape executable command strings. The tunnel remote command is the fixed literal filees tunnel-v1, and the possession command is exactly svnserve -t. filees-entry and filees-client-entry validate SSH_ORIGINAL_COMMAND exactly. The tunnel-session frame is passed through stdin. The operation and client UUID arguments in the per-key forced command are generated server-side, validated as UUIDs and never interpreted by a shell.
T-4. BSD Auth handoff is filesystem-based, not environment-based. OpenBSD sshd does not propagate setenv values returned by a BSD Auth style to the forced command. Rather than relying on this undocumented property, FileES uses an atomic operation bundle state transition: a successful OTP verification advances the bundle from awaiting_tunnel to tunnel_authorized, which filees-entry then claims atomically as it transitions to tunnel_started. This pattern was discovered during native OpenBSD testing and results in a more auditable and robust handoff than environment passing would provide.
T-5. The worker deadline covers the entire helper session. After the TCP connection to the helper is established, the context deadline is propagated to the net.Conn using connection.SetDeadline(deadline). A context.AfterFunc callback closes the connection on cancellation even before the deadline. This prevents a misbehaving helper from keeping the worker alive indefinitely (S3 hardening, WORKER-002).
5.3 Client-Side Invariants
C-1. Staging survives process death. The commit staging map is written atomically to cache.json after every received event, including during offline backoff. The daemon never loses a staged change to SIGKILL or OOM kill; recovery is idempotent.
C-2. No duplicate commits after partial success. After a crash, the restart queries SVN status for each cached entry. Entries whose paths are already in normal state (accepted by the server) are silently dropped. This was verified with fault injection at revision r91–r92 on a real SVN repository.
C-3. Local deletes are not reconstructed by update. Before each svn update (startup or poll), the daemon runs a recursive SVN status. Any missing entry defers the update to prevent SVN from reconstructing a file whose local deletion is staged but not yet committed. This defect was found during chaos testing and is now guarded by a regression test.
C-4. Conflict copies are fsynced before resolve. saveConflictCopy calls Sync() before Close(). If the fsync fails, the copy is deleted (os.Remove(dst)) and the error is propagated; svn resolve is never called with a missing or incomplete backup. This was corrected after CR-01 (high severity) in the r61–r84 review.
C-5. Passport state is always saved before early return. When Release processes multiple paths and one unlock fails, the in-memory passport map may already differ from the on-disk passports.json. A changed flag ensures that saveLocked() is called before every error return from Release. This was corrected after CR-02 (high severity).
C-6. Runtime lock recovery is PID-reuse-safe on POSIX. The lock owner file is held open with an exclusive flock. Another process trying to acquire the lock tests it with LOCK_EX|LOCK_NB: success means the original owner is dead and the OS released the flock (since flock is not inherited across exec, and the OS releases it on process death regardless of PID recycling). This replaces the earlier timestamp-based mechanism that parsed but never used the timestamp field (CR-03). On Windows the old PID-only mechanism remains; PID reuse is a known residual risk on that platform.
C-7. The GUI enforces strict import boundaries. No GUI package imports sync-engine packages (watcher, commit, client, ipcserver, errmap). The boundary is enforced by an architectural test that runs as part of go test. The daemon does not import GUI or display libraries. The sole communication channel between GUI and daemon is filees.contract/v1 over the Unix socket.
C-8. Mutation actions are gated on fresh capabilities and connection state. Lock and unlock are offered only when the daemon reports them in the capabilities list and the GUI connection state is Connected (not Stale). The controller re-checks capability and connection state after the file picker closes, before sending the IPC command. The daemon independently validates all paths and operation parameters; the GUI is not the security boundary.
5.4 GUI Boundary Invariants
G-1. Structured error information is preserved across the IPC boundary. ipcclient.ResponseError retains the full ErrorBody (code, severity, hint, message_key, details). GUI code uses errors.As to extract it and maps hint to a safe, localised user message without parsing raw text (AUD-01 correction).
G-2. A per-user lock prevents multiple GUI instances. Before initialising the tray, the composition root acquires a per-user lock (POSIX flock on Linux; named session mutex on Windows). A second instance exits before any visible effect (AUD-02 correction).
G-3. Events are cache-invalidation signals, not the sole state source. A received event triggers a repo.status fetch. The GUI never constructs its authoritative state purely by applying events. After reconnect, a sequence gap, or an unknown event, a full resync is performed.
G-4. Autostart status distinguishes current from stale. --autostart status compares the stored command with the expected command for the current executable and arguments. It returns enabled-stale if they differ, prompting the user to re-run --autostart enable from the installed path (AUD-09 correction).
5.5 Identity and Grant Model
FileES separates three identity tiers, and access control binds to exactly one of them:
| Tier | Lifetime | Role |
|---|---|---|
| Realm | durable | The subject: a person or organisational unit. The only thing grants, ownership and cross-realm shares ever bind to. |
| Client (installation) | until revoked | One machine. Transport identity: svnserve runs as --tunnel-user <client_id> forced by the server-side key entry. Per-machine audit. |
| Installation key | until rotated | Ed25519 material authenticating one machine's SSH sessions. Never appears in any ACL. |
The binding is created exactly once, at onboarding: the admin issues the ticket with --realm-id, so every activation record ties client_id ↔ realm_id ↔ installation key, canonically published under admin/realms/ and admin/clients/ in the service repository. A user with four machines has four client IDs and four key pairs under one realm — and the question "which keys belong to this user" always has a deterministic answer.
I-10. Grants bind to realms, never to keys or client IDs. The canonical record is (realm → repository, permissions). The svnserve authz file is a generated projection of grants onto the realm's currently active installations — reproducible at any time, never a source of truth.
I-11. Installation lifecycle never touches grants. A new installation inherits its realm's grants automatically at activation (authz regeneration); revoking one machine removes one key without affecting the realm's rights; revoking a realm removes everything in one step; key rotation is a redeploy and leaves ACLs untouched.
I-12. Administrative actions take a realm as their subject. No administrative operation accepts a public key, fingerprint or client ID as an authorisation argument (the sole exception is per-machine revocation, which targets a machine, not the subject's rights). Moving an installation between realms is not an edit — it is revoke plus fresh onboarding.
Access is granted per repository only (rw or r; a per-folder model is deliberately out of scope — every user root is its own repository, so top-level "folders" are already repositories). Repository creation is a separate server capability, can_create_repositories; rw to a shared repository never implies permission to create another repository. Ownership is derived only from owner_realm_id and alone permits further sharing. An additional global read-only client role forces r everywhere and forces repository creation off. The client learns these fields plus attachment_policy=optional|required from its server-generated view.json projection and surfaces them through IPC and the GUI. GUI values are presentation hints, the daemon independently gates local requests, and the authenticated server worker remains the sole authorisation authority. For an r repository the daemon runs an update-only pipeline: nothing is staged, passports and commits are disabled, and server-side authz remains the enforcement.
I-13. Grant authority is ownership-bound and never delegable. A client with rw may share repositories of its own realm to another realm (r or rw); access received from a foreign realm carries no right to share it onward, in any mode. Sharing requests are authorised by the server worker against the realm of the authenticated installation, never against payload claims, and the resulting grant regenerates authz and the recipients' projections without administrator involvement.