Introduction

What is Ferro?

Ferro is a high-performance, self-hosted file storage platform built entirely in Rust. It provides WebDAV-compatible file access, content-addressable storage with deduplication, OIDC authentication, full-text search, WASM-based file processing, and WOPI protocol support for online document editing.

Ferro is a storage orchestrator -- it sits between your files and the storage backends, providing a unified API for accessing, searching, sharing, and processing files regardless of where they are stored.

Key Features

  • WebDAV Server -- Full Class 1/2/3 compliance (PROPFIND, MKCOL, PUT, GET, DELETE, COPY, MOVE, LOCK, UNLOCK, PROPPATCH)
  • Multiple Storage Backends -- In-memory, local filesystem, S3, GCS, Azure Blob Storage
  • Content-Addressable Storage -- SHA-256 deduplication, saves space automatically
  • OIDC Authentication -- PKCE login flow with Keycloak, Auth0, Google, etc.
  • Cedar Authorization -- Fine-grained policy-based access control
  • Full-Text Search -- Tantivy-powered search with auto-indexing
  • WASM Workers -- Run custom file processing pipelines (resize, convert, transform)
  • WOPI Protocol -- Online document editing via Collabora/OnlyOffice
  • CalDAV / CardDAV -- Calendar and address book access (RFC 5545, RFC 6350)
  • ActivityPub Federation -- Share files across Ferro instances using the fediverse
  • Share Links -- Public file sharing with optional passwords and expiration
  • Audit Logging -- Track all file operations
  • Metadata Snapshots -- Point-in-time recovery for ransomware protection
  • File Versioning -- Keep multiple versions per file with diff support
  • Rate Limiting -- Per-IP token-bucket rate limiter (10,000 req/min)
  • Web UI -- Modern Leptos-based file browser with drag-and-drop upload
  • Admin CLI -- Full command-line management tool
  • FUSE Mount -- Access remote files as a local directory on Linux
  • Desktop App -- Tauri desktop application for file browsing
  • E2E Encryption -- age-based file encryption (X25519, ChaCha20-Poly1305)

Comparison with Nextcloud / OCIS

FeatureFerroNextcloudOCIS
LanguageRust (100%)PHP / GoGo
WebDAVClass 1/2/3Class 1/2/3Class 1/2/3
CalDAV/CardDAVYesYesYes
FederationActivityPubNextcloud FederationNo
Storage BackendsMemory, FS, S3, GCS, AzureFS, S3, SMBFS, S3, Azure
CAS DeduplicationSHA-256NoNo
WASM WorkersYesNoNo
OIDC AuthYesYesYes
Cedar AuthZYesRBACRBAC
FUSE MountNativeExternalNo
Desktop AppTauriGTK/QtNo
Binary Size~15 MB~200+ MB~80 MB

Architecture Overview

                         +-------------------+
                         |     Web UI        |
                         |   (Leptos WASM)   |
                         +--------+----------+
                                  |
                         +--------+----------+
                         |   Reverse Proxy   |
                         |     (Caddy)       |
                         +--------+----------+
                                  |
                    +-------------+-------------+
                    |          Axum Server       |
                    |   +------+-------+------+  |
                    |   | Auth | Rate  | CORS |  |
                    |   | Layer| Limit |Layer |  |
                    |   +------+-------+------+  |
                    |   |     Security Headers    |
                    |   +------------------------+|
                    |   |      Middleware Stack    |
                    |   +------------------------+|
                    |          |                 |
                    |  +-------+-------+         |
                    |  | WebDAV  | REST | GraphQL |
                    |  | Handler | API  | Handler |
                    |  +---------+------+---------+
                    |  | CalDAV  |CardDAV| WOPI  |
                    |  | Handler |Handler|Handler |
                    |  +---------+------+---------+
                    |  |  WebSocket  | Federation  |
                    |  |  Handler    | Handler     |
                    |  +-------------+-------------+
                    |          |                  |
                    |  +-------+------------------+
                    |  |    AppState             |
                    |  | - StorageEngine         |
                    |  | - MetadataStore         |
                    |  | - SearchEngine          |
                    |  | - LockManager           |
                    |  | - WsManager             |
                    |  +-------------------------+
                    +-------------+-------------+
                                  |
               +------------------+------------------+
               |                  |                  |
        +------+------+   +------+------+   +------+------+
        | In-Memory   |   | Local FS    |   | Object Store|
        | Storage     |   | Storage     |   | (S3/GCS/Az) |
        +-------------+   +-------------+   +-------------+

License

Ferro is licensed under AGPL-3.0-or-later.

Quick Start

Get Ferro running in under 30 seconds with Docker.

Docker Quickstart

docker compose up -d

The server is available at http://localhost:8080. The Docker image includes the bundled Leptos web UI and a Caddy reverse proxy with automatic HTTPS.

Basic Operations

Upload a file

curl -X PUT http://localhost:8080/hello.txt \
  -H "Content-Type: text/plain" \
  -d "Hello, Ferro!"

Download a file

curl http://localhost:8080/hello.txt

List files

curl -X PROPFIND http://localhost:8080/ \
  -H "Depth: 1" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:propfind xmlns:D="DAV:">
         <D:prop>
           <D:resourcetype/>
           <D:getcontentlength/>
           <D:getlastmodified/>
         </D:prop>
       </D:propfind>'

Create a folder

curl -X MKCOL http://localhost:8080/documents/

Delete a file

curl -X DELETE http://localhost:8080/hello.txt

Move a file

curl -X MOVE http://localhost:8080/hello.txt \
  -H "Destination: /documents/hello.txt"

Copy a file

curl -X COPY http://localhost:8080/hello.txt \
  -H "Destination: /documents/hello-backup.txt"

Connecting with a WebDAV Client

rclone

rclone config
# Choose: WebDAV, vendor: Other, URL: http://localhost:8080

rclone ls ferro:
rclone copy local-file.txt ferro:documents/

macOS Finder

  1. Open Finder
  2. Go > Connect to Server (Cmd+K)
  3. Enter http://localhost:8080/
  4. Click Connect

Windows Explorer

  1. Open File Explorer
  2. Right-click "This PC" > Map network drive
  3. Enter http://localhost:8080/

Linux (GNOME)

  1. Open Files (Nautilus)
  2. Other Locations > Connect to Server
  3. Enter dav://localhost:8080/

Health Check

Verify the server is running:

curl http://localhost:8080/.well-known/ferro

Next Steps

Installation

Binary Download

Download pre-built binaries from GitHub Releases:

curl -sL https://github.com/WyattAu/ferro/releases/latest/download/ferro-server-linux -o ferro-server
chmod +x ferro-server
./ferro-server --port 8080

Docker

cd deploy
docker compose up -d

The Docker image is published at ghcr.io/wyattau/ferro:latest.

Docker with PostgreSQL

POSTGRES_PASSWORD=your-password docker compose -f docker-compose.yml -f docker-compose.pg.yml up -d

Docker with PostgreSQL and Redis

POSTGRES_PASSWORD=your-password docker compose -f docker-compose.yml -f docker-compose.pg.yml -f docker-compose.redis.yml up -d

Cargo Build from Source

Prerequisites

  • Rust 1.92+ (edition 2024)
  • OpenSSL (for PostgreSQL support)

Build

git clone https://github.com/WyattAu/ferro.git
cd ferro
cargo build --release --bin ferro-server
./target/release/ferro-server

Build with all storage backends

cargo build --release --features s3,gcs,azure --bin ferro-server

Run tests

cargo test --all

Nix

nix develop           # Full dev environment
nix develop .#web     # WASM build environment
nix develop .#desktop # Tauri desktop environment

System Requirements

Minimum

ResourceRequirement
CPU1 core
RAM128 MB
Disk50 MB (binary)
OSLinux, macOS, Windows
ResourceRequirement
CPU2+ cores
RAM512 MB
DiskDepends on storage backend
OSLinux (kernel 5.4+)

Runtime Dependencies

DependencyRequiredPurpose
OpenSSLIf using PostgreSQLTLS for database connections
FUSE kernel moduleFor ferro-fuseFilesystem mount support
tuntap kernel moduleFor FirecrackerMicroVM networking

Installing Additional Components

FUSE mount client

cargo install ferro-fuse

Admin CLI

cargo install ferro-cli

Desktop app

See Desktop App Guide for Tauri installation instructions.

Configuration

Ferro supports three layers of configuration, applied in order of priority:

  1. CLI flags (highest priority)
  2. Environment variables
  3. TOML config file (lowest priority)

CLI Flags

