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
| Feature | Ferro | Nextcloud | OCIS |
|---|---|---|---|
| Language | Rust (100%) | PHP / Go | Go |
| WebDAV | Class 1/2/3 | Class 1/2/3 | Class 1/2/3 |
| CalDAV/CardDAV | Yes | Yes | Yes |
| Federation | ActivityPub | Nextcloud Federation | No |
| Storage Backends | Memory, FS, S3, GCS, Azure | FS, S3, SMB | FS, S3, Azure |
| CAS Deduplication | SHA-256 | No | No |
| WASM Workers | Yes | No | No |
| OIDC Auth | Yes | Yes | Yes |
| Cedar AuthZ | Yes | RBAC | RBAC |
| FUSE Mount | Native | External | No |
| Desktop App | Tauri | GTK/Qt | No |
| 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
- Open Finder
- Go > Connect to Server (Cmd+K)
- Enter
http://localhost:8080/ - Click Connect
Windows Explorer
- Open File Explorer
- Right-click "This PC" > Map network drive
- Enter
http://localhost:8080/
Linux (GNOME)
- Open Files (Nautilus)
- Other Locations > Connect to Server
- Enter
dav://localhost:8080/
Health Check
Verify the server is running:
curl http://localhost:8080/.well-known/ferro
Next Steps
- Configuration -- Customize your server
- Architecture -- Understand how it works
- Deployment -- Production deployment options
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
Docker Compose (recommended)
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
| Resource | Requirement |
|---|---|
| CPU | 1 core |
| RAM | 128 MB |
| Disk | 50 MB (binary) |
| OS | Linux, macOS, Windows |
Recommended (production)
| Resource | Requirement |
|---|---|
| CPU | 2+ cores |
| RAM | 512 MB |
| Disk | Depends on storage backend |
| OS | Linux (kernel 5.4+) |
Runtime Dependencies
| Dependency | Required | Purpose |
|---|---|---|
| OpenSSL | If using PostgreSQL | TLS for database connections |
| FUSE kernel module | For ferro-fuse | Filesystem mount support |
| tuntap kernel module | For Firecracker | MicroVM 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:
- CLI flags (highest priority)
- Environment variables
- TOML config file (lowest priority)
CLI Flags
| Flag | Env Var | Default | Description |
|---|---|---|---|
--host | -- | 0.0.0.0 | Bind address |
-p, --port | -- | 8080 | Server port |
--log-level | -- | info | Log level (trace, debug, info, warn, error) |
--log-format | FERRO_LOG_FORMAT | text | Log format: text or json |
--storage | -- | memory | Storage backend (memory, local:/path, s3://bucket, gs://bucket, az://container) |
--data-dir | FERRO_DATA_DIR | (none) | Persistent data directory (enables SQLite metadata, CAS, snapshots, audit) |
--static-dir | FERRO_STATIC_DIR | (none) | Web UI static files directory |
--max-body-size | FERRO_MAX_BODY_SIZE | 1073741824 | Max request body size in bytes (1 GB) |
--wasm-enabled | FERRO_WASM_ENABLED | false | Enable WASM worker runtime |
--cas-enabled | -- | false | Enable in-memory content-addressable deduplication |
--search-index-path | -- | (auto) | Search index directory |
--metadata-db | FERRO_METADATA_DB | (none) | PostgreSQL metadata database URL |
--oidc-issuer | FERRO_OIDC_ISSUER | (none) | OIDC issuer URL (enables authentication) |
--oidc-client-id | FERRO_OIDC_CLIENT_ID | (none) | OIDC client ID |
--oidc-audience | FERRO_OIDC_AUDIENCE | ferro | OIDC audience claim |
--oidc-jwks-uri | FERRO_OIDC_JWKS_URI | (none) | JWKS URI (overrides auto-discovery) |
--cedar-policy-file | FERRO_CEDAR_POLICY_FILE | (none) | Path to Cedar policy file |
--admin-user | FERRO_ADMIN_USER | (none) | Admin username for HTTP Basic Auth |
--admin-password | FERRO_ADMIN_PASSWORD | (none) | Admin password for HTTP Basic Auth |
--config | FERRO_CONFIG | (none) | Path to configuration file (TOML format) |
--external-url | FERRO_EXTERNAL_URL | http://localhost:8080 | External base URL for OIDC callbacks |
--wopi-token-secret | FERRO_WOPI_TOKEN_SECRET | (default) | HMAC secret for WOPI tokens |
--wopi-office-url | FERRO_WOPI_OFFICE_URL | (none) | Collabora/OnlyOffice server URL |
--federation-secret | FERRO_FEDERATION_SECRET | (none) | Secret for federation HTTP Signatures |
--storage-quota | FERRO_STORAGE_QUOTA | (none) | Storage quota (e.g., 10GB, 500MB) |
--trash-ttl | FERRO_TRASH_TTL | 30d | Trash auto-purge TTL (0 to disable) |
--graceful-shutdown-timeout | FERRO_GRACEFUL_SHUTDOWN_TIMEOUT | 30 | Graceful shutdown timeout in seconds |
--cors-allowed-origins | FERRO_CORS_ALLOWED_ORIGINS | * | Comma-separated CORS origins |
--max-file-versions | FERRO_MAX_FILE_VERSIONS | 10 | Max file versions to retain (0 = disabled) |
--thumbnail-size | FERRO_THUMBNAIL_SIZE | 256 | Max thumbnail dimension in pixels (64-1024) |
--multi-user | FERRO_MULTI_USER | false | Enable 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:
- CLI flags always win
- Environment variables override file values
- 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.)
| Crate | Description |
|---|---|
ferro-common | Foundation types: StorageEngine trait, FileMetadata, FerroError, path utilities, WebDAV types |
ferro-core | Production storage backends (SQLite, PostgreSQL, S3, GCS, Azure), Tantivy search, Wasmtime WASM runtime |
ferro-server | Axum web server with all HTTP handlers: WebDAV, REST, GraphQL, WebSocket, CalDAV, CardDAV, WOPI, Federation |
ferro-dav | iCalendar (RFC 5545) and vCard (RFC 6350) parsers, CalDAV/CardDAV store traits and handlers |
ferro-crypto | CryptoProvider trait with Ring-based implementation: SHA-256/512, HMAC, bcrypt, secure random |
ferro-client | Async WebDAV client with optional C-FFI bindings for mobile platforms (Swift/Kotlin) |
ferro-fuse | FUSE3 filesystem mount translating POSIX operations to WebDAV HTTP requests |
ferro-web | Leptos WASM web frontend for file browsing and upload |
ferro-cli | Admin CLI tool for server management |
ferro-desktop | Tauri 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
| Flag | Crate | Description |
|---|---|---|
s3 | server, core | Amazon S3 storage backend |
gcs | server, core | Google Cloud Storage backend |
azure | server, core | Azure Blob Storage backend |
sqlite | core | SQLite metadata store (default) |
search | core | Tantivy full-text search (default) |
wasm | core | Wasmtime WASM worker runtime |
object_store | core | object_store backend (default) |
pg | server | PostgreSQL metadata and state (maps to ferro-core/postgres) |
redis | server | Redis distributed locking and rate limiting |
ldap | server | LDAP authentication |
handlers | dav | Axum handlers for CalDAV/CardDAV (default) |
persistence | dav | SQLite persistence for calendar/address book stores |
ffi | client | C-compatible FFI bindings for mobile |
ring | crypto | Ring-based CryptoProvider (default) |
fips | crypto | FIPS-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:
| Field | Type | Description |
|---|---|---|
storage | Arc<dyn StorageEngine> | Storage backend |
metadata_store | Option<Arc<SqliteMetadataStore>> | Persistent metadata |
search | Option<Arc<SearchEngine>> | Full-text search engine |
wasm_runtime | Option<Arc<WasmWorkerRuntime>> | WASM worker runtime |
cas_store | Option<Arc<CasStore>> | Content-addressable store |
lock_manager | Arc<dyn LockManagerTrait> | WebDAV lock manager |
share_store | Arc<ShareStore> | Share link store |
audit_log | Arc<AuditLog> | Audit log |
snapshot_store | Arc<SnapshotStore> | Metadata snapshots |
ws_manager | Arc<WsManager> | WebSocket manager |
activity_store | Arc<ActivityStore> | Federation activity store |
cedar | Option<Arc<CedarAuthorizer>> | Cedar policy engine |
oidc | Option<Arc<OidcValidator>> | OIDC validator |
external_url | String | Server's external URL |
federation_secret | String | Federation 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
| Method | Description | RFC |
|---|---|---|
OPTIONS | Discover supported methods and DAV capabilities | RFC 4918 |
PROPFIND | Retrieve properties for one or more resources | RFC 4918 |
GET | Retrieve file content | RFC 7231 |
PUT | Create or update a file | RFC 7231 |
DELETE | Remove a resource | RFC 7231 |
MKCOL | Create a collection (directory) | RFC 4918 |
COPY | Copy a resource to a new location | RFC 4918 |
MOVE | Move a resource to a new location | RFC 4918 |
LOCK | Lock a resource | RFC 4918 |
UNLOCK | Release a lock | RFC 4918 |
PROPPATCH | Modify resource properties | RFC 4918 |
Depth Header
The Depth header controls PROPFIND recursion:
| Value | Behavior |
|---|---|
0 | Only the requested resource (no children) |
1 | The resource and its immediate children |
infinity | The 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:
| Property | Value |
|---|---|
| Lock types | write |
| Lock scopes | exclusive |
| Lock timeout | Configurable (default: 3600 seconds) |
| Lock tokens | Opaque 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
Create a share link
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"]}'
Search
Full-text search
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
| Client | Platform | Notes |
|---|---|---|
| Thunderbird | Cross-platform | Built-in CalDAV support |
| macOS Calendar | macOS | Native CalDAV |
| DAVx5 | Android | Excellent CalDAV support |
| Evolution | Linux | GNOME calendar client |
| khal | CLI | Command-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
| Client | Platform | Notes |
|---|---|---|
| Thunderbird | Cross-platform | Built-in CardDAV support |
| macOS Contacts | macOS | Native CardDAV |
| DAVx5 | Android | CardDAV + CalDAV |
| Evolution | Linux | GNOME contacts |
| khard | CLI | Command-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
| Query | Arguments | Description |
|---|---|---|
files | path: String, limit: Int | List files at a path (default limit: 100, max: 1000) |
file | path: String! | Get metadata for a single file |
shares | -- | List all share links |
me | -- | Get current user info |
health | -- | Server health check |
audit_log | limit: Int, offset: Int | Query audit log entries |
Mutations
| Mutation | Arguments | Description |
|---|---|---|
create_folder | path: String! | Create a directory |
delete_file | path: String! | Delete a file or directory |
Types
FileItem
| Field | Type | Description |
|---|---|---|
path | String | Full path |
name | String | File name |
size | Int | Size in bytes |
is_collection | Boolean | Whether it is a directory |
mime_type | String | MIME type |
modified | String | Last modified timestamp |
owner | String | Owner username |
ShareItem
| Field | Type | Description |
|---|---|---|
token | String | Share token |
path | String | Shared file path |
expires_at | String | Expiration timestamp |
password_protected | Boolean | Has password protection |
max_downloads | Int | Maximum download count |
download_count | Int | Current download count |
created_by | String | Creator username |
UserItem
| Field | Type | Description |
|---|---|---|
username | String | Username |
role | String | User role |
HealthItem
| Field | Type | Description |
|---|---|---|
status | String | Health status |
version | String | Server version |
AuditItem
| Field | Type | Description |
|---|---|---|
method | String | HTTP method |
path | String | Request path |
user | String | User who performed the action |
status | Int | HTTP status code |
timestamp | String | When 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
| Parameter | Value |
|---|---|
| Max connections | 1,000 |
| Broadcast channel size | 1,024 messages |
| Protocol | WebSocket (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
- The
Signatureheader is parsed to extractkeyId,algorithm,headers, andsignature - The signing string is constructed from the
(request-target)pseudo-header:(request-target): post /fed/inbox - HMAC-SHA256 is computed over the signing string using the server's federation secret
- The actor's identity is extracted from the
keyId(everything before#) - The actor identity must match the
actorfield 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 Type | Description |
|---|---|
Follow | A remote actor wants to follow this server. Automatically accepted, adds follower. |
Accept | Sent in response to Follow requests. |
Undo | Removes a follower (Undo Follow). |
Create | A new resource was created on a remote server. |
Update | A resource was updated on a remote server. |
Delete | A resource was deleted on a remote server. |
Announce | Re-sharing of a resource (used for federated shares). |
Like | A resource was liked. |
Follow/Undo Flow
Remote server follows this server
- Remote server sends
Followactivity to this server's inbox - This server verifies the HTTP Signature
- This server adds the remote actor to its followers list
- This server sends
Acceptactivity to the remote actor's inbox
Remote server unfollows
- Remote server sends
Undoactivity to this server's inbox - 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
keyIdactor must match the activityactorfield (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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | Yes | -- | Target file path |
total_size | integer | No | -- | Total file size in bytes (enables validation) |
chunk_size | integer | No | 5242880 (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:
| Status | Meaning |
|---|---|
200 OK | Chunk accepted |
404 Not Found | Invalid upload ID |
413 Payload Too Large | Chunk 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:
| Status | Meaning |
|---|---|
201 Created | File assembled and stored |
400 Bad Request | Missing chunks (gap in sequence) |
404 Not Found | Invalid upload ID |
500 Internal Server Error | Storage 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 Size | Chunk Size | Chunks |
|---|---|---|
| 1 KB | 5 MB | 1 |
| 10 MB | 5 MB | 2 |
| 15 MB | 5 MB | 3 |
| 1 GB | 5 MB | 205 |
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
| Type | Description |
|---|---|
StorageEngine | Async trait for storage backend interface |
FileMetadata | Metadata for files and collections |
ContentHash | SHA-256 content hash with ETag parsing |
StorageReader | Async reader wrapper for streaming |
FerroError | Unified error type with HTTP status mapping |
Claims | Authentication claims |
AuthDecision | Authorization decision |
LockToken / LockInfo | WebDAV locking types |
MultiStatusResponse | WebDAV 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
| Type | Description |
|---|---|
InMemoryStorageEngine | In-memory StorageEngine for testing and development |
ObjectStoreStorageEngine | Wraps any object_store implementation (S3, GCS, Azure) |
SqliteMetadataStore | SQLite-backed metadata store |
PgMetadataStore | PostgreSQL-backed metadata store |
InMemoryMetadataStore | In-memory metadata store for testing |
MetadataStore | Trait for pluggable metadata backends |
SearchEngine | Tantivy full-text search with path, name, content, owner fields |
WasmWorkerRuntime | Sandboxed WASM execution with fuel limits and WASI support |
SqlitePersistence | Snapshot and audit log persistence |
Feature Flags
| Feature | Default | Description |
|---|---|---|
sqlite | yes | SQLite metadata store via sqlx |
search | yes | Tantivy full-text search engine |
wasm | yes | Wasmtime WASM worker runtime |
object_store | yes | object_store backend (local, S3, GCS, Azure) |
s3 | no | AWS S3 object store backend |
gcs | no | Google Cloud Storage backend |
azure | no | Azure Blob Storage backend |
postgres | no | PostgreSQL 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
| Type | Description |
|---|---|
parse_ical / serialize_ical | RFC 5545 iCalendar parser and serializer |
parse_vcard / serialize_vcard | RFC 6350 vCard parser and serializer |
IcalComponent / IcalProperty | Structured iCalendar representation |
Vcard / VcardValue / VcardAddress | Structured vCard representation |
Store Traits
| Type | Description |
|---|---|
CalendarStore | Trait for calendar CRUD and time-range event queries |
AddressBookStore | Trait for address book CRUD and contact management |
InMemoryCalendarStore | Thread-safe in-memory calendar store |
InMemoryAddressBookStore | Thread-safe in-memory address book store |
DynCalendarStore / DynAddressBookStore | Type-erased Arc<dyn ...> aliases |
Handlers (requires handlers feature)
| Type | Description |
|---|---|
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
| Feature | Default | Description |
|---|---|---|
handlers | yes | Axum handler modules for CalDAV and CardDAV HTTP endpoints |
persistence | no | SQLite 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
| Type | Description |
|---|---|
CryptoProvider | Async trait abstracting cryptographic operations |
RingProvider | Production implementation backed by the Ring library |
CryptoError | Error type for cryptographic operations |
CryptoProvider Trait Methods
| Method | Description |
|---|---|
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
| Feature | Default | Description |
|---|---|---|
ring | yes | Ring-based CryptoProvider implementation with bcrypt and base64 |
fips | no | Enables 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
| Type | Description |
|---|---|
FerroClient | Async WebDAV client |
FerroFileEntry | File metadata returned by list operations |
Feature Flags
| Feature | Default | Description |
|---|---|---|
ffi | no | C-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
| Function | Description |
|---|---|
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
_freefunction - 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
| Type | Description |
|---|---|
FUSEMount | Main 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_TOKENenvironment variable allow-rootmount option for privileged access- Automatic directory creation for mount point
Installation
cargo install ferro-fuse
CLI Options
| Option | Env | Default | Description |
|---|---|---|---|
--server-url | FERRO_URL | http://localhost:8080 | Ferro server URL |
--mount | FERRO_MOUNT | (required) | Local mount point path |
--token | FERRO_TOKEN | (none) | Bearer token for authentication |
--allow-root | -- | false | Allow 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
| Operation | Description |
|---|---|
read | Read file contents |
write | Create or overwrite files |
mkdir | Create directories |
rmdir | Remove empty directories |
unlink | Delete files |
rename | Move or rename files and directories |
readdir | List directory contents |
getattr / stat | Retrieve file metadata |
open / release | Open 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
- FUSE Mount Guide for detailed setup instructions
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:
| File | Adds |
|---|---|
docker-compose.yml | Ferro only |
docker-compose.pg.yml | PostgreSQL |
docker-compose.redis.yml | Redis |
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
| Variable | Default | Description |
|---|---|---|
FERRO_PORT | 8080 | Host port mapping |
POSTGRES_PASSWORD | ferro | PostgreSQL password |
FERRO_DATA_DIR | /data | Persistent data directory in container |
FERRO_LOG_FORMAT | json | Log format |
FERRO_LOG_LEVEL | info | Log 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=jsonfor structured logging in production - Set memory limits via
deploy.resources.limits - Use
docker compose logs -f ferroto follow logs - The health check uses
/healthzfor 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:
| Resource | Description |
|---|---|
Namespace | Dedicated ferro namespace |
Deployment | Ferro pods with resource limits |
Service | ClusterIP service |
Ingress | Ingress with configurable class |
PVC | Persistent volume claim for data |
Secret | Admin credentials |
ConfigMap | Server configuration |
PDB | Pod disruption budget |
NetworkPolicy | Network policies (deny, DNS, external, ingress) |
Network Policies
The base deployment includes restrictive network policies:
networkpolicy-deny.yaml-- Deny all ingress/egress by defaultnetworkpolicy-dns.yaml-- Allow DNS egressnetworkpolicy-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
| Value | Default | Description |
|---|---|---|
replicaCount | 1 | Number of replicas |
persistence.enabled | true | Enable persistent volume |
persistence.size | 5Gi | Volume size |
ingress.enabled | false | Enable Ingress |
ingress.className | "" | Ingress class name |
networkPolicy.enabled | true | Enable network policies |
auth.adminUser | admin | Admin username |
auth.adminPassword | "" | Admin password |
image.repository | ghcr.io/wyattau/ferro | Container image |
image.tag | latest | Image tag |
Tips
- Use PDBs to ensure availability during rolling updates
- Network policies provide defense-in-depth
- Set
FERRO_LOG_FORMAT=jsonfor 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
tuntapkernel 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:
| Variable | Default | Description |
|---|---|---|
FIRECRACKER_KERNEL | /opt/ferro/vmlinux | Path to kernel image |
FIRECRACKER_ROOTFS | /opt/ferro/rootfs.ext4 | Path to root filesystem |
FIRECRACKER_SOCKET | /tmp/firecracker.sock | API socket path |
FIRECRACKER_TAP | tap0 | TAP device name |
FIRECRACKER_VCPUS | 2 | Number of vCPUs |
FIRECRACKER_MEM | 512 | Memory in MiB |
FIRECRACKER_MAC | AA:FC:00:00:00:01 | Guest MAC address |
FIRECRACKER_ROOTFS_SIZE | 512 | Rootfs size in MiB |
Resource Requirements
| Resource | Requirement |
|---|---|
| vCPUs | 2 |
| RAM | 512 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
kubectlconfigured 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
| Variable | Default | Description |
|---|---|---|
admin_user | admin | Admin username |
admin_password | (required) | Admin password |
replica_count | 1 | Number of pod replicas |
persistence_enabled | true | Enable persistent storage |
persistence_size | 5Gi | Persistent volume size |
image_repository | ghcr.io/wyattau/ferro | Container image repository |
image_tag | latest | Container image tag |
ingress_enabled | false | Enable Ingress resource |
ingress_class_name | "" | Ingress class name |
network_policy_enabled | true | Enable network policies |
What is Deployed
The Terraform configuration creates:
- Kubernetes namespace (
ferro) with standard labels - 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_passwordin Terraform state secrets or a vault - Use
-var-fileto load variables from a file - Pin the
image_tagto 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
- Launch the Ferro desktop app
- Enter your server URL (e.g.,
https://ferro.example.com) - Enter your authentication token
- 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: 1for 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:
- PROPFIND requests list directory contents
- PUT requests upload files
- GET requests download files
- DELETE requests remove files
Keyboard Shortcuts
The Tauri app supports standard keyboard shortcuts:
| Shortcut | Action |
|---|---|
Cmd/Ctrl+N | New window |
Cmd/Ctrl+Q | Quit |
Backspace | Navigate up |
Enter | Open 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)
fuse3development libraries (for building from source)- The
fusermountcommand (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:
--tokenCLI flagFERRO_TOKENenvironment 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:
| Parameter | Value |
|---|---|
| Cache size | 10 MB |
| Max entries | 10,000 |
| Eviction policy | LRU |
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
| Operation | Description |
|---|---|
read | Read file contents |
write | Create or overwrite files |
mkdir | Create directories |
rmdir | Remove empty directories |
unlink | Delete files |
rename | Move or rename files and directories |
readdir | List directory contents |
getattr / stat | Retrieve file metadata |
open / release | Open and close file handles |
Tips
- Use
/etc/fstabfor persistent mounts (not recommended for remote FUSE) - Set
--allow-rootif 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/ferroto 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:
| Setting | Value |
|---|---|
| Server URL | http://localhost:8080 (or your domain) |
| CalDAV path | /dav/cal/ |
| CardDAV path | /dav/card/ |
| Username | Your Ferro username |
| Password | Your Ferro password |
Thunderbird (Cross-platform)
Calendar setup
- Open Thunderbird
- Click the calendar icon in the toolbar
- Right-click in the calendar list > New Calendar
- Select "On the Network"
- Choose "CalDAV"
- Enter the server URL:
http://your-server:8080/dav/cal/ - Enter your credentials
- Click "Find Calendars" to auto-discover
- Select the calendar and click "Subscribe"
Contacts setup
- Open Thunderbird
- Click the address book icon
- File > New > Remote Address Book
- Select "CardDAV"
- Enter the server URL:
http://your-server:8080/dav/card/ - Enter your credentials
- Click "OK"
macOS Calendar
Calendar setup
- Open Calendar (or System Settings > Internet Accounts)
- Click "Add Account" > "Other" > "CalDAV"
- Select "Manual"
- Enter:
- Server:
your-server:8080 - Path:
/dav/cal/ - Username and password
- Server:
- Sign In
Contacts setup
- Open Contacts
- Contacts > Add Account > Other Contacts Account
- Select "CardDAV"
- Enter:
- Server:
your-server:8080 - Path:
/dav/card/ - Username and password
- Server:
- Sign In
DAVx5 (Android)
Setup
- Install DAVx5 from F-Droid or Google Play
- Open DAVx5
- Tap "+" to add an account
- Enter:
- Base URL:
http://your-server:8080 - Username and password
- Base URL:
- DAVx5 will auto-discover CalDAV and CardDAV services
- Select which calendars and address books to sync
Troubleshooting
- Ensure the server URL is accessible from your device
- Check that
--admin-userand--admin-passwordare set on the server - DAVx5 requires HTTPS in production -- use a reverse proxy with TLS
Evolution (Linux/GNOME)
Calendar setup
- Open Evolution
- File > New > Calendar
- Select "CalDAV"
- Enter:
- URL:
http://your-server:8080/dav/cal/ - Username and password
- URL:
- Click "Retrieve List" to find calendars
Contacts setup
- Open Evolution
- File > New > Address Book
- Select "CardDAV"
- Enter:
- URL:
http://your-server:8080/dav/card/ - Username and password
- URL:
- 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-userand--admin-passwordare 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/calto 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
agecrate - 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
- The server reads the file at the given path
- Encrypts the content using age with the provided passphrase
- Stores the encrypted content back to the same path
- 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
- The server reads the file at the given path
- Checks if the content is age-encrypted (starts with the age header)
- Decrypts the content using age with the provided passphrase
- 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
| Property | Value |
|---|---|
| Key exchange | X25519 |
| Symmetric encryption | ChaCha20-Poly1305 |
| Key derivation | scrypt (passphrase-based) |
| Header format | ASCII-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
keyIdmatches the activityactorfield to prevent spoofing. - Empty secret = disabled -- If
--federation-secretis not set, the inbox returns 503 and federation is disabled. - Firewall -- Restrict
/fed/inboxaccess 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
| Method | Details |
|---|---|
| security@wyatt.au (PGP encrypted) | |
| GitHub | Security Advisories |
Response Timeline
| Severity | Initial Response | Patch Release |
|---|---|---|
| Critical (RCE, auth bypass) | 24 hours | 72 hours |
| High (data exposure, privilege escalation) | 48 hours | 7 days |
| Medium (CSRF, XSS, information disclosure) | 72 hours | 14 days |
| Low (best practices, minor issues) | 1 week | Next release |
Security Features
Authentication
| Method | Description |
|---|---|
| Simple auth | HTTP Basic Auth with bcrypt-hashed passwords (cost factor 12) |
| OIDC | OpenID Connect with PKCE flow (Keycloak, Auth0, Google, etc.) |
| LDAP | LDAP authentication (behind ldap feature flag) |
| Authorization | Cedar policy engine for fine-grained access control |
Encryption
| Layer | Implementation |
|---|---|
| Transport | TLS 1.3 (rustls) |
| File E2E | age (X25519, ChaCha20-Poly1305) |
| Passwords | bcrypt (cost factor 12) |
| Tokens | HMAC-SHA256 |
| Comparison | Constant-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
| Header | Value |
|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Content-Security-Policy | Configurable |
X-Request-ID | Unique 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-privilegessecurity optioncap-drop: ALLwith 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
| Version | Supported |
|---|---|
| 2.x | Yes |
| < 2.0 | No |
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.