FlagEnv VarDefaultDescription
--host--0.0.0.0Bind address
-p, --port--8080Server port
--log-level--infoLog level (trace, debug, info, warn, error)
--log-formatFERRO_LOG_FORMATtextLog format: text or json
--storage--memoryStorage backend (memory, local:/path, s3://bucket, gs://bucket, az://container)
--data-dirFERRO_DATA_DIR(none)Persistent data directory (enables SQLite metadata, CAS, snapshots, audit)
--static-dirFERRO_STATIC_DIR(none)Web UI static files directory
--max-body-sizeFERRO_MAX_BODY_SIZE1073741824Max request body size in bytes (1 GB)
--wasm-enabledFERRO_WASM_ENABLEDfalseEnable WASM worker runtime
--cas-enabled--falseEnable in-memory content-addressable deduplication
--search-index-path--(auto)Search index directory
--metadata-dbFERRO_METADATA_DB(none)PostgreSQL metadata database URL
--oidc-issuerFERRO_OIDC_ISSUER(none)OIDC issuer URL (enables authentication)
--oidc-client-idFERRO_OIDC_CLIENT_ID(none)OIDC client ID
--oidc-audienceFERRO_OIDC_AUDIENCEferroOIDC audience claim
--oidc-jwks-uriFERRO_OIDC_JWKS_URI(none)JWKS URI (overrides auto-discovery)
--cedar-policy-fileFERRO_CEDAR_POLICY_FILE(none)Path to Cedar policy file
--admin-userFERRO_ADMIN_USER(none)Admin username for HTTP Basic Auth
--admin-passwordFERRO_ADMIN_PASSWORD(none)Admin password for HTTP Basic Auth
--configFERRO_CONFIG(none)Path to configuration file (TOML format)
--external-urlFERRO_EXTERNAL_URLhttp://localhost:8080External base URL for OIDC callbacks
--wopi-token-secretFERRO_WOPI_TOKEN_SECRET(default)HMAC secret for WOPI tokens
--wopi-office-urlFERRO_WOPI_OFFICE_URL(none)Collabora/OnlyOffice server URL
--federation-secretFERRO_FEDERATION_SECRET(none)Secret for federation HTTP Signatures
--storage-quotaFERRO_STORAGE_QUOTA(none)Storage quota (e.g., 10GB, 500MB)
--trash-ttlFERRO_TRASH_TTL30dTrash auto-purge TTL (0 to disable)
--graceful-shutdown-timeoutFERRO_GRACEFUL_SHUTDOWN_TIMEOUT30Graceful shutdown timeout in seconds
--cors-allowed-originsFERRO_CORS_ALLOWED_ORIGINS*Comma-separated CORS origins
--max-file-versionsFERRO_MAX_FILE_VERSIONS10Max file versions to retain (0 = disabled)
--thumbnail-sizeFERRO_THUMBNAIL_SIZE256Max thumbnail dimension in pixels (64-1024)
--multi-userFERRO_MULTI_USERfalseEnable multi-user mode with per-user home directories

TOML Config File

Ferro can load settings from a ferro.toml file:

ferro-server --config /path/to/ferro.toml

Auto-discovery searches the current directory, then /etc/ferro/ferro.toml.

Example ferro.toml

host = "0.0.0.0"
port = 8080
storage = "local:/data/files"
data_dir = "/var/lib/ferro"
admin_user = "admin"
admin_password = "changeme"
log_level = "info"
log_format = "json"
external_url = "https://ferro.example.com"
federation_secret = "a-random-secret-string"
cas_enabled = true
wasm_enabled = true
storage_quota = "100GB"
trash_ttl = "30d"
cors_allowed_origins = "https://ferro.example.com"

Size Format

Fields that accept byte sizes support human-readable formats:

max_body_size = "2GB"
storage_quota = "500MB"

Supported suffixes: B, KB, MB, GB.

Environment Variables

All CLI flags have corresponding environment variables (see table above). Example:

FERRO_ADMIN_USER=admin FERRO_ADMIN_PASSWORD=secret FERRO_DATA_DIR=/var/lib/ferro ferro-server

Config Layering (Include)

Configuration files support include directives for modular configuration:

# base.toml
host = "0.0.0.0"
port = 8080
log_level = "info"
include = ["production.toml"]
# production.toml
data_dir = "/var/lib/ferro"
log_format = "json"
external_url = "https://ferro.example.com"

Included files are merged with later files overriding earlier ones. Include paths are resolved relative to the including file. Circular includes are detected and rejected.

Config Precedence

When the same setting is defined in multiple sources, the order of precedence is:

  1. CLI flags always win
  2. Environment variables override file values
  3. TOML config file provides defaults

CLI flags set on the command line will never be overridden by config file values, even if the config file is loaded explicitly.

Storage Backend Configuration

In-Memory (default)

ferro-server

All data is lost on restart.

Local Filesystem

ferro-server --storage local:/path/to/files

Amazon S3

cargo build --release --features s3 --bin ferro-server
./target/release/ferro-server --storage s3://my-bucket
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=...

Google Cloud Storage

cargo build --release --features gcs --bin ferro-server
./target/release/ferro-server --storage gs://my-bucket
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json

Azure Blob Storage

cargo build --release --features azure --bin ferro-server
./target/release/ferro-server --storage az://my-container
export AZURE_STORAGE_ACCOUNT_NAME=...
export AZURE_STORAGE_ACCOUNT_KEY=...

Persistent Data

Use --data-dir to enable SQLite-backed persistence for metadata, CAS deduplication, snapshots, and audit logs in a single database:

ferro-server --data-dir /var/lib/ferro

This creates /var/lib/ferro/ferro.db with WAL mode for concurrent access. CAS deduplication is automatically enabled when --data-dir is set.

Warning: Without --data-dir, all data will be lost on restart.

Architecture

Crate Structure

Ferro is built as a Rust workspace with 20 crates:

ferro/
+-- crates/
|   +-- common/       # Shared types, StorageEngine trait, error handling
|   +-- core/         # Storage backends, search engine, WASM runtime
|   +-- server/       # Axum web server, WebDAV, REST API, all HTTP handlers
|   +-- dav/          # CalDAV/CardDAV parsers, store traits, Axum handlers
|   +-- crypto/       # Cryptographic primitives (hashing, HMAC, bcrypt)
|   +-- client/       # Async WebDAV client SDK with optional C-FFI
|   +-- fuse/         # FUSE3 filesystem mount (Linux only)
|   +-- web/          # Leptos WASM web frontend
|   +-- cli/          # Admin CLI tool
|   +-- desktop/      # Tauri desktop application
|   +-- admin/        # Leptos WASM admin dashboard
|   +-- auth/         # Authentication (simple, OIDC), authorization (Cedar)
|   +-- webdav-handler/ # WebDAV XML request/response builders
|   +-- server-activitypub/ # ActivityPub federation
|   +-- server-webrtc/     # WebRTC signaling
|   +-- server-wopi/       # WOPI protocol for office documents
|   +-- server-versioning/ # File versioning and diff
|   +-- graphql/       # GraphQL API (async-graphql)
|   +-- observability/ # Metrics, logging, health checks
|   +-- benchmarks/    # Criterion benchmark suite
+-- deploy/           # Deployment configurations (Docker, K8s, Terraform, etc.)
CrateDescription
ferro-commonFoundation types: StorageEngine trait, FileMetadata, FerroError, path utilities, WebDAV types
ferro-coreProduction storage backends (SQLite, PostgreSQL, S3, GCS, Azure), Tantivy search, Wasmtime WASM runtime
ferro-serverAxum web server with all HTTP handlers: WebDAV, REST, GraphQL, WebSocket, CalDAV, CardDAV, WOPI, Federation
ferro-daviCalendar (RFC 5545) and vCard (RFC 6350) parsers, CalDAV/CardDAV store traits and handlers
ferro-cryptoCryptoProvider trait with Ring-based implementation: SHA-256/512, HMAC, bcrypt, secure random
ferro-clientAsync WebDAV client with optional C-FFI bindings for mobile platforms (Swift/Kotlin)
ferro-fuseFUSE3 filesystem mount translating POSIX operations to WebDAV HTTP requests
ferro-webLeptos WASM web frontend for file browsing and upload
ferro-cliAdmin CLI tool for server management
ferro-desktopTauri desktop application with file browser and FUSE integration

Request Flow

HTTP Request
    |
    v
+------------------+
| Compression Layer|  (gzip, brotli)
+--------+---------+
         |
+--------+---------+
| Security Headers |  (HSTS, CSP, X-Content-Type-Options, X-Frame-Options)
+--------+---------+
         |
+--------+---------+
| Request Logging  |  (X-Request-ID, request counter)
+--------+---------+
         |
+--------+---------+
| Request ID       |  (assigns unique X-Request-ID header)
+--------+---------+
         |
+--------+---------+
| CORS Layer       |  (configurable origins, preflight handling)
+--------+---------+
         |
+--------+---------+
| Simple Auth      |  (HTTP Basic Auth if configured)
+--------+---------+
         |
+--------+---------+
| OIDC Auth        |  (PKCE flow if configured)
+--------+---------+
         |
+--------+---------+
| Cedar AuthZ      |  (policy-based authorization if configured)
+--------+---------+
         |
+--------+---------+
| Rate Limiter     |  (10,000 req/min per IP)
+--------+---------+
         |
+--------+---------+
|   Router         |  (Axum)
|  +------------+  |
|  | Handler(s) |  |  (WebDAV, REST, GraphQL, CalDAV, etc.)
|  +------------+  |
|  |  AppState   |  |
|  +------------+  |
+------------------+
         |
+--------+---------+
|  Storage Engine  |  (In-Memory, Local FS, S3, GCS, Azure)
+------------------+

The middleware stack processes requests in order. Each layer can short-circuit the request (e.g., rate limiter returns 429, auth returns 401).

Storage Abstraction

All storage operations go through the StorageEngine trait defined in ferro-common:

#![allow(unused)]
fn main() {
pub trait StorageEngine: Send + Sync {
    async fn head(&self, path: &str) -> Result<FileMetadata>;
    async fn get(&self, path: &str) -> Result<Bytes>;
    async fn get_stream(&self, path: &str) -> Result<StorageReader>;
    async fn put(&self, path: &str, content: Bytes, owner: &str) -> Result<FileMetadata>;
    async fn delete(&self, path: &str) -> Result<()>;
    async fn list(&self, path: &str) -> Result<Vec<FileMetadata>>;
    async fn copy(&self, from: &str, to: &str) -> Result<()>;
    async fn move_path(&self, from: &str, to: &str) -> Result<()>;
    async fn exists(&self, path: &str) -> Result<bool>;
    async fn create_collection(&self, path: &str, owner: &str) -> Result<FileMetadata>;
    async fn list_all(&self, path: &str, max_depth: u32) -> Result<Vec<FileMetadata>>;
    async fn put_multipart(&self, path: &str, parts: Vec<Bytes>, owner: &str) -> Result<FileMetadata>;
}
}

This allows swapping backends without changing any server code. The ObjectStoreStorageEngine in ferro-core wraps the object_store crate to support S3, GCS, and Azure via a single implementation.

Feature Flags

FlagCrateDescription
s3server, coreAmazon S3 storage backend
gcsserver, coreGoogle Cloud Storage backend
azureserver, coreAzure Blob Storage backend
sqlitecoreSQLite metadata store (default)
searchcoreTantivy full-text search (default)
wasmcoreWasmtime WASM worker runtime
object_storecoreobject_store backend (default)
pgserverPostgreSQL metadata and state (maps to ferro-core/postgres)
redisserverRedis distributed locking and rate limiting
ldapserverLDAP authentication
handlersdavAxum handlers for CalDAV/CardDAV (default)
persistencedavSQLite persistence for calendar/address book stores
fficlientC-compatible FFI bindings for mobile
ringcryptoRing-based CryptoProvider (default)
fipscryptoFIPS-approved mode (implies ring)
# Build with all storage backends
cargo build --features s3,gcs,azure

# Build with PostgreSQL and Redis
cargo build --features pg,redis  # Note: server feature is "pg", core feature is "postgres"

AppState

The central state object shared across all handlers:

FieldTypeDescription
storageArc<dyn StorageEngine>Storage backend
metadata_storeOption<Arc<SqliteMetadataStore>>Persistent metadata
searchOption<Arc<SearchEngine>>Full-text search engine
wasm_runtimeOption<Arc<WasmWorkerRuntime>>WASM worker runtime
cas_storeOption<Arc<CasStore>>Content-addressable store
lock_managerArc<dyn LockManagerTrait>WebDAV lock manager
share_storeArc<ShareStore>Share link store
audit_logArc<AuditLog>Audit log
snapshot_storeArc<SnapshotStore>Metadata snapshots
ws_managerArc<WsManager>WebSocket manager
activity_storeArc<ActivityStore>Federation activity store
cedarOption<Arc<CedarAuthorizer>>Cedar policy engine
oidcOption<Arc<OidcValidator>>OIDC validator
external_urlStringServer's external URL
federation_secretStringFederation HMAC secret

API Reference

WebDAV API

Ferro provides a fully compliant WebDAV server supporting Class 1 (PROPFIND), Class 2 (LOCK/UNLOCK), and Class 3 (PROPPATCH) operations.

Base URL

All WebDAV operations use the root path as the base collection:

http://localhost:8080/

Supported Methods

MethodDescriptionRFC
OPTIONSDiscover supported methods and DAV capabilitiesRFC 4918
PROPFINDRetrieve properties for one or more resourcesRFC 4918
GETRetrieve file contentRFC 7231
PUTCreate or update a fileRFC 7231
DELETERemove a resourceRFC 7231
MKCOLCreate a collection (directory)RFC 4918
COPYCopy a resource to a new locationRFC 4918
MOVEMove a resource to a new locationRFC 4918
LOCKLock a resourceRFC 4918
UNLOCKRelease a lockRFC 4918
PROPPATCHModify resource propertiesRFC 4918

Depth Header

The Depth header controls PROPFIND recursion:

ValueBehavior
0Only the requested resource (no children)
1The resource and its immediate children
infinityThe resource and all descendants (not fully supported)

Sync-Token Support

Ferro supports WebDAV sync-collection for incremental synchronization. Use the sync endpoint to get changes since a given sync token:

curl -X REPORT http://localhost:8080/ \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:sync-collection xmlns:D="DAV:">
         <D:sync-token>token-value</D:sync-token>
         <D:sync-level>1</D:sync-level>
       </D:sync-collection>'

Examples with curl

Create a directory

curl -X MKCOL http://localhost:8080/documents/

Upload a file

curl -X PUT http://localhost:8080/documents/report.pdf \
  -H "Content-Type: application/pdf" \
  --data-binary @report.pdf

List directory contents (PROPFIND Depth: 1)

curl -X PROPFIND http://localhost:8080/documents/ \
  -H "Depth: 1" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:propfind xmlns:D="DAV:">
         <D:prop>
           <D:resourcetype/>
           <D:getcontentlength/>
           <D:getlastmodified/>
           <D:getetag/>
         </D:prop>
       </D:propfind>'

Response (207 Multi-Status):

<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
  <D:response>
    <D:href>/documents/</D:href>
    <D:propstat>
      <D:prop>
        <D:resourcetype><D:collection/></D:resourcetype>
        <D:getcontentlength>0</D:getcontentlength>
        <D:getlastmodified>Mon, 01 Jan 2024 00:00:00 GMT</D:getlastmodified>
      </D:prop>
      <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
  </D:response>
  <D:response>
    <D:href>/documents/report.pdf</D:href>
    <D:propstat>
      <D:prop>
        <D:resourcetype/>
        <D:getcontentlength>12345</D:getcontentlength>
        <D:getlastmodified>Mon, 01 Jan 2024 12:00:00 GMT</D:getlastmodified>
        <D:getetag>"abc123"</D:getetag>
      </D:prop>
      <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
  </D:response>
</D:multistatus>

Lock a file

curl -X LOCK http://localhost:8080/documents/report.pdf \
  -H "Content-Type: application/xml" \
  -H "Timeout: Second-3600" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:lockinfo xmlns:D="DAV:">
         <D:locktype><D:write/></D:locktype>
         <D:lockscope><D:exclusive/></D:lockscope>
         <D:owner>
           <D:href>mailto:user@example.com</D:href>
         </D:owner>
       </D:lockinfo>'

Unlock a file

curl -X UNLOCK http://localhost:8080/documents/report.pdf \
  -H "Lock-Token: opaquelocktoken:abc123"

Move a file

curl -X MOVE http://localhost:8080/documents/report.pdf \
  -H "Destination: http://localhost:8080/archive/report.pdf"

Copy a file

curl -X COPY http://localhost:8080/documents/report.pdf \
  -H "Destination: http://localhost:8080/backup/report.pdf"

Delete a file

curl -X DELETE http://localhost:8080/documents/report.pdf

Delete a directory

curl -X DELETE http://localhost:8080/documents/

Locking

Ferro supports WebDAV locking with the following lock types:

PropertyValue
Lock typeswrite
Lock scopesexclusive
Lock timeoutConfigurable (default: 3600 seconds)
Lock tokensOpaque token strings

Locks are tracked in-memory or in the SQLite database (when --data-dir is set). Expired locks are cleaned up periodically.

DAV Header

The DAV response header advertises server capabilities:

DAV: 1, 2, 3

REST API

The REST API provides JSON endpoints for file operations, user management, sharing, and server administration.

All endpoints are prefixed with /api/. Authentication is required when --admin-user or --oidc-issuer is configured.

Health Endpoints

Health check

curl http://localhost:8080/.well-known/ferro
{
  "version": "2.5.1",
  "storage": "ok"
}

Liveness probe

curl http://localhost:8080/healthz

Readiness probe

curl http://localhost:8080/readyz
{
  "status": "ok",
  "subsystems": {
    "storage": "ok",
    "metadata": "persistent"
  }
}

Server Configuration

Get capabilities

curl http://localhost:8080/api/config
{
  "version": "2.5.1",
  "auth_enabled": true,
  "search_enabled": true,
  "wasm_workers_enabled": false,
  "cedar_enabled": false,
  "metadata_persistent": true,
  "cas_enabled": true,
  "storage": "configured",
  "external_url": "http://localhost:8080",
  "wopi_configured": false
}

File Operations

Upload a file (WebDAV PUT)

curl -X PUT http://localhost:8080/documents/hello.txt \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: text/plain" \
  -d "Hello, Ferro!"

Download a file

curl http://localhost:8080/documents/hello.txt \
  -H "Authorization: Bearer TOKEN" -o hello.txt

Move a file

curl -X POST http://localhost:8080/api/files/move \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"from": "/documents/hello.txt", "to": "/archive/hello.txt"}'

Copy a file

curl -X POST http://localhost:8080/api/files/copy \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"from": "/documents/hello.txt", "to": "/backup/hello.txt"}'

Encrypt a file

curl -X POST http://localhost:8080/api/files/encrypt \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/secret.txt", "passphrase": "my-password"}'

Decrypt a file

curl -X POST http://localhost:8080/api/files/decrypt \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/secret.txt", "passphrase": "my-password"}'

User Management

Create a user

curl -X POST http://localhost:8080/api/admin/users \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"username": "newuser", "password": "SecurePass123!", "role": "user"}'

List users

curl http://localhost:8080/api/admin/users \
  -H "Authorization: Bearer TOKEN"

Get current user

curl http://localhost:8080/api/users/me \
  -H "Authorization: Bearer TOKEN"

Reset a user's password

curl -X POST http://localhost:8080/api/admin/users/1/reset-password \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password": "NewPass456!"}'

Sharing

curl -X POST http://localhost:8080/api/shares \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/report.pdf", "password": "secret123", "expires_hours": 48}'

List shares

curl http://localhost:8080/api/shares \
  -H "Authorization: Bearer TOKEN"

Delete a share

curl -X DELETE http://localhost:8080/api/shares/TOKEN \
  -H "Authorization: Bearer TOKEN"

Access a shared file

curl http://localhost:8080/s/TOKEN -o report.pdf

Tags

Add tags to a file

curl -X POST http://localhost:8080/api/tags/documents/report.pdf \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["important", "finance"]}'

Get tags for a file

curl http://localhost:8080/api/tags/documents/report.pdf \
  -H "Authorization: Bearer TOKEN"

Search by tag

curl "http://localhost:8080/api/tags/search?tag=important" \
  -H "Authorization: Bearer TOKEN"

Remove a tag

curl -X DELETE http://localhost:8080/api/tags/documents/report.pdf/important \
  -H "Authorization: Bearer TOKEN"

Batch Operations

Batch copy

curl -X POST http://localhost:8080/api/batch/copy \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"items": [
    {"from": "/a.txt", "to": "/backup/a.txt"},
    {"from": "/b.txt", "to": "/backup/b.txt"}
  ]}'

Batch move

curl -X POST http://localhost:8080/api/batch/move \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"items": [
    {"from": "/a.txt", "to": "/archive/a.txt"},
    {"from": "/b.txt", "to": "/archive/b.txt"}
  ]}'

Bulk delete

curl -X POST http://localhost:8080/api/bulk/delete \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"paths": ["/a.txt", "/b.txt", "/c.txt"]}'
curl "http://localhost:8080/api/search?q=report&limit=10" \
  -H "Authorization: Bearer TOKEN"

Trash

List trashed items

curl http://localhost:8080/api/trash \
  -H "Authorization: Bearer TOKEN"

Restore from trash

curl -X POST http://localhost:8080/api/trash/restore \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/old-file.txt"}'

Empty trash

curl -X DELETE http://localhost:8080/api/trash/empty \
  -H "Authorization: Bearer TOKEN"

Snapshots

Create a snapshot

curl -X POST http://localhost:8080/api/snapshots \
  -H "Authorization: Bearer TOKEN"

List snapshots

curl http://localhost:8080/api/snapshots \
  -H "Authorization: Bearer TOKEN"

Restore a snapshot

curl -X POST http://localhost:8080/api/snapshots/1/restore \
  -H "Authorization: Bearer TOKEN"

Storage Stats

curl http://localhost:8080/api/storage/stats \
  -H "Authorization: Bearer TOKEN"

Audit Log

curl "http://localhost:8080/api/audit?limit=50&offset=0" \
  -H "Authorization: Bearer TOKEN"

CalDAV API

Ferro implements the CalDAV protocol (RFC 4791) for calendar access. Calendar data is stored as iCalendar (RFC 5545) resources.

Base URL

http://localhost:8080/dav/cal/

Calendar Discovery

Discover calendar home

curl -X OPTIONS http://localhost:8080/dav/cal \
  -H "Authorization: Bearer TOKEN"

Response includes DAV: calendar-access header.

List calendars

curl -X GET http://localhost:8080/dav/cal/ \
  -H "Authorization: Bearer TOKEN"

Get calendar properties

curl -X PROPFIND http://localhost:8080/dav/cal/default/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Depth: 0" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
         <D:prop>
           <D:displayname/>
           <C:calendar-color/>
           <C:getctag/>
         </D:prop>
       </D:propfind>'

Calendar Operations

Create a calendar

curl -X PUT http://localhost:8080/dav/cal/personal/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:mkcol xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
         <D:set>
           <D:prop>
             <D:resourcetype>
               <D:collection/>
               <C:calendar/>
             </D:resourcetype>
             <D:displayname>Personal Calendar</D:displayname>
           </D:prop>
         </D:set>
       </D:mkcol>'

Delete a calendar

curl -X DELETE http://localhost:8080/dav/cal/personal/ \
  -H "Authorization: Bearer TOKEN"

Event Operations

Create an event

curl -X PUT http://localhost:8080/dav/cal/default/meeting.ics \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: text/calendar" \
  -d 'BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Ferro//EN
BEGIN:VEVENT
UID:meeting-1
DTSTART:20240101T100000Z
DTEND:20240101T110000Z
SUMMARY:Team Meeting
LOCATION:Conference Room A
END:VEVENT
END:VCALENDAR'

Get an event

curl http://localhost:8080/dav/cal/default/meeting.ics \
  -H "Authorization: Bearer TOKEN"

Delete an event

curl -X DELETE http://localhost:8080/dav/cal/default/meeting.ics \
  -H "Authorization: Bearer TOKEN"

Calendar Query (REPORT)

Query events by time range

curl -X REPORT http://localhost:8080/dav/cal/default/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
         <D:prop xmlns:D="DAV:">
           <D:getetag/>
           <C:calendar-data/>
         </D:prop>
         <C:filter>
           <C:comp-filter name="VCALENDAR">
             <C:comp-filter name="VEVENT">
               <C:time-range start="20240101T000000Z" end="20240131T235959Z"/>
             </C:comp-filter>
           </C:comp-filter>
         </C:filter>
       </C:calendar-query>'

CTag Support

Calendars expose a getctag property that changes whenever any event in the calendar is modified. Use this for efficient synchronization:

curl -X PROPFIND http://localhost:8080/dav/cal/default/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Depth: 0" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
         <D:prop>
           <C:getctag/>
         </D:prop>
       </D:propfind>'

Compatible Clients

ClientPlatformNotes
ThunderbirdCross-platformBuilt-in CalDAV support
macOS CalendarmacOSNative CalDAV
DAVx5AndroidExcellent CalDAV support
EvolutionLinuxGNOME calendar client
khalCLICommand-line calendar

See the CalDAV Clients Guide for setup instructions.

CardDAV API

Ferro implements the CardDAV protocol (RFC 6352) for address book access. Contact data is stored as vCard (RFC 6350) resources.

Base URL

http://localhost:8080/dav/card/

Address Book Discovery

Discover address book home

curl -X OPTIONS http://localhost:8080/dav/card \
  -H "Authorization: Bearer TOKEN"

Response includes DAV: addressbook header.

List address books

curl -X GET http://localhost:8080/dav/card/ \
  -H "Authorization: Bearer TOKEN"

Get address book properties

curl -X PROPFIND http://localhost:8080/dav/card/contacts/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Depth: 0" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:propfind xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
         <D:prop>
           <D:displayname/>
           <CR:addressbook-description/>
           <CR:getctag/>
         </D:prop>
       </D:propfind>'

Address Book Operations

Create an address book

curl -X PUT http://localhost:8080/dav/card/friends/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:mkcol xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
         <D:set>
           <D:prop>
             <D:resourcetype>
               <D:collection/>
               <CR:addressbook/>
             </D:resourcetype>
             <D:displayname>Friends</D:displayname>
           </D:prop>
         </D:set>
       </D:mkcol>'

Delete an address book

curl -X DELETE http://localhost:8080/dav/card/friends/ \
  -H "Authorization: Bearer TOKEN"

Contact Operations

Create a contact

curl -X PUT http://localhost:8080/dav/card/contacts/jane.vcf \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: text/vcard" \
  -d 'BEGIN:VCARD
VERSION:3.0
FN:Jane Doe
N:Doe;Jane;;;
EMAIL:jane@example.com
TEL:+1-555-0100
END:VCARD'

Get a contact

curl http://localhost:8080/dav/card/contacts/jane.vcf \
  -H "Authorization: Bearer TOKEN"

Update a contact

curl -X PUT http://localhost:8080/dav/card/contacts/jane.vcf \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: text/vcard" \
  -d 'BEGIN:VCARD
VERSION:3.0
FN:Jane Smith
N:Smith;Jane;;;
EMAIL:jane.smith@example.com
TEL:+1-555-0200
END:VCARD'

Delete a contact

curl -X DELETE http://localhost:8080/dav/card/contacts/jane.vcf \
  -H "Authorization: Bearer TOKEN"

Address Book Query (REPORT)

Query contacts by filter

curl -X REPORT http://localhost:8080/dav/card/contacts/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <CR:addressbook-query xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:D="DAV:">
         <D:prop>
           <D:getetag/>
           <CR:address-data/>
         </D:prop>
         <CR:filter>
           <CR:prop-filter name="FN">
             <CR:text-match match-type="contains">Jane</CR:text-match>
           </CR:prop-filter>
         </CR:filter>
       </CR:addressbook-query>'

CTag Support

Address books expose a getctag property that changes whenever any contact is modified. Use this for efficient synchronization:

curl -X PROPFIND http://localhost:8080/dav/card/contacts/ \
  -H "Authorization: Bearer TOKEN" \
  -H "Depth: 0" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="utf-8"?>
       <D:propfind xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
         <D:prop>
           <CR:getctag/>
         </D:prop>
       </D:propfind>'

Compatible Clients

ClientPlatformNotes
ThunderbirdCross-platformBuilt-in CardDAV support
macOS ContactsmacOSNative CardDAV
DAVx5AndroidCardDAV + CalDAV
EvolutionLinuxGNOME contacts
khardCLICommand-line address book

See the CalDAV Clients Guide for setup instructions.

GraphQL API

Ferro provides a GraphQL endpoint built with async-graphql. A Playground UI is available in the browser.

Endpoint

POST /api/graphql
GET  /api/graphql   (Playground UI)

Schema Overview

Queries

QueryArgumentsDescription
filespath: String, limit: IntList files at a path (default limit: 100, max: 1000)
filepath: String!Get metadata for a single file
shares--List all share links
me--Get current user info
health--Server health check
audit_loglimit: Int, offset: IntQuery audit log entries

Mutations

MutationArgumentsDescription
create_folderpath: String!Create a directory
delete_filepath: String!Delete a file or directory

Types

FileItem

FieldTypeDescription
pathStringFull path
nameStringFile name
sizeIntSize in bytes
is_collectionBooleanWhether it is a directory
mime_typeStringMIME type
modifiedStringLast modified timestamp
ownerStringOwner username

ShareItem

FieldTypeDescription
tokenStringShare token
pathStringShared file path
expires_atStringExpiration timestamp
password_protectedBooleanHas password protection
max_downloadsIntMaximum download count
download_countIntCurrent download count
created_byStringCreator username

UserItem

FieldTypeDescription
usernameStringUsername
roleStringUser role

HealthItem

FieldTypeDescription
statusStringHealth status
versionStringServer version

AuditItem

FieldTypeDescription
methodStringHTTP method
pathStringRequest path
userStringUser who performed the action
statusIntHTTP status code
timestampStringWhen the action occurred

Examples

List root files

curl -X POST http://localhost:8080/api/graphql \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ files(path: \"/\") { name size is_collection modified } }"}'

Get a specific file

curl -X POST http://localhost:8080/api/graphql \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ file(path: \"/documents/report.pdf\") { path name size mime_type modified } }"}'

Create a folder

curl -X POST http://localhost:8080/api/graphql \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { create_folder(path: \"/new-folder\") { path name } }"}'

Delete a file

curl -X POST http://localhost:8080/api/graphql \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { delete_file(path: \"/old-file.txt\") }"}'

Query audit log

curl -X POST http://localhost:8080/api/graphql \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ audit_log(limit: 10) { method path user status timestamp } }"}'

Check health

curl -X POST http://localhost:8080/api/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ health { status version } }"}'

List shares

curl -X POST http://localhost:8080/api/graphql \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ shares { token path password_protected download_count } }"}'

Playground

Open http://localhost:8080/api/graphql in a browser to access the interactive GraphQL Playground with schema documentation and auto-completion.

WebSocket API

Ferro provides real-time notifications via WebSocket. Clients connect to receive JSON messages for file operations as they happen.

Connection

ws://localhost:8080/api/ws

Connect with a client

wscat -c ws://localhost:8080/api/ws

Or in JavaScript:

const ws = new WebSocket('ws://localhost:8080/api/ws');
ws.onmessage = (event) => console.log(JSON.parse(event.data));

Authentication

WebSocket connections inherit the server's authentication configuration. When simple auth or OIDC is enabled, the WebSocket connection requires authentication through the same mechanism.

Event Types

All events are JSON objects with a type field:

file_created

Emitted when a new file is uploaded or created.

{
  "type": "file_created",
  "path": "/documents/report.pdf",
  "size": 12345,
  "owner": "admin"
}

file_updated

Emitted when a file's content is modified.

{
  "type": "file_updated",
  "path": "/documents/report.pdf",
  "size": 15000,
  "owner": "admin"
}

file_deleted

Emitted when a file or directory is deleted.

{
  "type": "file_deleted",
  "path": "/documents/old-file.txt",
  "owner": "admin"
}

file_moved

Emitted when a file is moved or renamed.

{
  "type": "file_moved",
  "from": "/documents/report.pdf",
  "to": "/archive/report.pdf",
  "owner": "admin"
}

file_shared

Emitted when a share link is created for a file.

{
  "type": "file_shared",
  "path": "/documents/report.pdf",
  "token": "abc123def456",
  "owner": "admin"
}

sync_op

Emitted when a sync operation occurs (for client synchronization).

{
  "type": "sync_op",
  "clock": 42,
  "op_type": "create",
  "path": "/documents/new-file.txt"
}

storage_health

Emitted when the storage backend health status changes.

{
  "type": "storage_health",
  "healthy": true,
  "backend": "local"
}

Connection Limits

ParameterValue
Max connections1,000
Broadcast channel size1,024 messages
ProtocolWebSocket (RFC 6455)

When the maximum connection limit is reached, new connections are rejected.

Message Flow

The WebSocket connection is unidirectional from server to client -- the server pushes events, and clients receive them. The server does not process messages sent by clients (except for close and ping frames).

Reconnection

If the connection is lost, clients should reconnect with exponential backoff. The sync API (/api/sync/events, /api/sync/delta) can be used to catch up on missed events after reconnection.

Federation API

Ferro implements the ActivityPub protocol for cross-server federation. This allows separate Ferro instances to share files and follow each other, similar to how Mastodon servers federate.

Enabling Federation

Set the --federation-secret flag to enable the federation inbox:

ferro-server --federation-secret "your-hmac-secret-here" \
  --external-url "https://ferro.example.com"

Or via environment variable:

FERRO_FEDERATION_SECRET=your-hmac-secret-here ferro-server

When the federation secret is empty, the inbox returns 503 Service Unavailable.

HTTP Signatures

Ferro uses HTTP Signatures (draft-cavage-http-signatures-12) with HMAC-SHA256 for authenticating incoming ActivityPub activities.

Signature Format

keyId="https://example.com/actor/alice#main-key",
algorithm="hs2019",
headers="(request-target)",
signature="base64-encoded-hmac"

Verification

  1. The Signature header is parsed to extract keyId, algorithm, headers, and signature
  2. The signing string is constructed from the (request-target) pseudo-header: (request-target): post /fed/inbox
  3. HMAC-SHA256 is computed over the signing string using the server's federation secret
  4. The actor's identity is extracted from the keyId (everything before #)
  5. The actor identity must match the actor field in the activity JSON

Endpoints

WebFinger

GET /.well-known/webfinger?resource=acct:admin@ferro.example.com

Returns the actor URL for a given account.

Actor

GET /fed/actor/:username

Returns the ActivityPub actor object for a user.

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "id": "https://ferro.example.com/fed/actor/admin",
  "type": "Person",
  "preferredUsername": "admin",
  "inbox": "https://ferro.example.com/fed/inbox",
  "outbox": "https://ferro.example.com/fed/outbox",
  "followers": "https://ferro.example.com/fed/actor/admin/followers",
  "following": "https://ferro.example.com/fed/actor/admin/following"
}

Followers

GET /fed/actor/:username/followers

Returns an OrderedCollection of followers.

Following

GET /fed/actor/:username/following

Returns an OrderedCollection of accounts this server follows.

Inbox

POST /fed/inbox
GET  /fed/inbox?offset=0&limit=20

Receives ActivityPub activities (POST) and lists received activities (GET).

Outbox

GET /fed/outbox?offset=0&limit=20

Lists activities this server has published.

NodeInfo

GET /fed/nodeinfo

Returns server metadata including supported protocols and usage statistics.

Federated Share

POST /api/fed/share

Share a file with followers via an Announce activity.

curl -X POST http://localhost:8080/api/fed/share \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/report.pdf"}'

Supported Activities

Activity TypeDescription
FollowA remote actor wants to follow this server. Automatically accepted, adds follower.
AcceptSent in response to Follow requests.
UndoRemoves a follower (Undo Follow).
CreateA new resource was created on a remote server.
UpdateA resource was updated on a remote server.
DeleteA resource was deleted on a remote server.
AnnounceRe-sharing of a resource (used for federated shares).
LikeA resource was liked.

Follow/Undo Flow

Remote server follows this server

  1. Remote server sends Follow activity to this server's inbox
  2. This server verifies the HTTP Signature
  3. This server adds the remote actor to its followers list
  4. This server sends Accept activity to the remote actor's inbox

Remote server unfollows

  1. Remote server sends Undo activity to this server's inbox
  2. This server removes the remote actor from its followers list

Delivery

When this server creates an Announce activity (via federated share), it attempts delivery to all followers' inboxes. Delivery happens asynchronously and errors are logged but do not fail the request.

Security Considerations

  • The federation secret must be shared between federating servers
  • All incoming activities must have a valid HTTP Signature
  • The keyId actor must match the activity actor field (prevents spoofing)
  • Empty federation secret = federation disabled

Chunked Upload API

Ferro supports resumable chunked uploads for large files. Files are split into chunks, uploaded individually, and then reassembled on the server.

Flow Overview

1. Init   -> POST /api/upload/init       -> returns upload_id, chunk_size
2. Chunk  -> PUT  /api/upload/:id/:index  -> upload each chunk (0, 1, 2, ...)
3. Done   -> POST /api/upload/:id/complete -> reassemble and store the file

Init

Start a new chunked upload session:

curl -X POST http://localhost:8080/api/upload/init \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "path": "/videos/large-file.mp4",
    "total_size": 157286400,
    "chunk_size": 5242880
  }'

Response:

{
  "upload_id": "ul_a1b2c3d4e5f6",
  "chunk_size": 5242880
}

Request Fields

FieldTypeRequiredDefaultDescription
pathstringYes--Target file path
total_sizeintegerNo--Total file size in bytes (enables validation)
chunk_sizeintegerNo5242880 (5 MB)Chunk size in bytes

Upload Chunks

Upload each chunk by its zero-based index:

# Chunk 0 (bytes 0 - 5242879)
dd if=large-file.mp4 bs=1M count=5 | \
  curl -X PUT http://localhost:8080/api/upload/ul_a1b2c3d4e5f6/0 \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @-

# Chunk 1 (bytes 5242880 - 10485759)
dd if=large-file.mp4 bs=1M skip=5 count=5 | \
  curl -X PUT http://localhost:8080/api/upload/ul_a1b2c3d4e5f6/1 \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @-

# Chunk 2 (remaining bytes)
dd if=large-file.mp4 bs=1M skip=10 | \
  curl -X PUT http://localhost:8080/api/upload/ul_a1b2c3d4e5f6/2 \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @-

Responses:

StatusMeaning
200 OKChunk accepted
404 Not FoundInvalid upload ID
413 Payload Too LargeChunk exceeds configured chunk size

Complete Upload

After all chunks are uploaded, finalize the upload:

curl -X POST http://localhost:8080/api/upload/ul_a1b2c3d4e5f6/complete \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/videos/large-file.mp4"}'

Responses:

StatusMeaning
201 CreatedFile assembled and stored
400 Bad RequestMissing chunks (gap in sequence)
404 Not FoundInvalid upload ID
500 Internal Server ErrorStorage backend error

The path in the complete request is optional -- if omitted, the path from the init request is used.

Cancel Upload

Abort an upload session and free its memory:

curl -X DELETE http://localhost:8080/api/upload/ul_a1b2c3d4e5f6 \
  -H "Authorization: Bearer TOKEN"

List Active Uploads

View all in-progress uploads:

curl http://localhost:8080/api/uploads \
  -H "Authorization: Bearer TOKEN"
[
  {
    "upload_id": "ul_a1b2c3d4e5f6",
    "path": "/videos/large-file.mp4",
    "chunk_size": 5242880,
    "received": 2,
    "total_chunks": 30,
    "elapsed_secs": 15
  }
]

Chunk Size

The default chunk size is 5 MB (5,242,880 bytes). You can customize it in the init request.

When total_size is provided, the server calculates the expected number of chunks:

total_chunks = ceil(total_size / chunk_size)
Total SizeChunk SizeChunks
1 KB5 MB1
10 MB5 MB2
15 MB5 MB3
1 GB5 MB205

Notes

  • Upload state is held in memory. Server restarts will lose in-progress uploads.
  • Chunks can be uploaded in any order (out-of-order).
  • There is no timeout for in-progress uploads.
  • The chunk data is held in memory until the upload is completed or cancelled.

Library Crates

ferro-common

Foundation types and traits shared across the Ferro ecosystem. Defines the core StorageEngine trait, file metadata types, error handling, WebDAV protocol types, authentication primitives, and path utilities.

Key Types

TypeDescription
StorageEngineAsync trait for storage backend interface
FileMetadataMetadata for files and collections
ContentHashSHA-256 content hash with ETag parsing
StorageReaderAsync reader wrapper for streaming
FerroErrorUnified error type with HTTP status mapping
ClaimsAuthentication claims
AuthDecisionAuthorization decision
LockToken / LockInfoWebDAV locking types
MultiStatusResponseWebDAV multistatus response

StorageEngine Trait

The central abstraction -- all storage backends implement this trait:

#![allow(unused)]
fn main() {
pub trait StorageEngine: Send + Sync {
    async fn head(&self, path: &str) -> Result<FileMetadata>;
    async fn get(&self, path: &str) -> Result<Bytes>;
    async fn get_stream(&self, path: &str) -> Result<StorageReader>;
    async fn put(&self, path: &str, content: Bytes, owner: &str) -> Result<FileMetadata>;
    async fn delete(&self, path: &str) -> Result<()>;
    async fn list(&self, path: &str) -> Result<Vec<FileMetadata>>;
    async fn copy(&self, from: &str, to: &str) -> Result<()>;
    async fn move_path(&self, from: &str, to: &str) -> Result<()>;
    async fn exists(&self, path: &str) -> Result<bool>;
    async fn create_collection(&self, path: &str, owner: &str) -> Result<FileMetadata>;
    async fn list_all(&self, path: &str, max_depth: u32) -> Result<Vec<FileMetadata>>;
    async fn put_multipart(&self, path: &str, parts: Vec<Bytes>, owner: &str) -> Result<FileMetadata>;
}
}

Path Utilities

#![allow(unused)]
fn main() {
use ferro_common::path::{normalize_path, join_path, base_name};

assert_eq!(normalize_path("/foo/../bar"), "/bar");
assert_eq!(join_path("/docs", "file.txt"), "/docs/file.txt");
assert_eq!(base_name("/docs/file.txt"), "file.txt");
}

Feature Flags

This crate has no feature flags -- it is always included as a dependency.

Minimal Usage

Implement a custom storage backend:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use bytes::Bytes;
use ferro_common::storage::StorageEngine;
use ferro_common::metadata::FileMetadata;
use ferro_common::error::Result;

struct MyBackend;

#[async_trait]
impl StorageEngine for MyBackend {
    async fn head(&self, path: &str) -> Result<FileMetadata> { todo!() }
    async fn get(&self, path: &str) -> Result<Bytes> { todo!() }
    async fn put(&self, path: &str, content: Bytes, owner: &str) -> Result<FileMetadata> { todo!() }
    async fn delete(&self, path: &str) -> Result<()> { todo!() }
    async fn list(&self, path: &str) -> Result<Vec<FileMetadata>> { todo!() }
    async fn copy(&self, from: &str, to: &str) -> Result<()> { todo!() }
    async fn move_path(&self, from: &str, to: &str) -> Result<()> { todo!() }
    async fn exists(&self, path: &str) -> Result<bool> { todo!() }
    async fn create_collection(&self, path: &str, owner: &str) -> Result<FileMetadata> { todo!() }
    async fn list_all(&self, path: &str, max_depth: u32) -> Result<Vec<FileMetadata>> { todo!() }
}
}

ferro-core

Core storage, metadata, search, and runtime abstractions for the Ferro platform. Provides production-ready storage backends (SQLite, PostgreSQL, S3, GCS, Azure Blob), a Tantivy-based full-text search engine, and a WASM worker runtime powered by Wasmtime.

Key Types

TypeDescription
InMemoryStorageEngineIn-memory StorageEngine for testing and development
ObjectStoreStorageEngineWraps any object_store implementation (S3, GCS, Azure)
SqliteMetadataStoreSQLite-backed metadata store
PgMetadataStorePostgreSQL-backed metadata store
InMemoryMetadataStoreIn-memory metadata store for testing
MetadataStoreTrait for pluggable metadata backends
SearchEngineTantivy full-text search with path, name, content, owner fields
WasmWorkerRuntimeSandboxed WASM execution with fuel limits and WASI support
SqlitePersistenceSnapshot and audit log persistence

Feature Flags

FeatureDefaultDescription
sqliteyesSQLite metadata store via sqlx
searchyesTantivy full-text search engine
wasmyesWasmtime WASM worker runtime
object_storeyesobject_store backend (local, S3, GCS, Azure)
s3noAWS S3 object store backend
gcsnoGoogle Cloud Storage backend
azurenoAzure Blob Storage backend
postgresnoPostgreSQL metadata store via sqlx

Minimal Usage

In-memory storage

#![allow(unused)]
fn main() {
use ferro_core::storage::InMemoryStorageEngine;
use ferro_common::storage::StorageEngine;
use bytes::Bytes;

let engine = InMemoryStorageEngine::new();
let meta = engine.put("/hello.txt", Bytes::from("world"), "user1").await?;
}

SQLite metadata store

#![allow(unused)]
fn main() {
use ferro_core::sqlx_metadata::SqliteMetadataStore;

let store = SqliteMetadataStore::new("sqlite:metadata.db").await?;
}

Full-text search

#![allow(unused)]
fn main() {
use ferro_core::search::SearchEngine;
use std::path::Path;

let engine = SearchEngine::new(Path::new("/tmp/ferro-index"))?;
engine.index("/docs/readme.txt", "Introduction to Ferro", &meta).await?;
let results = engine.search("introduction", 10).await?;
}

WASM worker

#![allow(unused)]
fn main() {
use ferro_core::wasm::{WasmWorkerRuntime, WorkerConfig};

let runtime = WasmWorkerRuntime::new(WorkerConfig::default())?;
runtime.register("*.md", &wasm_bytes, "process")?;
let result = runtime.execute("/docs/readme.md", &content).await?;
}

Object store with S3

# Cargo.toml
ferro-core = { version = "0.1", features = ["s3"] }
#![allow(unused)]
fn main() {
use ferro_core::ObjectStoreStorageEngine;
use object_store::aws::AmazonS3Builder;
use std::sync::Arc;

let s3 = AmazonS3Builder::new()
    .with_bucket_name("my-bucket")
    .build()?;
let engine = ObjectStoreStorageEngine::with_prefix(Arc::new(s3), "ferro");
}

ferro-dav

CalDAV and CardDAV protocol implementations for the Ferro platform. Provides iCalendar (RFC 5545) and vCard (RFC 6350) parsers, store traits for calendar and address book data, and ready-to-use Axum handlers.

Key Types

Parsers

TypeDescription
parse_ical / serialize_icalRFC 5545 iCalendar parser and serializer
parse_vcard / serialize_vcardRFC 6350 vCard parser and serializer
IcalComponent / IcalPropertyStructured iCalendar representation
Vcard / VcardValue / VcardAddressStructured vCard representation

Store Traits

TypeDescription
CalendarStoreTrait for calendar CRUD and time-range event queries
AddressBookStoreTrait for address book CRUD and contact management
InMemoryCalendarStoreThread-safe in-memory calendar store
InMemoryAddressBookStoreThread-safe in-memory address book store
DynCalendarStore / DynAddressBookStoreType-erased Arc<dyn ...> aliases

Handlers (requires handlers feature)

TypeDescription
CalDavState / caldav::*CalDAV Axum handlers (OPTIONS, PROPFIND, REPORT, MKCALENDAR, GET, PUT, DELETE)
CardDavState / carddav::*CardDAV Axum handlers (OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE)

Feature Flags

FeatureDefaultDescription
handlersyesAxum handler modules for CalDAV and CardDAV HTTP endpoints
persistencenoSQLite persistence for in-memory stores

Minimal Usage

Parse iCalendar data

#![allow(unused)]
fn main() {
use ferro_dav::ical::{parse_ical, get_first_prop};

let ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:mtg-1\r\n\
SUMMARY:Meeting\r\nDTSTART:20240101T100000Z\r\n\
DTEND:20240101T110000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";

let components = parse_ical(ical)?;
let vevent = &components[0].children[0];
let summary = get_first_prop(vevent, "SUMMARY").unwrap();
println!("Event: {}", summary.value);
}

Parse vCard data

#![allow(unused)]
fn main() {
use ferro_dav::vcard::{parse_vcard, serialize_vcard};

let vcard = parse_vcard(
    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nUID:1\r\nEND:VCARD\r\n"
)?;
println!("Name: {}", vcard.fn_name);
let roundtrip = serialize_vcard(&vcard);
}

Build a CalDAV server

#![allow(unused)]
fn main() {
use ferro_dav::store::{InMemoryCalendarStore, DynCalendarStore};
use ferro_dav::caldav::{CalDavState, self};
use std::sync::Arc;

let store: DynCalendarStore = Arc::new(InMemoryCalendarStore::new());
let state = CalDavState {
    store,
    principal: "user1".into(),
};

let app = axum::Router::new()
    .route("/.well-known/caldav", axum::routing::get(caldav::options_handler))
    .route("/dav/calendars", axum::routing::get(caldav::propfind_calendars))
    .with_state(state);
}

ferro-crypto

Standalone cryptographic primitives for the Ferro platform. Provides a CryptoProvider trait with a Ring-based implementation for hashing, HMAC, password hashing, secure random generation, and constant-time comparisons.

Key Types

TypeDescription
CryptoProviderAsync trait abstracting cryptographic operations
RingProviderProduction implementation backed by the Ring library
CryptoErrorError type for cryptographic operations

CryptoProvider Trait Methods

MethodDescription
sha256(data)SHA-256 hash
sha512(data)SHA-512 hash
hmac_sha256(key, data)HMAC-SHA256 message authentication code
random_bytes(len)Cryptographically secure random bytes
hash_password(password)Bcrypt password hash
verify_password(password, hash)Bcrypt password verification
generate_token(len)URL-safe, no-pad base64 token
constant_time_eq(a, b)Constant-time byte comparison
provider_name()Provider identifier string
is_fips_approved()Whether FIPS mode is active

Feature Flags

FeatureDefaultDescription
ringyesRing-based CryptoProvider implementation with bcrypt and base64
fipsnoEnables FIPS-approved mode (implies ring)

Minimal Usage

Hash and verify a password

#![allow(unused)]
fn main() {
use ferro_crypto::{CryptoProvider, ring_provider::RingProvider};

let provider = RingProvider::new();

let hash = provider.hash_password("s3cret").await?;
assert!(provider.verify_password("s3cret", &hash).await?);
assert!(!provider.verify_password("wrong", &hash).await?);
}

Compute HMAC-SHA256

#![allow(unused)]
fn main() {
use ferro_crypto::{CryptoProvider, ring_provider::RingProvider};

let provider = RingProvider::new();
let mac = provider.hmac_sha256(b"secret-key", b"message data").await?;
assert_eq!(mac.len(), 32);
}

Generate a secure token

#![allow(unused)]
fn main() {
use ferro_crypto::{CryptoProvider, ring_provider::RingProvider};

let provider = RingProvider::new();
let token = provider.generate_token(32).await?;
}

Constant-time comparison

#![allow(unused)]
fn main() {
use ferro_crypto::ring_provider::RingProvider;

assert!(RingProvider::constant_time_eq(b"same", b"same"));
assert!(!RingProvider::constant_time_eq(b"a", b"b"));
}

ferro-client

Async WebDAV client SDK for Ferro servers, with optional C-FFI bindings for mobile platforms (Swift/Kotlin).

Key Types

TypeDescription
FerroClientAsync WebDAV client
FerroFileEntryFile metadata returned by list operations

Feature Flags

FeatureDefaultDescription
ffinoC-compatible FFI bindings for mobile (Swift/Kotlin)

Minimal Usage

use ferro_client::FerroClient;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = FerroClient::new("https://ferro.example.com", "my-token");

    let info = client.test_connection().await?;
    println!("Server has {} root entries", info.root_entries);

    let files = client.list("/").await?;
    for file in &files {
        println!("{} ({} bytes)", file.name, file.size);
    }

    client.put_text("/hello.txt", "Hello, Ferro!").await?;
    let content = client.get_text("/hello.txt").await?;
    println!("{}", content);

    client.create_directory("/documents").await?;
    client.move_item("/hello.txt", "/documents/hello.txt").await?;
    client.copy("/documents/hello.txt", "/documents/hello-backup.txt").await?;
    client.delete("/documents/hello-backup.txt").await?;

    Ok(())
}

C-FFI (Mobile)

Enable the ffi feature for C-compatible bindings:

[dependencies]
ferro-client = { version = "0.1", features = ["ffi"] }

FFI API

FunctionDescription
ferro_client_new(url, token)Create a client handle
ferro_client_free(handle)Destroy a client handle
ferro_test_connection(handle)Test server connection

Swift Example

let handle = ferro_client_new("https://ferro.example.com", "my-token")
defer { ferro_client_free(handle) }

let result = ferro_test_connection(handle)
if result == .Success {
    print("Connected!")
}

Kotlin Example

val handle = ferro_client_new("https://ferro.example.com", "my-token")
try {
    val result = ferro_test_connection(handle)
    if (result == FerroResult.Success) {
        println("Connected!")
    }
} finally {
    ferro_client_free(handle)
}

Safety

  • All FFI pointer returns must be freed with their corresponding _free function
  • String pointers are null-terminated UTF-8
  • Client handles are not thread-safe; use one per thread or synchronize externally

ferro-fuse

FUSE3 filesystem mount for Ferro. Translates POSIX file operations into WebDAV HTTP requests, letting you access a remote Ferro server as a local directory on Linux.

Key Types

TypeDescription
FUSEMountMain FUSE filesystem implementation

Features

  • Read, write, create, and delete files via WebDAV
  • Directory creation (mkdir), removal (rmdir), and listing (readdir)
  • Rename and copy operations
  • In-memory file cache (10 MB, 10,000 entries) for read performance
  • Token-based authentication via FERRO_TOKEN environment variable
  • allow-root mount option for privileged access
  • Automatic directory creation for mount point

Installation

cargo install ferro-fuse

CLI Options

OptionEnvDefaultDescription
--server-urlFERRO_URLhttp://localhost:8080Ferro server URL
--mountFERRO_MOUNT(required)Local mount point path
--tokenFERRO_TOKEN(none)Bearer token for authentication
--allow-root--falseAllow root user to access the mount
--no-foreground--true (foreground)Run in the background

Minimal Usage

Mount a server

ferro-fuse --server-url https://ferro.example.com --mount /mnt/ferro --token YOUR_TOKEN

With environment variables

export FERRO_URL=https://ferro.example.com
export FERRO_MOUNT=/mnt/ferro
export FERRO_TOKEN=YOUR_TOKEN
ferro-fuse

Unmount

fusermount -u /mnt/ferro

Or press Ctrl+C when running in the foreground.

Supported Operations

OperationDescription
readRead file contents
writeCreate or overwrite files
mkdirCreate directories
rmdirRemove empty directories
unlinkDelete files
renameMove or rename files and directories
readdirList directory contents
getattr / statRetrieve file metadata
open / releaseOpen and close file handles

Platform Support

This crate targets Linux only via the fuse3 crate. Building on non-Linux platforms compiles but the binary exits with an error at startup.

See Also

Deployment

Docker Deployment

Quick Start

The fastest way to run Ferro:

docker compose up -d

This starts Ferro with in-memory storage on port 8080.

Base Configuration

The Docker Compose configs use a layered overlay pattern. Start with the base and add overlays as needed:

FileAdds
docker-compose.ymlFerro only
docker-compose.pg.ymlPostgreSQL
docker-compose.redis.ymlRedis

Single Node (Minimal)

cd deploy
docker compose up -d

docker-compose.yml

services:
  ferro:
    image: ghcr.io/wyattau/ferro:latest
    container_name: ferro
    restart: unless-stopped
    ports:
      - "${FERRO_PORT:-8080}:8080"
    volumes:
      - ferro-data:/data
    environment:
      FERRO_DATA_DIR: /data
      FERRO_HOST: 0.0.0.0
      FERRO_PORT: 8080
      FERRO_LOG_FORMAT: json
      FERRO_LOG_LEVEL: info
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 512M

volumes:
  ferro-data:
    driver: local

With PostgreSQL

POSTGRES_PASSWORD=your-password \
  docker compose -f docker-compose.yml -f docker-compose.pg.yml up -d

With PostgreSQL and Redis

POSTGRES_PASSWORD=your-password \
  docker compose -f docker-compose.yml -f docker-compose.pg.yml -f docker-compose.redis.yml up -d

Environment Variables

VariableDefaultDescription
FERRO_PORT8080Host port mapping
POSTGRES_PASSWORDferroPostgreSQL password
FERRO_DATA_DIR/dataPersistent data directory in container
FERRO_LOG_FORMATjsonLog format
FERRO_LOG_LEVELinfoLog level

Health Check

The container includes a health check that hits /healthz:

docker inspect --format='{{.State.Health.Status}}' ferro

With Reverse Proxy (Caddy)

For production, use Caddy for automatic HTTPS:

services:
  ferro:
    build: .
    expose:
      - "8080"
    volumes:
      - ferro-data:/data
    environment:
      - FERRO_DATA_DIR=/data
      - FERRO_STATIC_DIR=/app/ui
      - FERRO_ADMIN_USER=admin
      - FERRO_ADMIN_PASSWORD=changeme
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8080/.well-known/ferro"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    environment:
      - DOMAIN=localhost
    depends_on:
      - ferro
    restart: unless-stopped

volumes:
  ferro-data:
  caddy-data:
  caddy-config:

Tips

  • Use named volumes for persistent data (ferro-data)
  • Set FERRO_LOG_FORMAT=json for structured logging in production
  • Set memory limits via deploy.resources.limits
  • Use docker compose logs -f ferro to follow logs
  • The health check uses /healthz for liveness monitoring

Kubernetes Deployment

Ferro provides two Kubernetes deployment options: a lightweight single-manifest setup via K3s and a full Kustomize-based production deployment.

K3s (Lightweight)

Deploy

kubectl apply -f deploy/k3s/ferro.yaml

With Traefik (K3s default)

K3s ships with Traefik. Add to /etc/hosts:

127.0.0.1 ferro.local

The Ingress is pre-configured for ferro.local.

With PostgreSQL

helm repo add bitnami https://charts.bitnami.com/bitnami
helm install postgres bitnami/postgresql \
  --set auth.postgresPassword=ferro \
  --namespace ferro

Then set FERRO_DATABASE_URL in the Deployment.

Cleanup

kubectl delete -f deploy/k3s/ferro.yaml

Production (Kustomize)

Deploy base

kubectl apply -k deploy/kubernetes/base

What's included

The production deployment includes:

ResourceDescription
NamespaceDedicated ferro namespace
DeploymentFerro pods with resource limits
ServiceClusterIP service
IngressIngress with configurable class
PVCPersistent volume claim for data
SecretAdmin credentials
ConfigMapServer configuration
PDBPod disruption budget
NetworkPolicyNetwork policies (deny, DNS, external, ingress)

Network Policies

The base deployment includes restrictive network policies:

  • networkpolicy-deny.yaml -- Deny all ingress/egress by default
  • networkpolicy-dns.yaml -- Allow DNS egress
  • networkpolicy-external.yaml -- Allow external egress (for S3, OIDC, etc.)
  • networkpolicy-ingress.yaml -- Allow ingress on service ports

Helm

Install

helm install ferro deploy/helm/ferro

Custom values

helm install ferro deploy/helm/ferro \
  --set replicaCount=2 \
  --set persistence.size=10Gi \
  --set auth.adminUser=admin \
  --set auth.adminPassword=changeme \
  --set ingress.enabled=true \
  --set ingress.className=nginx

Helm chart values

ValueDefaultDescription
replicaCount1Number of replicas
persistence.enabledtrueEnable persistent volume
persistence.size5GiVolume size
ingress.enabledfalseEnable Ingress
ingress.className""Ingress class name
networkPolicy.enabledtrueEnable network policies
auth.adminUseradminAdmin username
auth.adminPassword""Admin password
image.repositoryghcr.io/wyattau/ferroContainer image
image.taglatestImage tag

Tips

  • Use PDBs to ensure availability during rolling updates
  • Network policies provide defense-in-depth
  • Set FERRO_LOG_FORMAT=json for structured logging
  • Use the Helm chart for complex deployments with custom values

Podman Deployment

Run Ferro with rootless containers using Podman and systemd integration.

Quick Start

cd deploy/podman
podman-compose -f podman-compose.yml up -d

Podman Machine (macOS/Windows)

podman machine init
podman machine start
eval $(podman machine env)
podman-compose -f podman-compose.yml up -d

Systemd Integration

Generate and install a systemd user service for auto-start:

podman generate systemd --new --files --name ferro
cp container-ferro.service ~/.config/systemd/user/
systemctl --user enable --now container-ferro.service

This ensures Ferro starts automatically on login and restarts on failure.

SELinux

The Podman configuration includes SELinux label support (:z and :Z volume options) for proper file access on SELinux-enabled systems.

Common Issues

Permission denied on volume

# Use :Z for single-container volumes
podman run -v ./data:/data:Z ghcr.io/wyattau/ferro:latest

Container won't start

# Check logs
podman logs ferro

# Check container status
podman ps -a

Port already in use

# Check what's using port 8080
ss -tlnp | grep 8080

Advantages over Docker

  • Rootless by default (no root daemon)
  • Native systemd integration
  • SELinux support out of the box
  • Compatible with Docker Compose files via podman-compose
  • OCI-compliant containers

Firecracker Deployment

Deploy Ferro inside a Firecracker MicroVM for VM-level isolation with minimal attack surface and ~125ms boot time.

Prerequisites

  • Firecracker v1.6+
  • Root privileges
  • tuntap kernel module

Quick Start

cd deploy/firecracker
chmod +x start-vm.sh
sudo ./start-vm.sh

Ferro will be available at http://<VM-IP>:8080.

Configuration

Environment variables control the MicroVM:

VariableDefaultDescription
FIRECRACKER_KERNEL/opt/ferro/vmlinuxPath to kernel image
FIRECRACKER_ROOTFS/opt/ferro/rootfs.ext4Path to root filesystem
FIRECRACKER_SOCKET/tmp/firecracker.sockAPI socket path
FIRECRACKER_TAPtap0TAP device name
FIRECRACKER_VCPUS2Number of vCPUs
FIRECRACKER_MEM512Memory in MiB
FIRECRACKER_MACAA:FC:00:00:00:01Guest MAC address
FIRECRACKER_ROOTFS_SIZE512Rootfs size in MiB

Resource Requirements

ResourceRequirement
vCPUs2
RAM512 MB
Disk (rootfs)512 MB

Building the Root Filesystem

The root filesystem is built using a Dockerfile:

cd deploy/firecracker/ferro-rootfs
docker build -t ferro-rootfs .

Security Benefits

  • VM-level isolation (separate kernel)
  • Minimal attack surface (only networking and block device)
  • No shared kernel with host
  • Fast boot (~125ms) for quick scaling
  • No persistent state on host beyond rootfs

Tips

  • Use a TAP device for network access from the host
  • The VM has no persistent storage beyond the rootfs -- mount external storage or use S3
  • Configure the firewall to restrict access to the VM IP
  • Monitor VM resource usage via the Firecracker API socket

Terraform Deployment

Use Terraform to deploy Ferro on Kubernetes or K3s as Infrastructure as Code.

Prerequisites

  • Terraform >= 1.0
  • kubectl configured with a valid kubeconfig
  • Helm 3 (installed by Terraform provider)

Quick Start

Deploy to existing Kubernetes cluster

cd deploy/terraform
terraform init
terraform plan -var="admin_password=your-password"
terraform apply -var="admin_password=your-password"

Deploy to K3s (self-managed)

cd deploy/terraform/k3s
terraform init
terraform apply

Variables

VariableDefaultDescription
admin_useradminAdmin username
admin_password(required)Admin password
replica_count1Number of pod replicas
persistence_enabledtrueEnable persistent storage
persistence_size5GiPersistent volume size
image_repositoryghcr.io/wyattau/ferroContainer image repository
image_taglatestContainer image tag
ingress_enabledfalseEnable Ingress resource
ingress_class_name""Ingress class name
network_policy_enabledtrueEnable network policies

What is Deployed

The Terraform configuration creates:

  1. Kubernetes namespace (ferro) with standard labels
  2. Helm release using the Ferro Helm chart with the configured values

The Helm chart (at deploy/helm/ferro) deploys:

  • Deployment with resource limits and health checks
  • Service (ClusterIP)
  • PersistentVolumeClaim
  • Ingress (if enabled)
  • Secret (admin credentials)
  • NetworkPolicy (if enabled)
  • PodDisruptionBudget

Cleanup

terraform destroy -var="admin_password=your-password"

Tips

  • Store the admin_password in Terraform state secrets or a vault
  • Use -var-file to load variables from a file
  • Pin the image_tag to a specific version in production
  • Use Terraform workspaces for multiple environments
  • The K3s module handles cluster provisioning and Ferro deployment together

Guides

Desktop App

The Ferro desktop app is built with Tauri, providing a native file browser for your Ferro server.

Installation

From Source

nix develop .#desktop
cd crates/desktop
cargo tauri build

Nix

nix develop .#desktop

This opens a development shell with Tauri dependencies.

Connecting to a Server

  1. Launch the Ferro desktop app
  2. Enter your server URL (e.g., https://ferro.example.com)
  3. Enter your authentication token
  4. Click "Connect"

The app will display the server's root directory contents.

File Browser Features

  • Directory navigation -- Browse directories with back/forward navigation
  • File operations -- Upload, download, rename, and delete files
  • WebDAV PROPFIND -- Uses PROPFIND with Depth: 1 for directory listing
  • Drag and drop -- Drag files into the app to upload
  • File metadata -- View file size, modification date, ETag

Architecture

The desktop app communicates with the Ferro server over HTTP using WebDAV:

  1. PROPFIND requests list directory contents
  2. PUT requests upload files
  3. GET requests download files
  4. DELETE requests remove files

Keyboard Shortcuts

The Tauri app supports standard keyboard shortcuts:

ShortcutAction
Cmd/Ctrl+NNew window
Cmd/Ctrl+QQuit
BackspaceNavigate up
EnterOpen file/folder

Tray Integration

The app can run in the system tray for background access. Use the tray icon to:

  • Show/hide the main window
  • Quick access to recent files
  • Check connection status

Building for Distribution

cd crates/desktop
cargo tauri build

Output binaries are in target/release/bundle/:

  • .dmg (macOS)
  • .deb / .AppImage (Linux)
  • .msi / .exe (Windows)

FUSE Mount

Mount a remote Ferro server as a local directory on Linux using ferro-fuse.

Installation

cargo install ferro-fuse

Prerequisites

  • Linux kernel with FUSE support (most distributions include this)
  • fuse3 development libraries (for building from source)
  • The fusermount command (included with FUSE)

Mounting a Server

Basic mount

ferro-fuse \
  --server-url https://ferro.example.com \
  --mount /mnt/ferro \
  --token YOUR_TOKEN

Using environment variables

export FERRO_URL=https://ferro.example.com
export FERRO_MOUNT=/mnt/ferro
export FERRO_TOKEN=YOUR_TOKEN
ferro-fuse

Allow root access

ferro-fuse \
  --server-url https://ferro.example.com \
  --mount /mnt/ferro \
  --token YOUR_TOKEN \
  --allow-root

Background mode

ferro-fuse \
  --server-url https://ferro.example.com \
  --mount /mnt/ferro \
  --token YOUR_TOKEN \
  --no-foreground

Authentication

The FUSE mount authenticates using a Bearer token. Set the token via:

  • --token CLI flag
  • FERRO_TOKEN environment variable

When the server uses simple auth (--admin-user / --admin-password), the token is the admin password. When OIDC is configured, use a valid access token.

Offline Cache

The FUSE mount includes an in-memory cache for improved read performance:

ParameterValue
Cache size10 MB
Max entries10,000
Eviction policyLRU

The cache is in-memory only -- it does not persist across mounts.

Unmounting

Foreground (Ctrl+C)

Press Ctrl+C when running in the foreground. The mount point is cleaned up automatically.

fusermount

fusermount -u /mnt/ferro

Force unmount

fusermount -uz /mnt/ferro

Supported Operations

OperationDescription
readRead file contents
writeCreate or overwrite files
mkdirCreate directories
rmdirRemove empty directories
unlinkDelete files
renameMove or rename files and directories
readdirList directory contents
getattr / statRetrieve file metadata
open / releaseOpen and close file handles

Tips

  • Use /etc/fstab for persistent mounts (not recommended for remote FUSE)
  • Set --allow-root if you need root to access the mount
  • The mount translates POSIX operations to WebDAV HTTP requests
  • Large file operations may be slower than native filesystem access due to HTTP overhead
  • Use df -h /mnt/ferro to check mount status

Troubleshooting

"Transport endpoint is not connected"

The FUSE connection was lost. Unmount and remount:

fusermount -uz /mnt/ferro
ferro-fuse --server-url https://ferro.example.com --mount /mnt/ferro --token TOKEN

Permission denied

Ensure your token is valid and the mount point exists:

mkdir -p /mnt/ferro
curl -H "Authorization: Bearer TOKEN" https://ferro.example.com/.well-known/ferro

CalDAV Clients

Connect your calendar and contacts apps to Ferro's CalDAV and CardDAV servers.

Common Settings

For most clients, you need:

SettingValue
Server URLhttp://localhost:8080 (or your domain)
CalDAV path/dav/cal/
CardDAV path/dav/card/
UsernameYour Ferro username
PasswordYour Ferro password

Thunderbird (Cross-platform)

Calendar setup

  1. Open Thunderbird
  2. Click the calendar icon in the toolbar
  3. Right-click in the calendar list > New Calendar
  4. Select "On the Network"
  5. Choose "CalDAV"
  6. Enter the server URL: http://your-server:8080/dav/cal/
  7. Enter your credentials
  8. Click "Find Calendars" to auto-discover
  9. Select the calendar and click "Subscribe"

Contacts setup

  1. Open Thunderbird
  2. Click the address book icon
  3. File > New > Remote Address Book
  4. Select "CardDAV"
  5. Enter the server URL: http://your-server:8080/dav/card/
  6. Enter your credentials
  7. Click "OK"

macOS Calendar

Calendar setup

  1. Open Calendar (or System Settings > Internet Accounts)
  2. Click "Add Account" > "Other" > "CalDAV"
  3. Select "Manual"
  4. Enter:
    • Server: your-server:8080
    • Path: /dav/cal/
    • Username and password
  5. Sign In

Contacts setup

  1. Open Contacts
  2. Contacts > Add Account > Other Contacts Account
  3. Select "CardDAV"
  4. Enter:
    • Server: your-server:8080
    • Path: /dav/card/
    • Username and password
  5. Sign In

DAVx5 (Android)

Setup

  1. Install DAVx5 from F-Droid or Google Play
  2. Open DAVx5
  3. Tap "+" to add an account
  4. Enter:
    • Base URL: http://your-server:8080
    • Username and password
  5. DAVx5 will auto-discover CalDAV and CardDAV services
  6. Select which calendars and address books to sync

Troubleshooting

  • Ensure the server URL is accessible from your device
  • Check that --admin-user and --admin-password are set on the server
  • DAVx5 requires HTTPS in production -- use a reverse proxy with TLS

Evolution (Linux/GNOME)

Calendar setup

  1. Open Evolution
  2. File > New > Calendar
  3. Select "CalDAV"
  4. Enter:
    • URL: http://your-server:8080/dav/cal/
    • Username and password
  5. Click "Retrieve List" to find calendars

Contacts setup

  1. Open Evolution
  2. File > New > Address Book
  3. Select "CardDAV"
  4. Enter:
    • URL: http://your-server:8080/dav/card/
    • Username and password
  5. Click "Retrieve List" to find address books

Troubleshooting

Connection refused

  • Ensure Ferro is running: curl http://localhost:8080/healthz
  • Check the port matches your configuration

Authentication failed

  • Verify --admin-user and --admin-password are set
  • Try the credentials with curl: curl -u admin:password http://localhost:8080/api/config

Sync not working

  • Check the CalDAV/CardDAV paths are correct
  • Use curl -X OPTIONS http://localhost:8080/dav/cal to verify CalDAV is available
  • Check server logs for errors

HTTPS required

Many clients require HTTPS for CalDAV/CardDAV. Use a reverse proxy (Caddy, Nginx) with TLS certificates:

# Caddy (automatic HTTPS)
caddy reverse-proxy --from ferro.example.com --to localhost:8080

Encryption

Ferro supports end-to-end file encryption using the age format. Files are encrypted with X25519 (key exchange) and ChaCha20-Poly1305 (symmetric encryption), then stored in ASCII-armored format.

E2E Encryption Overview

Original file -> age encrypt (passphrase) -> ASCII armored .age file -> stored on server
Stored .age file -> age decrypt (passphrase) -> original file -> returned to user
  • Encryption is performed on the server using the age crate
  • Passphrase-based encryption uses scrypt key derivation
  • Encrypted files are identified by the -----BEGIN AGE ENCRYPTED FILE----- header
  • The server does not store your passphrase

Encrypting a File

Via REST API

curl -X POST http://localhost:8080/api/files/encrypt \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/secret.txt", "passphrase": "my-secure-password"}'

Response:

{
  "path": "/documents/secret.txt",
  "size": 543,
  "encrypted": true
}

The original file content is replaced with the age-encrypted version in-place.

What happens

  1. The server reads the file at the given path
  2. Encrypts the content using age with the provided passphrase
  3. Stores the encrypted content back to the same path
  4. The encrypted content starts with -----BEGIN AGE ENCRYPTED FILE-----

Decrypting a File

Via REST API

curl -X POST http://localhost:8080/api/files/decrypt \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/secret.txt", "passphrase": "my-secure-password"}'

Response:

{
  "path": "/documents/secret.txt",
  "size": 42,
  "encrypted": false
}

What happens

  1. The server reads the file at the given path
  2. Checks if the content is age-encrypted (starts with the age header)
  3. Decrypts the content using age with the provided passphrase
  4. Stores the decrypted content back to the same path

Wrong passphrase

If the passphrase is incorrect, the API returns a 400 error:

{
  "error": "DECRYPT_FAILED",
  "message": "Decryption failed: wrong passphrase? ..."
}

Key Management

Ferro uses passphrase-based encryption via the age format. There is no server-side key management -- the passphrase is the only secret required to decrypt files.

Best practices

  • Use strong, unique passphrases (20+ characters)
  • Do not store passphrases in configuration files
  • Consider using a password manager
  • Different passphrases for different sensitivity levels
  • Test decryption after encryption to verify the passphrase

Security properties

PropertyValue
Key exchangeX25519
Symmetric encryptionChaCha20-Poly1305
Key derivationscrypt (passphrase-based)
Header formatASCII-armored (-----BEGIN AGE ENCRYPTED FILE-----)

Using the age CLI Directly

You can also encrypt files before uploading using the age CLI tool:

# Encrypt locally
age -p -o secret.txt.age secret.txt

# Upload encrypted file
curl -X PUT http://localhost:8080/documents/secret.txt.age \
  -H "Authorization: Bearer TOKEN" \
  --data-binary @secret.txt.age

# Download and decrypt
curl http://localhost:8080/documents/secret.txt.age \
  -H "Authorization: Bearer TOKEN" -o secret.txt.age
age -d -o secret.txt secret.txt.age

This approach keeps the passphrase entirely on your machine.

See Also

Federation Setup

Ferro supports ActivityPub federation, allowing separate Ferro instances to follow each other and share files across the network.

Prerequisites

  • Two or more Ferro instances with public URLs
  • A shared federation secret (or separate secrets for each server pair)
  • HTTPS on both servers (required for HTTP Signatures)

Setting Up Federation

Step 1: Configure the first server

ferro-server \
  --external-url "https://ferro-a.example.com" \
  --federation-secret "shared-secret-value" \
  --data-dir /var/lib/ferro \
  --admin-user admin \
  --admin-password secure-password

Step 2: Configure the second server

ferro-server \
  --external-url "https://ferro-b.example.com" \
  --federation-secret "shared-secret-value" \
  --data-dir /var/lib/ferro \
  --admin-user admin \
  --admin-password secure-password

Both servers must use the same federation secret for HMAC-SHA256 signature verification to succeed.

Step 3: Verify federation is enabled

curl https://ferro-a.example.com/fed/nodeinfo
{
  "version": "2.1",
  "software": {
    "name": "ferro",
    "version": "2.5.1"
  },
  "protocols": ["activitypub", "webfinger", "dav"],
  "services": {
    "inbound": ["activitypub", "webdav", "caldav", "carddav"],
    "outbound": ["activitypub"]
  }
}

Following Other Servers

Send a Follow activity

To follow another Ferro server, send a Follow activity to its inbox:

# Construct the signing string
METHOD="POST"
PATH="/fed/inbox"
KEY_ID="https://ferro-a.example.com/fed/actor/admin#main-key"

# Create HMAC-SHA256 signature
SIGNING_STRING="(request-target): post /fed/inbox"
SIGNATURE=$(echo -n "$SIGNING_STRING" | openssl dgst -sha256 -hmac "shared-secret-value" -binary | base64)

# Send Follow activity
curl -X POST https://ferro-b.example.com/fed/inbox \
  -H "Content-Type: application/json" \
  -H "Signature: keyId=\"${KEY_ID}\",algorithm=\"hs2019\",headers=\"(request-target)\",signature=\"${SIGNATURE}\"" \
  -d '{
    "@context": "https://www.w3.org/ns/activitystreams",
    "type": "Follow",
    "id": "https://ferro-a.example.com/activities/follow-1",
    "actor": "https://ferro-a.example.com/fed/actor/admin",
    "object": "https://ferro-b.example.com/fed/actor/admin",
    "to": ["https://ferro-b.example.com/fed/actor/admin"]
  }'

The target server will automatically accept the Follow and add the actor to its followers list.

Check followers

curl https://ferro-b.example.com/fed/actor/admin/followers

Federated Sharing

Share a file with all your followers using an Announce activity:

curl -X POST https://ferro-a.example.com/api/fed/share \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path": "/documents/report.pdf"}'

This creates an Announce activity and delivers it to all followers' inboxes.

WebFinger Discovery

Other servers can discover your actor via WebFinger:

curl "https://ferro-a.example.com/.well-known/webfinger?resource=acct:admin@ferro-a.example.com"

Security Considerations

  • Shared secret -- The federation secret is used for HMAC-SHA256 signature verification. Keep it secure and rotate it periodically.
  • HTTPS required -- HTTP Signatures are transmitted over the wire. Use TLS to prevent interception.
  • Actor validation -- Ferro validates that the signature keyId matches the activity actor field to prevent spoofing.
  • Empty secret = disabled -- If --federation-secret is not set, the inbox returns 503 and federation is disabled.
  • Firewall -- Restrict /fed/inbox access if you only want specific servers to federate.

Troubleshooting

Federation returns 503

Set the --federation-secret flag. An empty secret disables federation.

Signature verification failed

Ensure both servers use the same federation secret and the keyId actor matches the activity actor.

Activity not delivered to followers

Check server logs for delivery errors. Delivery happens asynchronously.

Security

Ferro is designed with security as a priority. This page summarizes the security features and policies. For the full security policy, see SECURITY.md in the repository.

Reporting Vulnerabilities

MethodDetails
Emailsecurity@wyatt.au (PGP encrypted)
GitHubSecurity Advisories

Response Timeline

SeverityInitial ResponsePatch Release
Critical (RCE, auth bypass)24 hours72 hours
High (data exposure, privilege escalation)48 hours7 days
Medium (CSRF, XSS, information disclosure)72 hours14 days
Low (best practices, minor issues)1 weekNext release

Security Features

Authentication

MethodDescription
Simple authHTTP Basic Auth with bcrypt-hashed passwords (cost factor 12)
OIDCOpenID Connect with PKCE flow (Keycloak, Auth0, Google, etc.)
LDAPLDAP authentication (behind ldap feature flag)
AuthorizationCedar policy engine for fine-grained access control

Encryption

LayerImplementation
TransportTLS 1.3 (rustls)
File E2Eage (X25519, ChaCha20-Poly1305)
Passwordsbcrypt (cost factor 12)
TokensHMAC-SHA256
ComparisonConstant-time for secrets

Input Validation

  • Path traversal prevention (normalized paths, .. rejection)
  • Content-Type validation on uploads
  • Request body size limits (configurable, default 1 GB)
  • XML entity expansion prevention in WebDAV

Security Headers

HeaderValue
Strict-Transport-Securitymax-age=31536000; includeSubDomains
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Content-Security-PolicyConfigurable
X-Request-IDUnique per request (audit trail)

Federation Security

  • HTTP Signatures (draft-cavage-http-signatures-12)
  • HMAC-SHA256 verification
  • Actor keyId validation (must match activity actor)
  • Empty federation secret = disabled (503)

Rate Limiting

  • Per-IP token-bucket rate limiter
  • Default: 10,000 requests per 60-second window
  • Returns 429 Too Many Requests when exceeded

Deployment Security

  • Non-root containers where supported
  • no-new-privileges security option
  • cap-drop: ALL with minimal capabilities
  • Resource limits on all containers
  • Health checks on all services
  • No secrets in configuration files

Audit Logging

Ferro tracks all file operations in an audit log. Access via:

curl http://localhost:8080/api/audit?limit=50 \
  -H "Authorization: Bearer TOKEN"

Supported Versions

VersionSupported
2.xYes
< 2.0No

Dependency Security

  • Weekly cargo audit (automated in CI)
  • Monthly manual review of new dependencies
  • No dependencies with known critical CVEs
  • Prefer pure-Rust implementations over C bindings

Penetration Testing

Ferro is designed to be pen-testable. See SECURITY.md for the full penetration testing guide including test cases for authentication bypass, path traversal, XML injection, federation spoofing, and more.