diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4e7c53b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/warpgate/target/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..532d4c5
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,80 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**Warpgate** is an SSD read-write caching proxy that accelerates remote NAS access for photographers, videographers, and remote workers. It sits between client devices and a remote NAS, providing local SSD caching with automatic write-back.
+
+- **Current state**: Specification/PRD only (`warpgate-prd-v4.md`, written in Chinese). No implementation code exists yet.
+- **MVP target**: Configuration files + bash deployment scripts for Linux (Ubuntu 22.04+ / Debian 12+)
+- **Primary user**: Photographers using Lightroom over Tailscale to access a home NAS
+
+## Architecture
+
+```
+Clients (macOS/Windows/Linux/iPad)
+ │ SMB / NFS / WebDAV
+ ▼
+Warpgate Proxy (local network)
+ ├─ Samba Server (SMB2/3 — primary, for Lightroom/Finder/Explorer)
+ ├─ NFS Server (Linux clients)
+ ├─ WebDAV Server (mobile)
+ └─ rclone VFS FUSE mount (/mnt/nas-photos)
+ └─ SSD Cache (rclone-managed LRU, dirty file protection)
+ │ SFTP over Tailscale/WireGuard
+ ▼
+ Remote NAS (any brand supporting SFTP)
+```
+
+**Key design decisions:**
+- All protocols share a single rclone FUSE mount point — one cache layer, no duplication
+- rclone VFS with `--vfs-cache-mode full` handles both read-through caching and async write-back
+- **Single-direction write constraint**: NAS doesn't change while user is away, eliminating conflict resolution
+- Remote change detection uses rclone's `--dir-cache-time` (no brand-specific NAS APIs)
+- Cache filesystem should be btrfs or ZFS (CoW + journal for crash consistency)
+
+## Core Technologies
+
+- **rclone** v1.65+ — VFS FUSE mount, read-through cache, async write-back, LRU eviction, RC API
+- **Samba** 4.x — SMB shares for macOS/Windows clients
+- **nfs-kernel-server** — NFS exports for Linux clients
+- **FUSE** 3.x — userspace filesystem for rclone mount
+- **Tailscale/WireGuard** — secure tunnel to remote NAS via SFTP
+
+## PRD Structure (warpgate-prd-v4.md)
+
+The PRD is comprehensive (836 lines, Chinese) with these sections:
+- Sections 1-2: Product positioning, target users
+- Section 3: System architecture with Mermaid diagrams
+- Section 4: Features organized by priority (P0=MVP, P1=important, P2=future)
+- Section 5: Data consistency model and scenario walkthroughs
+- Sections 6-7: Remote change detection, cache behavior details
+- Section 8: Full configuration parameter reference
+- Section 9: Preset templates (photographer, video editor, office)
+- Sections 10-11: Deployment requirements, risks
+- Sections 12-15: Roadmap, paid services, anti-scope, glossary
+
+## Feature Priority
+
+- **P0 (MVP)**: Transparent multi-protocol proxy, read-through cache, cache consistency, remote change detection, cache space management, one-click deployment
+- **P1**: WiFi setup AP + captive portal proxy, cache warm-up, CLI tools (`warpgate status/cache-list/warmup/bwlimit/...`), adaptive bandwidth throttling, connection resilience
+- **P2**: WiFi AP sharing, web UI, NAS-side agent push, multi-NAS support, smart cache policies, Docker image, notifications
+
+## Configuration
+
+All behavior driven by environment variables — key groups:
+- Connection: `NAS_HOST`, `NAS_USER`, `NAS_PASS`/`NAS_KEY_FILE`, `NAS_REMOTE_PATH`
+- Cache: `CACHE_DIR`, `CACHE_MAX_SIZE`, `CACHE_MAX_AGE`, `CACHE_MIN_FREE`
+- Read tuning: `READ_CHUNK_SIZE`, `READ_AHEAD`, `BUFFER_SIZE`
+- Write-back: `VFS_WRITE_BACK` (default 5s), `TRANSFERS`
+- Bandwidth: `BW_LIMIT_UP`, `BW_LIMIT_DOWN`, `BW_ADAPTIVE`
+- Protocols: `ENABLE_SMB`, `ENABLE_NFS`, `ENABLE_WEBDAV`
+- Directory cache: `DIR_CACHE_TIME`
+
+## Language & Conventions
+
+- PRD and product context are in **Simplified Chinese**
+- Product name is **Warpgate** (English)
+- NAS brand-agnostic: must work with Synology, QNAP, TrueNAS, DIY — SFTP only, no vendor APIs
+- Deployment targets Linux only; clients are cross-platform
diff --git a/warpgate/Cargo.lock b/warpgate/Cargo.lock
new file mode 100644
index 0000000..2cef67a
--- /dev/null
+++ b/warpgate/Cargo.lock
@@ -0,0 +1,1126 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "clap"
+version = "4.5.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f"
+dependencies = [
+ "cookie",
+ "document-features",
+ "idna",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "ctrlc"
+version = "3.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
+dependencies = [
+ "dispatch2",
+ "nix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
+dependencies = [
+ "bitflags",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "libc"
+version = "0.2.180"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "objc2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.116"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+
+[[package]]
+name = "time-macros"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "toml"
+version = "1.0.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1dfefef6a142e93f346b64c160934eb13b5594b84ab378133ac6815cb2bd57f"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.0.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.0.9+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.0.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc"
+dependencies = [
+ "base64",
+ "cookie_store",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "ureq-proto",
+ "utf-8",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "warpgate"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "ctrlc",
+ "libc",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "toml",
+ "ureq",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/warpgate/Cargo.toml b/warpgate/Cargo.toml
new file mode 100644
index 0000000..6ccfae1
--- /dev/null
+++ b/warpgate/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "warpgate"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0.101"
+clap = { version = "4.5.59", features = ["derive"] }
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.149"
+thiserror = "2.0.18"
+toml = "1.0.2"
+ctrlc = "3.4"
+libc = "0.2"
+ureq = { version = "3.2.0", features = ["json"] }
diff --git a/warpgate/src/cli/bwlimit.rs b/warpgate/src/cli/bwlimit.rs
new file mode 100644
index 0000000..2ec1744
--- /dev/null
+++ b/warpgate/src/cli/bwlimit.rs
@@ -0,0 +1,59 @@
+//! `warpgate bwlimit` — view or adjust bandwidth limits at runtime.
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+use crate::rclone::rc;
+
+pub fn run(_config: &Config, up: Option<&str>, down: Option<&str>) -> Result<()> {
+ let result = rc::bwlimit(up, down).context("Failed to call rclone bwlimit API")?;
+
+ if up.is_none() && down.is_none() {
+ println!("Current bandwidth limits:");
+ } else {
+ println!("Updated bandwidth limits:");
+ }
+
+ // rclone core/bwlimit returns { "bytesPerSecond": N, "bytesPerSecondTx": N, "bytesPerSecondRx": N }
+ // A value of -1 means unlimited.
+ let has_fields = result.get("bytesPerSecondTx").is_some();
+
+ if has_fields {
+ if let Some(tx) = result.get("bytesPerSecondTx").and_then(|v| v.as_i64()) {
+ if tx < 0 {
+ println!(" Upload: unlimited");
+ } else {
+ println!(" Upload: {}/s", format_bytes(tx as u64));
+ }
+ }
+ if let Some(rx) = result.get("bytesPerSecondRx").and_then(|v| v.as_i64()) {
+ if rx < 0 {
+ println!(" Download: unlimited");
+ } else {
+ println!(" Download: {}/s", format_bytes(rx as u64));
+ }
+ }
+ } else {
+ // Fallback: print raw response
+ println!("{}", serde_json::to_string_pretty(&result)?);
+ }
+
+ Ok(())
+}
+
+fn format_bytes(bytes: u64) -> String {
+ const KIB: f64 = 1024.0;
+ const MIB: f64 = KIB * 1024.0;
+ const GIB: f64 = MIB * 1024.0;
+
+ let b = bytes as f64;
+ if b >= GIB {
+ format!("{:.1} GiB", b / GIB)
+ } else if b >= MIB {
+ format!("{:.1} MiB", b / MIB)
+ } else if b >= KIB {
+ format!("{:.1} KiB", b / KIB)
+ } else {
+ format!("{} B", bytes)
+ }
+}
diff --git a/warpgate/src/cli/cache.rs b/warpgate/src/cli/cache.rs
new file mode 100644
index 0000000..87b0af1
--- /dev/null
+++ b/warpgate/src/cli/cache.rs
@@ -0,0 +1,90 @@
+//! `warpgate cache-list` and `warpgate cache-clean` commands.
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+use crate::rclone::rc;
+
+/// List cached files via rclone RC API.
+pub fn list(_config: &Config) -> Result<()> {
+ let result = rc::vfs_list("/").context("Failed to list VFS cache")?;
+
+ // vfs/list may return an array directly or { "list": [...] }
+ let entries = if let Some(arr) = result.as_array() {
+ arr.as_slice()
+ } else if let Some(list) = result.get("list").and_then(|v| v.as_array()) {
+ list.as_slice()
+ } else {
+ // Unknown format — print raw JSON
+ println!("{}", serde_json::to_string_pretty(&result)?);
+ return Ok(());
+ };
+
+ if entries.is_empty() {
+ println!("Cache is empty.");
+ return Ok(());
+ }
+
+ println!("{:<10} PATH", "SIZE");
+ println!("{}", "-".repeat(60));
+
+ for entry in entries {
+ let name = entry.get("Name").and_then(|v| v.as_str()).unwrap_or("?");
+ let size = entry.get("Size").and_then(|v| v.as_u64()).unwrap_or(0);
+ let is_dir = entry
+ .get("IsDir")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ if is_dir {
+ println!("{:<10} {}/", "
", name);
+ } else {
+ println!("{:<10} {}", format_bytes(size), name);
+ }
+ }
+
+ Ok(())
+}
+
+/// Clean cached files (only clean files, never dirty).
+pub fn clean(_config: &Config, all: bool) -> Result<()> {
+ if all {
+ println!("Clearing VFS directory cache...");
+ rc::vfs_forget("/").context("Failed to clear VFS cache")?;
+ println!("Done. VFS directory cache cleared.");
+ } else {
+ println!("Current cache status:");
+ match rc::vfs_stats() {
+ Ok(vfs) => {
+ if let Some(dc) = vfs.disk_cache {
+ println!(" Used: {}", format_bytes(dc.bytes_used));
+ println!(" Uploading: {}", dc.uploads_in_progress);
+ println!(" Queued: {}", dc.uploads_queued);
+ if dc.uploads_in_progress > 0 || dc.uploads_queued > 0 {
+ println!("\n Dirty files exist — only synced files are safe to clean.");
+ }
+ }
+ }
+ Err(e) => eprintln!(" Could not fetch cache stats: {}", e),
+ }
+ println!("\nRun with --all to clear the directory cache.");
+ }
+ Ok(())
+}
+
+fn format_bytes(bytes: u64) -> String {
+ const KIB: f64 = 1024.0;
+ const MIB: f64 = KIB * 1024.0;
+ const GIB: f64 = MIB * 1024.0;
+
+ let b = bytes as f64;
+ if b >= GIB {
+ format!("{:.1} GiB", b / GIB)
+ } else if b >= MIB {
+ format!("{:.1} MiB", b / MIB)
+ } else if b >= KIB {
+ format!("{:.1} KiB", b / KIB)
+ } else {
+ format!("{} B", bytes)
+ }
+}
diff --git a/warpgate/src/cli/config_init.rs b/warpgate/src/cli/config_init.rs
new file mode 100644
index 0000000..e8c8d29
--- /dev/null
+++ b/warpgate/src/cli/config_init.rs
@@ -0,0 +1,21 @@
+//! `warpgate config-init` — generate a default config file.
+
+use std::path::PathBuf;
+
+use anyhow::Result;
+
+use crate::config::Config;
+
+pub fn run(output: Option) -> Result<()> {
+ let path = output.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_CONFIG_PATH));
+ let content = Config::default_toml();
+ if path.exists() {
+ anyhow::bail!("Config file already exists: {}. Remove it first.", path.display());
+ }
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(&path, content)?;
+ println!("Config written to {}", path.display());
+ Ok(())
+}
diff --git a/warpgate/src/cli/log.rs b/warpgate/src/cli/log.rs
new file mode 100644
index 0000000..9b05ae5
--- /dev/null
+++ b/warpgate/src/cli/log.rs
@@ -0,0 +1,33 @@
+//! `warpgate log` — stream service logs in real time.
+
+use std::process::Command;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+
+pub fn run(_config: &Config, lines: u32, follow: bool) -> Result<()> {
+ let mut cmd = Command::new("journalctl");
+ cmd.arg("-u")
+ .arg("warpgate-mount")
+ .arg("-n")
+ .arg(lines.to_string());
+
+ if follow {
+ // Stream directly to stdout with -f (like tail -f)
+ cmd.arg("-f");
+ let status = cmd.status().context("Failed to run journalctl")?;
+ if !status.success() {
+ anyhow::bail!("journalctl exited with status {}", status);
+ }
+ } else {
+ let output = cmd.output().context("Failed to run journalctl")?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ anyhow::bail!("journalctl failed: {}", stderr.trim());
+ }
+ print!("{}", String::from_utf8_lossy(&output.stdout));
+ }
+
+ Ok(())
+}
diff --git a/warpgate/src/cli/mod.rs b/warpgate/src/cli/mod.rs
new file mode 100644
index 0000000..e6eadb0
--- /dev/null
+++ b/warpgate/src/cli/mod.rs
@@ -0,0 +1,7 @@
+pub mod bwlimit;
+pub mod cache;
+pub mod config_init;
+pub mod log;
+pub mod speed_test;
+pub mod status;
+pub mod warmup;
diff --git a/warpgate/src/cli/speed_test.rs b/warpgate/src/cli/speed_test.rs
new file mode 100644
index 0000000..ac10a09
--- /dev/null
+++ b/warpgate/src/cli/speed_test.rs
@@ -0,0 +1,117 @@
+//! `warpgate speed-test` — test network speed to remote NAS.
+
+use std::io::Write;
+use std::process::Command;
+use std::time::Instant;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+use crate::rclone::config as rclone_config;
+
+const TEST_SIZE: usize = 10 * 1024 * 1024; // 10 MiB
+
+pub fn run(config: &Config) -> Result<()> {
+ let tmp_local = std::env::temp_dir().join("warpgate-speedtest");
+ let remote_path = format!(
+ "nas:{}/.warpgate-speedtest",
+ config.connection.remote_path
+ );
+
+ // Create a 10 MiB test file
+ println!("Creating 10 MiB test file...");
+ {
+ let mut f = std::fs::File::create(&tmp_local)
+ .context("Failed to create temp test file")?;
+ let buf = vec![0x42u8; TEST_SIZE];
+ f.write_all(&buf)?;
+ f.flush()?;
+ }
+
+ // Upload test
+ println!("Testing upload speed...");
+ let start = Instant::now();
+ let status = Command::new("rclone")
+ .arg("copyto")
+ .arg("--config")
+ .arg(rclone_config::RCLONE_CONF_PATH)
+ .arg(&tmp_local)
+ .arg(&remote_path)
+ .status()
+ .context("Failed to run rclone copyto for upload")?;
+
+ let upload_elapsed = start.elapsed();
+ if !status.success() {
+ cleanup(&tmp_local, &remote_path);
+ anyhow::bail!("Upload failed with exit code {}", status);
+ }
+
+ let upload_speed = TEST_SIZE as f64 / upload_elapsed.as_secs_f64();
+ println!(
+ "Upload: {}/s ({:.1}s)",
+ format_bytes(upload_speed as u64),
+ upload_elapsed.as_secs_f64()
+ );
+
+ // Remove local file before download test
+ let _ = std::fs::remove_file(&tmp_local);
+
+ // Download test
+ println!("Testing download speed...");
+ let start = Instant::now();
+ let status = Command::new("rclone")
+ .arg("copyto")
+ .arg("--config")
+ .arg(rclone_config::RCLONE_CONF_PATH)
+ .arg(&remote_path)
+ .arg(&tmp_local)
+ .status()
+ .context("Failed to run rclone copyto for download")?;
+
+ let download_elapsed = start.elapsed();
+ if !status.success() {
+ cleanup(&tmp_local, &remote_path);
+ anyhow::bail!("Download failed with exit code {}", status);
+ }
+
+ let download_speed = TEST_SIZE as f64 / download_elapsed.as_secs_f64();
+ println!(
+ "Download: {}/s ({:.1}s)",
+ format_bytes(download_speed as u64),
+ download_elapsed.as_secs_f64()
+ );
+
+ // Cleanup
+ cleanup(&tmp_local, &remote_path);
+ println!("Done.");
+
+ Ok(())
+}
+
+/// Remove local and remote temp files.
+fn cleanup(local: &std::path::Path, remote: &str) {
+ let _ = std::fs::remove_file(local);
+ let _ = Command::new("rclone")
+ .arg("deletefile")
+ .arg("--config")
+ .arg(rclone_config::RCLONE_CONF_PATH)
+ .arg(remote)
+ .status();
+}
+
+fn format_bytes(bytes: u64) -> String {
+ const KIB: f64 = 1024.0;
+ const MIB: f64 = KIB * 1024.0;
+ const GIB: f64 = MIB * 1024.0;
+
+ let b = bytes as f64;
+ if b >= GIB {
+ format!("{:.1} GiB", b / GIB)
+ } else if b >= MIB {
+ format!("{:.1} MiB", b / MIB)
+ } else if b >= KIB {
+ format!("{:.1} KiB", b / KIB)
+ } else {
+ format!("{} B", bytes)
+ }
+}
diff --git a/warpgate/src/cli/status.rs b/warpgate/src/cli/status.rs
new file mode 100644
index 0000000..f388036
--- /dev/null
+++ b/warpgate/src/cli/status.rs
@@ -0,0 +1,72 @@
+//! `warpgate status` — show service status, cache stats, write-back queue, bandwidth.
+
+use anyhow::Result;
+
+use crate::config::Config;
+use crate::rclone::{mount, rc};
+
+pub fn run(config: &Config) -> Result<()> {
+ // Check mount status
+ let mounted = match mount::is_mounted(config) {
+ Ok(m) => m,
+ Err(e) => {
+ eprintln!("Warning: could not check mount status: {}", e);
+ false
+ }
+ };
+
+ if mounted {
+ println!("Mount: UP ({})", config.mount.point.display());
+ } else {
+ println!("Mount: DOWN");
+ println!("\nrclone VFS mount is not active.");
+ println!("Start with: systemctl start warpgate-mount");
+ return Ok(());
+ }
+
+ // Transfer stats from rclone RC API
+ match rc::core_stats() {
+ Ok(stats) => {
+ println!("Speed: {}/s", format_bytes(stats.speed as u64));
+ println!("Moved: {}", format_bytes(stats.bytes));
+ println!("Active: {} transfers", stats.transfers);
+ println!("Errors: {}", stats.errors);
+ }
+ Err(e) => {
+ eprintln!("Could not reach rclone RC API: {}", e);
+ }
+ }
+
+ // VFS cache stats (RC connection error already reported above)
+ if let Ok(vfs) = rc::vfs_stats() {
+ if let Some(dc) = vfs.disk_cache {
+ println!("Cache: {}", format_bytes(dc.bytes_used));
+ println!(
+ "Dirty: {} uploading, {} queued",
+ dc.uploads_in_progress, dc.uploads_queued
+ );
+ if dc.errored_files > 0 {
+ println!("Errored: {} files", dc.errored_files);
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn format_bytes(bytes: u64) -> String {
+ const KIB: f64 = 1024.0;
+ const MIB: f64 = KIB * 1024.0;
+ const GIB: f64 = MIB * 1024.0;
+
+ let b = bytes as f64;
+ if b >= GIB {
+ format!("{:.1} GiB", b / GIB)
+ } else if b >= MIB {
+ format!("{:.1} MiB", b / MIB)
+ } else if b >= KIB {
+ format!("{:.1} KiB", b / KIB)
+ } else {
+ format!("{} B", bytes)
+ }
+}
diff --git a/warpgate/src/cli/warmup.rs b/warpgate/src/cli/warmup.rs
new file mode 100644
index 0000000..e9a73c9
--- /dev/null
+++ b/warpgate/src/cli/warmup.rs
@@ -0,0 +1,45 @@
+//! `warpgate warmup` — pre-cache a remote directory to local SSD.
+
+use std::process::Command;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+use crate::rclone::config as rclone_config;
+
+pub fn run(config: &Config, path: &str, newer_than: Option<&str>) -> Result<()> {
+ let remote_src = format!("nas:{}/{}", config.connection.remote_path, path);
+ let local_dest = std::env::temp_dir().join("warpgate-warmup");
+
+ println!("Warming up: {}", remote_src);
+
+ // Create temp destination for downloaded files
+ std::fs::create_dir_all(&local_dest)
+ .context("Failed to create temp directory for warmup")?;
+
+ let mut cmd = Command::new("rclone");
+ cmd.arg("copy")
+ .arg("--config")
+ .arg(rclone_config::RCLONE_CONF_PATH)
+ .arg(&remote_src)
+ .arg(&local_dest)
+ .arg("--no-traverse")
+ .arg("--progress");
+
+ if let Some(age) = newer_than {
+ cmd.arg("--max-age").arg(age);
+ }
+
+ println!("Downloading from remote NAS...");
+ let status = cmd.status().context("Failed to run rclone copy")?;
+
+ // Clean up temp directory
+ let _ = std::fs::remove_dir_all(&local_dest);
+
+ if status.success() {
+ println!("Warmup complete.");
+ Ok(())
+ } else {
+ anyhow::bail!("rclone copy exited with status {}", status);
+ }
+}
diff --git a/warpgate/src/config.rs b/warpgate/src/config.rs
new file mode 100644
index 0000000..6daf2f3
--- /dev/null
+++ b/warpgate/src/config.rs
@@ -0,0 +1,213 @@
+//! Warpgate configuration schema.
+//!
+//! Maps to `/etc/warpgate/config.toml`. All fields mirror the PRD Section 8 parameter list.
+//! Environment variables can override config file values (prefixed with `WARPGATE_`).
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+/// Default config file path.
+pub const DEFAULT_CONFIG_PATH: &str = "/etc/warpgate/config.toml";
+
+/// Top-level configuration.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Config {
+ pub connection: ConnectionConfig,
+ pub cache: CacheConfig,
+ pub read: ReadConfig,
+ pub bandwidth: BandwidthConfig,
+ pub writeback: WritebackConfig,
+ pub directory_cache: DirectoryCacheConfig,
+ pub protocols: ProtocolsConfig,
+ pub mount: MountConfig,
+}
+
+/// SFTP connection to remote NAS.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ConnectionConfig {
+ /// Remote NAS Tailscale IP or hostname.
+ pub nas_host: String,
+ /// SFTP username.
+ pub nas_user: String,
+ /// SFTP password (prefer key_file).
+ #[serde(default)]
+ pub nas_pass: Option,
+ /// Path to SSH private key.
+ #[serde(default)]
+ pub nas_key_file: Option,
+ /// Target path on NAS.
+ pub remote_path: String,
+ /// SFTP port.
+ #[serde(default = "default_sftp_port")]
+ pub sftp_port: u16,
+ /// SFTP connection pool size.
+ #[serde(default = "default_sftp_connections")]
+ pub sftp_connections: u32,
+}
+
+/// SSD cache settings.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CacheConfig {
+ /// Cache storage directory (should be on SSD, prefer btrfs/ZFS).
+ pub dir: PathBuf,
+ /// Max cache size (e.g. "200G").
+ #[serde(default = "default_cache_max_size")]
+ pub max_size: String,
+ /// Max cache retention time (e.g. "720h").
+ #[serde(default = "default_cache_max_age")]
+ pub max_age: String,
+ /// Minimum free space on cache disk (e.g. "10G").
+ #[serde(default = "default_cache_min_free")]
+ pub min_free: String,
+}
+
+/// Read optimization parameters.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ReadConfig {
+ /// Chunk size for chunked reads (e.g. "256M").
+ #[serde(default = "default_read_chunk_size")]
+ pub chunk_size: String,
+ /// Max chunk growth limit (e.g. "1G").
+ #[serde(default = "default_read_chunk_limit")]
+ pub chunk_limit: String,
+ /// Read-ahead buffer size (e.g. "512M").
+ #[serde(default = "default_read_ahead")]
+ pub read_ahead: String,
+ /// In-memory buffer size (e.g. "256M").
+ #[serde(default = "default_buffer_size")]
+ pub buffer_size: String,
+}
+
+/// Bandwidth control.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BandwidthConfig {
+ /// Upload (write-back) speed limit (e.g. "10M", "0" = unlimited).
+ #[serde(default = "default_bw_zero")]
+ pub limit_up: String,
+ /// Download (cache pull) speed limit.
+ #[serde(default = "default_bw_zero")]
+ pub limit_down: String,
+ /// Enable adaptive write-back throttling.
+ #[serde(default = "default_true")]
+ pub adaptive: bool,
+}
+
+/// Write-back behavior.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WritebackConfig {
+ /// Delay before write-back (rclone --vfs-write-back).
+ #[serde(default = "default_write_back")]
+ pub write_back: String,
+ /// Concurrent transfer count (rclone --transfers).
+ #[serde(default = "default_transfers")]
+ pub transfers: u32,
+}
+
+/// Directory listing cache.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DirectoryCacheConfig {
+ /// Directory cache TTL (rclone --dir-cache-time).
+ #[serde(default = "default_dir_cache_time")]
+ pub cache_time: String,
+}
+
+/// Multi-protocol service toggles.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ProtocolsConfig {
+ /// Enable SMB (Samba) sharing.
+ #[serde(default = "default_true")]
+ pub enable_smb: bool,
+ /// Enable NFS export.
+ #[serde(default)]
+ pub enable_nfs: bool,
+ /// Enable WebDAV service.
+ #[serde(default)]
+ pub enable_webdav: bool,
+ /// NFS allowed network CIDR.
+ #[serde(default = "default_nfs_network")]
+ pub nfs_allowed_network: String,
+ /// WebDAV listen port.
+ #[serde(default = "default_webdav_port")]
+ pub webdav_port: u16,
+}
+
+/// FUSE mount point configuration.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MountConfig {
+ /// FUSE mount point path.
+ #[serde(default = "default_mount_point")]
+ pub point: PathBuf,
+}
+
+// --- Default value functions ---
+
+fn default_sftp_port() -> u16 {
+ 22
+}
+fn default_sftp_connections() -> u32 {
+ 8
+}
+fn default_cache_max_size() -> String {
+ "200G".into()
+}
+fn default_cache_max_age() -> String {
+ "720h".into()
+}
+fn default_cache_min_free() -> String {
+ "10G".into()
+}
+fn default_read_chunk_size() -> String {
+ "256M".into()
+}
+fn default_read_chunk_limit() -> String {
+ "1G".into()
+}
+fn default_read_ahead() -> String {
+ "512M".into()
+}
+fn default_buffer_size() -> String {
+ "256M".into()
+}
+fn default_bw_zero() -> String {
+ "0".into()
+}
+fn default_true() -> bool {
+ true
+}
+fn default_write_back() -> String {
+ "5s".into()
+}
+fn default_transfers() -> u32 {
+ 4
+}
+fn default_dir_cache_time() -> String {
+ "1h".into()
+}
+fn default_nfs_network() -> String {
+ "192.168.0.0/24".into()
+}
+fn default_webdav_port() -> u16 {
+ 8080
+}
+fn default_mount_point() -> PathBuf {
+ PathBuf::from("/mnt/nas-photos")
+}
+
+impl Config {
+ /// Load config from a TOML file.
+ pub fn load(path: &Path) -> Result {
+ let content = std::fs::read_to_string(path)
+ .with_context(|| format!("Failed to read config file: {}", path.display()))?;
+ let config: Config =
+ toml::from_str(&content).with_context(|| "Failed to parse config TOML")?;
+ Ok(config)
+ }
+
+ /// Generate a default config TOML string with comments.
+ pub fn default_toml() -> String {
+ include_str!("../templates/config.toml.default")
+ .to_string()
+ }
+}
diff --git a/warpgate/src/deploy/deps.rs b/warpgate/src/deploy/deps.rs
new file mode 100644
index 0000000..1557d4d
--- /dev/null
+++ b/warpgate/src/deploy/deps.rs
@@ -0,0 +1,70 @@
+//! Dependency detection and installation.
+
+use std::process::Command;
+
+use anyhow::{Context, Result};
+
+/// Required system dependencies (binary names).
+pub const REQUIRED_DEPS: &[&str] = &["rclone", "smbd", "fusermount3"];
+
+/// Optional dependencies (only if enabled in config).
+pub const OPTIONAL_DEPS: &[(&str, &str)] = &[
+ ("exportfs", "nfs-kernel-server"), // NFS
+];
+
+/// Check which required dependencies are missing.
+///
+/// Runs `which ` for each entry in [`REQUIRED_DEPS`] and returns the
+/// names of binaries that could not be found.
+pub fn check_missing() -> Vec {
+ REQUIRED_DEPS
+ .iter()
+ .filter(|bin| {
+ !Command::new("which")
+ .arg(bin)
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ .map(|s| s.success())
+ .unwrap_or(false)
+ })
+ .map(|bin| (*bin).to_string())
+ .collect()
+}
+
+/// Map a binary name to its corresponding apt package name.
+fn binary_to_package(binary: &str) -> &str {
+ match binary {
+ "rclone" => "rclone",
+ "smbd" => "samba",
+ "fusermount3" => "fuse3",
+ "exportfs" => "nfs-kernel-server",
+ other => other,
+ }
+}
+
+/// Install missing dependencies via apt.
+///
+/// Takes a list of missing **binary names** (as returned by [`check_missing`]),
+/// maps each to the correct apt package, and runs `apt-get install -y`.
+pub fn install_missing(binaries: &[String]) -> Result<()> {
+ if binaries.is_empty() {
+ return Ok(());
+ }
+
+ let packages: Vec<&str> = binaries.iter().map(|b| binary_to_package(b)).collect();
+
+ println!("Installing packages: {}", packages.join(", "));
+
+ let status = Command::new("apt-get")
+ .args(["install", "-y"])
+ .args(&packages)
+ .status()
+ .context("Failed to run apt-get install")?;
+
+ if !status.success() {
+ anyhow::bail!("apt-get install failed with exit code: {}", status);
+ }
+
+ Ok(())
+}
diff --git a/warpgate/src/deploy/fs_check.rs b/warpgate/src/deploy/fs_check.rs
new file mode 100644
index 0000000..0dd8396
--- /dev/null
+++ b/warpgate/src/deploy/fs_check.rs
@@ -0,0 +1,66 @@
+//! Filesystem detection for cache directory.
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+
+/// Detect the filesystem type of the given path by reading `/proc/mounts`.
+///
+/// Parses each line of `/proc/mounts` (format: `device mount_point fs_type options dump pass`)
+/// and finds the mount entry whose mount point is the longest prefix of `path`.
+/// Returns the filesystem type string (e.g. "ext4", "btrfs", "zfs").
+pub fn detect_fs_type(path: &Path) -> Result {
+ let canonical = path
+ .canonicalize()
+ .unwrap_or_else(|_| path.to_path_buf());
+ let path_str = canonical.to_string_lossy();
+
+ let mounts = std::fs::read_to_string("/proc/mounts")
+ .context("Failed to read /proc/mounts")?;
+
+ let mut best_mount = "";
+ let mut best_fs = String::new();
+
+ for line in mounts.lines() {
+ let fields: Vec<&str> = line.split_whitespace().collect();
+ if fields.len() < 3 {
+ continue;
+ }
+ let mount_point = fields[1];
+ let fs_type = fields[2];
+
+ // Check if this mount point is a prefix of the target path
+ // and is longer than the current best match.
+ if path_str.starts_with(mount_point) && mount_point.len() > best_mount.len() {
+ best_mount = mount_point;
+ best_fs = fs_type.to_string();
+ }
+ }
+
+ if best_fs.is_empty() {
+ anyhow::bail!("Could not determine filesystem type for {}", path.display());
+ }
+
+ Ok(best_fs)
+}
+
+/// Warn if the cache directory is not on btrfs or ZFS.
+///
+/// Calls [`detect_fs_type`] and prints a warning if the filesystem is not
+/// a copy-on-write filesystem (btrfs or zfs).
+pub fn warn_if_not_cow(path: &Path) -> Result<()> {
+ match detect_fs_type(path) {
+ Ok(fs_type) => {
+ if fs_type != "btrfs" && fs_type != "zfs" {
+ println!(
+ "WARNING: Cache directory is on {fs_type}. \
+ btrfs or ZFS is recommended for crash consistency."
+ );
+ }
+ }
+ Err(e) => {
+ println!("WARNING: Could not detect filesystem type: {e}");
+ }
+ }
+ Ok(())
+}
diff --git a/warpgate/src/deploy/mod.rs b/warpgate/src/deploy/mod.rs
new file mode 100644
index 0000000..da7231e
--- /dev/null
+++ b/warpgate/src/deploy/mod.rs
@@ -0,0 +1,3 @@
+pub mod deps;
+pub mod fs_check;
+pub mod setup;
diff --git a/warpgate/src/deploy/setup.rs b/warpgate/src/deploy/setup.rs
new file mode 100644
index 0000000..8029fb2
--- /dev/null
+++ b/warpgate/src/deploy/setup.rs
@@ -0,0 +1,57 @@
+//! `warpgate deploy` — one-click deployment orchestration.
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+use crate::deploy::{deps, fs_check};
+use crate::rclone;
+use crate::services::{nfs, samba, systemd, webdav};
+
+pub fn run(config: &Config) -> Result<()> {
+ // Step 1: Check and install dependencies
+ println!("Checking dependencies...");
+ let missing = deps::check_missing();
+ if missing.is_empty() {
+ println!(" All dependencies satisfied.");
+ } else {
+ println!(" Missing: {}", missing.join(", "));
+ deps::install_missing(&missing)?;
+ }
+
+ // Step 2: Check filesystem type
+ println!("Checking filesystem...");
+ fs_check::warn_if_not_cow(&config.cache.dir)?;
+
+ // Step 3: Create cache directory
+ println!("Creating cache directory...");
+ std::fs::create_dir_all(&config.cache.dir)
+ .with_context(|| format!("Failed to create cache dir: {}", config.cache.dir.display()))?;
+
+ // Step 4: Generate rclone config
+ println!("Generating rclone config...");
+ rclone::config::write_config(config)?;
+
+ // Step 5: Generate service configs based on protocol toggles
+ println!("Generating service configs...");
+ if config.protocols.enable_smb {
+ samba::write_config(config)?;
+ }
+ if config.protocols.enable_nfs {
+ nfs::write_config(config)?;
+ }
+ // WebDAV is served by rclone directly; validate the config is correct.
+ if config.protocols.enable_webdav {
+ let _ = webdav::build_serve_command(config);
+ }
+
+ // Step 6: Install single warpgate.service unit (supervisor mode)
+ println!("Installing warpgate.service...");
+ systemd::install_run_unit(config)?;
+
+ // Step 7: Enable and start the unified service
+ println!("Starting warpgate service...");
+ systemd::enable_and_start_run()?;
+
+ println!("Deployment complete!");
+ Ok(())
+}
diff --git a/warpgate/src/main.rs b/warpgate/src/main.rs
new file mode 100644
index 0000000..69d6895
--- /dev/null
+++ b/warpgate/src/main.rs
@@ -0,0 +1,125 @@
+mod cli;
+mod config;
+mod deploy;
+mod rclone;
+mod services;
+mod supervisor;
+
+use std::path::PathBuf;
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+
+use config::Config;
+
+/// Warpgate — Make your NAS feel local.
+///
+/// SSD read-write caching proxy for remote NAS access.
+#[derive(Parser)]
+#[command(name = "warpgate", version, about)]
+struct Cli {
+ /// Path to config file.
+ #[arg(short, long, default_value = config::DEFAULT_CONFIG_PATH)]
+ config: PathBuf,
+
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// One-click deploy: install deps, generate configs, enable services.
+ Deploy,
+ /// Show service status, cache stats, write-back queue, bandwidth.
+ Status,
+ /// List files currently in the SSD cache.
+ CacheList,
+ /// Clean cached files (only evicts clean files, never dirty).
+ CacheClean {
+ /// Remove all clean files (default: only expired).
+ #[arg(long)]
+ all: bool,
+ },
+ /// Pre-cache a remote directory to local SSD.
+ Warmup {
+ /// Remote path to warm up (relative to NAS remote_path).
+ path: String,
+ /// Only files newer than this duration (e.g. "7d", "24h").
+ #[arg(long)]
+ newer_than: Option,
+ },
+ /// View or adjust bandwidth limits at runtime.
+ Bwlimit {
+ /// Upload limit (e.g. "10M", "0" for unlimited).
+ #[arg(long)]
+ up: Option,
+ /// Download limit (e.g. "50M", "0" for unlimited).
+ #[arg(long)]
+ down: Option,
+ },
+ /// Stream service logs in real time.
+ Log {
+ /// Number of recent lines to show.
+ #[arg(short, long, default_value = "50")]
+ lines: u32,
+ /// Follow log output (like tail -f).
+ #[arg(short, long)]
+ follow: bool,
+ },
+ /// Test network speed to remote NAS.
+ SpeedTest,
+ /// Run all services under a single supervisor process.
+ Run,
+ /// Generate a default config file.
+ ConfigInit {
+ /// Output path (default: /etc/warpgate/config.toml).
+ #[arg(short, long)]
+ output: Option,
+ },
+}
+
+fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ match cli.command {
+ // config-init doesn't need an existing config file
+ Commands::ConfigInit { output } => cli::config_init::run(output),
+ // deploy loads config if it exists, or generates one
+ Commands::Deploy => {
+ let config = load_config_or_default(&cli.config)?;
+ deploy::setup::run(&config)
+ }
+ // all other commands require a valid config
+ cmd => {
+ let config = Config::load(&cli.config)?;
+ match cmd {
+ Commands::Status => cli::status::run(&config),
+ Commands::CacheList => cli::cache::list(&config),
+ Commands::CacheClean { all } => cli::cache::clean(&config, all),
+ Commands::Warmup { path, newer_than } => {
+ cli::warmup::run(&config, &path, newer_than.as_deref())
+ }
+ Commands::Bwlimit { up, down } => {
+ cli::bwlimit::run(&config, up.as_deref(), down.as_deref())
+ }
+ Commands::Log { lines, follow } => cli::log::run(&config, lines, follow),
+ Commands::SpeedTest => cli::speed_test::run(&config),
+ Commands::Run => supervisor::run(&config),
+ // already handled above
+ Commands::ConfigInit { .. } | Commands::Deploy => unreachable!(),
+ }
+ }
+ }
+}
+
+/// Load config from file, or return a useful error.
+fn load_config_or_default(path: &std::path::Path) -> Result {
+ if path.exists() {
+ Config::load(path)
+ } else {
+ anyhow::bail!(
+ "Config file not found: {}. Run `warpgate config-init` to generate one.",
+ path.display()
+ )
+ }
+}
diff --git a/warpgate/src/rclone/config.rs b/warpgate/src/rclone/config.rs
new file mode 100644
index 0000000..18f4c27
--- /dev/null
+++ b/warpgate/src/rclone/config.rs
@@ -0,0 +1,73 @@
+//! Generate rclone.conf from Warpgate config.
+
+use std::fmt::Write;
+use std::path::Path;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+
+/// Default path for generated rclone config.
+pub const RCLONE_CONF_PATH: &str = "/etc/warpgate/rclone.conf";
+
+/// Generate rclone.conf content for the SFTP remote.
+///
+/// Produces an INI-style config with a `[nas]` section containing all SFTP
+/// connection parameters from the Warpgate config.
+pub fn generate(config: &Config) -> Result {
+ let conn = &config.connection;
+ let mut conf = String::new();
+
+ writeln!(conf, "[nas]")?;
+ writeln!(conf, "type = sftp")?;
+ writeln!(conf, "host = {}", conn.nas_host)?;
+ writeln!(conf, "user = {}", conn.nas_user)?;
+ writeln!(conf, "port = {}", conn.sftp_port)?;
+
+ if let Some(pass) = &conn.nas_pass {
+ let obscured = obscure_password(pass)?;
+ writeln!(conf, "pass = {obscured}")?;
+ }
+ if let Some(key_file) = &conn.nas_key_file {
+ writeln!(conf, "key_file = {key_file}")?;
+ }
+
+ writeln!(conf, "connections = {}", conn.sftp_connections)?;
+
+ // Disable hash checking — many NAS SFTP servers (e.g. Synology) don't support
+ // running shell commands like md5sum, causing upload verification to fail.
+ writeln!(conf, "disable_hashcheck = true")?;
+
+ Ok(conf)
+}
+
+/// Obscure a password using `rclone obscure` (required for rclone.conf).
+fn obscure_password(plain: &str) -> Result {
+ let output = std::process::Command::new("rclone")
+ .args(["obscure", plain])
+ .output()
+ .context("Failed to run `rclone obscure`. Is rclone installed?")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ anyhow::bail!("rclone obscure failed: {}", stderr.trim());
+ }
+
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+/// Write rclone.conf to disk at [`RCLONE_CONF_PATH`].
+pub fn write_config(config: &Config) -> Result<()> {
+ let content = generate(config)?;
+ let path = Path::new(RCLONE_CONF_PATH);
+
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)
+ .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
+ }
+
+ std::fs::write(path, &content)
+ .with_context(|| format!("Failed to write rclone config: {}", path.display()))?;
+
+ Ok(())
+}
diff --git a/warpgate/src/rclone/mod.rs b/warpgate/src/rclone/mod.rs
new file mode 100644
index 0000000..2b1622b
--- /dev/null
+++ b/warpgate/src/rclone/mod.rs
@@ -0,0 +1,3 @@
+pub mod config;
+pub mod mount;
+pub mod rc;
diff --git a/warpgate/src/rclone/mount.rs b/warpgate/src/rclone/mount.rs
new file mode 100644
index 0000000..6cd8b0d
--- /dev/null
+++ b/warpgate/src/rclone/mount.rs
@@ -0,0 +1,141 @@
+//! Manage rclone VFS FUSE mount lifecycle.
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+
+use super::config::RCLONE_CONF_PATH;
+
+/// Build the full `rclone mount` command-line arguments from config.
+///
+/// Returns a `Vec` starting with `"mount"` followed by the remote
+/// source, mount point, and all VFS/cache flags derived from config.
+pub fn build_mount_args(config: &Config) -> Vec {
+ let mut args = Vec::new();
+
+ // Subcommand and source:dest
+ args.push("mount".into());
+ args.push(format!("nas:{}", config.connection.remote_path));
+ args.push(config.mount.point.display().to_string());
+
+ // Point to our generated rclone.conf
+ args.push("--config".into());
+ args.push(RCLONE_CONF_PATH.into());
+
+ // VFS cache mode — full enables read-through + write-back
+ args.push("--vfs-cache-mode".into());
+ args.push("full".into());
+
+ // Write-back delay
+ args.push("--vfs-write-back".into());
+ args.push(config.writeback.write_back.clone());
+
+ // Cache size limits
+ args.push("--vfs-cache-max-size".into());
+ args.push(config.cache.max_size.clone());
+
+ args.push("--vfs-cache-max-age".into());
+ args.push(config.cache.max_age.clone());
+
+ // NOTE: --vfs-cache-min-free-space requires rclone 1.65+.
+ // Ubuntu apt may ship older versions. We detect support at runtime.
+ if rclone_supports_min_free_space() {
+ args.push("--vfs-cache-min-free-space".into());
+ args.push(config.cache.min_free.clone());
+ }
+
+ // Cache directory (SSD path)
+ args.push("--cache-dir".into());
+ args.push(config.cache.dir.display().to_string());
+
+ // Directory listing cache TTL
+ args.push("--dir-cache-time".into());
+ args.push(config.directory_cache.cache_time.clone());
+
+ // Read optimization
+ args.push("--buffer-size".into());
+ args.push(config.read.buffer_size.clone());
+
+ args.push("--vfs-read-chunk-size".into());
+ args.push(config.read.chunk_size.clone());
+
+ args.push("--vfs-read-chunk-size-limit".into());
+ args.push(config.read.chunk_limit.clone());
+
+ args.push("--vfs-read-ahead".into());
+ args.push(config.read.read_ahead.clone());
+
+ // Concurrent transfers for write-back
+ args.push("--transfers".into());
+ args.push(config.writeback.transfers.to_string());
+
+ // Bandwidth limits (only add flag if at least one direction is limited)
+ let bw = format_bwlimit(&config.bandwidth.limit_up, &config.bandwidth.limit_down);
+ if bw != "0" {
+ args.push("--bwlimit".into());
+ args.push(bw);
+ }
+
+ // Enable rclone RC API on default port
+ args.push("--rc".into());
+
+ // Allow non-root users to access the FUSE mount (requires user_allow_other in /etc/fuse.conf)
+ args.push("--allow-other".into());
+
+ args
+}
+
+/// Format the `--bwlimit` value from separate up/down strings.
+///
+/// rclone accepts `RATE` for symmetric or `UP:DOWN` for asymmetric limits.
+/// Returns `"0"` when both directions are unlimited.
+fn format_bwlimit(up: &str, down: &str) -> String {
+ let up_zero = up == "0" || up.is_empty();
+ let down_zero = down == "0" || down.is_empty();
+
+ match (up_zero, down_zero) {
+ (true, true) => "0".into(),
+ _ => format!("{up}:{down}"),
+ }
+}
+
+/// Check if rclone supports `--vfs-cache-min-free-space` (added in v1.65).
+///
+/// Runs `rclone mount --help` and checks for the flag in the output.
+/// Returns false if rclone is not found or the flag is absent.
+fn rclone_supports_min_free_space() -> bool {
+ std::process::Command::new("rclone")
+ .args(["mount", "--help"])
+ .output()
+ .map(|o| {
+ let stdout = String::from_utf8_lossy(&o.stdout);
+ stdout.contains("--vfs-cache-min-free-space")
+ })
+ .unwrap_or(false)
+}
+
+/// Build the rclone mount command as a string (for systemd ExecStart).
+pub fn build_mount_command(config: &Config) -> String {
+ let args = build_mount_args(config);
+ format!("/usr/bin/rclone {}", args.join(" "))
+}
+
+/// Check if the FUSE mount is currently active by inspecting `/proc/mounts`.
+pub fn is_mounted(config: &Config) -> Result {
+ let mount_point = config.mount.point.display().to_string();
+
+ let content = std::fs::read_to_string("/proc/mounts")
+ .with_context(|| "Failed to read /proc/mounts")?;
+
+ for line in content.lines() {
+ // /proc/mounts format: device mountpoint fstype options dump pass
+ let mut fields = line.split_whitespace();
+ let _device = fields.next();
+ if let Some(mp) = fields.next()
+ && mp == mount_point {
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+}
diff --git a/warpgate/src/rclone/rc.rs b/warpgate/src/rclone/rc.rs
new file mode 100644
index 0000000..7c9b6a0
--- /dev/null
+++ b/warpgate/src/rclone/rc.rs
@@ -0,0 +1,102 @@
+//! rclone RC (Remote Control) API client.
+//!
+//! rclone exposes an HTTP API on localhost when started with `--rc`.
+//! This module calls those endpoints for runtime status and control.
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+
+/// Default rclone RC API address.
+pub const RC_ADDR: &str = "http://127.0.0.1:5572";
+
+/// Response from `core/stats`.
+#[derive(Debug, Deserialize)]
+pub struct CoreStats {
+ pub bytes: u64,
+ pub speed: f64,
+ pub transfers: u64,
+ pub errors: u64,
+ #[serde(rename = "totalBytes")]
+ pub total_bytes: Option,
+}
+
+/// Response from `vfs/stats`.
+#[derive(Debug, Deserialize)]
+pub struct VfsStats {
+ #[serde(rename = "diskCache")]
+ pub disk_cache: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct DiskCacheStats {
+ #[serde(rename = "bytesUsed")]
+ pub bytes_used: u64,
+ #[serde(rename = "erroredFiles")]
+ pub errored_files: u64,
+ #[serde(rename = "uploadsInProgress")]
+ pub uploads_in_progress: u64,
+ #[serde(rename = "uploadsQueued")]
+ pub uploads_queued: u64,
+}
+
+/// Call `core/stats` — transfer statistics.
+pub fn core_stats() -> Result {
+ let stats: CoreStats = ureq::post(format!("{RC_ADDR}/core/stats"))
+ .send_json(serde_json::json!({}))?
+ .body_mut()
+ .read_json()
+ .context("Failed to parse core/stats response")?;
+ Ok(stats)
+}
+
+/// Call `vfs/stats` — VFS cache statistics.
+pub fn vfs_stats() -> Result {
+ let stats: VfsStats = ureq::post(format!("{RC_ADDR}/vfs/stats"))
+ .send_json(serde_json::json!({}))?
+ .body_mut()
+ .read_json()
+ .context("Failed to parse vfs/stats response")?;
+ Ok(stats)
+}
+
+/// Call `vfs/list` — list active VFS instances.
+pub fn vfs_list(dir: &str) -> Result {
+ let value: serde_json::Value = ureq::post(format!("{RC_ADDR}/vfs/list"))
+ .send_json(serde_json::json!({ "dir": dir }))?
+ .body_mut()
+ .read_json()
+ .context("Failed to parse vfs/list response")?;
+ Ok(value)
+}
+
+/// Call `vfs/forget` — force directory cache refresh.
+pub fn vfs_forget(dir: &str) -> Result<()> {
+ ureq::post(format!("{RC_ADDR}/vfs/forget"))
+ .send_json(serde_json::json!({ "dir": dir }))?;
+ Ok(())
+}
+
+/// Call `core/bwlimit` — get or set bandwidth limits.
+///
+/// If both `upload` and `download` are `None`, returns current limits.
+/// Otherwise sets new limits using rclone's `UP:DOWN` rate format.
+pub fn bwlimit(upload: Option<&str>, download: Option<&str>) -> Result {
+ let body = match (upload, download) {
+ (None, None) => serde_json::json!({}),
+ (up, down) => {
+ let rate = format!(
+ "{}:{}",
+ up.unwrap_or("off"),
+ down.unwrap_or("off"),
+ );
+ serde_json::json!({ "rate": rate })
+ }
+ };
+
+ let value: serde_json::Value = ureq::post(format!("{RC_ADDR}/core/bwlimit"))
+ .send_json(&body)?
+ .body_mut()
+ .read_json()
+ .context("Failed to parse core/bwlimit response")?;
+ Ok(value)
+}
diff --git a/warpgate/src/services/mod.rs b/warpgate/src/services/mod.rs
new file mode 100644
index 0000000..3c5f0ef
--- /dev/null
+++ b/warpgate/src/services/mod.rs
@@ -0,0 +1,4 @@
+pub mod nfs;
+pub mod samba;
+pub mod systemd;
+pub mod webdav;
diff --git a/warpgate/src/services/nfs.rs b/warpgate/src/services/nfs.rs
new file mode 100644
index 0000000..97ccf63
--- /dev/null
+++ b/warpgate/src/services/nfs.rs
@@ -0,0 +1,47 @@
+//! Generate NFS export configuration.
+
+use std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+
+/// Default output path for NFS exports.
+pub const EXPORTS_PATH: &str = "/etc/exports.d/warpgate.exports";
+
+/// Generate NFS exports entry for the FUSE mount point.
+///
+/// Produces a line like:
+/// ```text
+/// /mnt/nas-photos 192.168.0.0/24(rw,sync,no_subtree_check,fsid=1)
+/// ```
+/// `fsid=1` is required for FUSE-backed mounts because the kernel cannot
+/// derive a stable fsid from the device number.
+pub fn generate(config: &Config) -> Result {
+ let mount_point = config.mount.point.display();
+ let network = &config.protocols.nfs_allowed_network;
+
+ let line = format!(
+ "# Generated by Warpgate — do not edit manually.\n\
+ {mount_point} {network}(rw,sync,no_subtree_check,fsid=1)\n"
+ );
+
+ Ok(line)
+}
+
+/// Write exports file to disk.
+pub fn write_config(config: &Config) -> Result<()> {
+ let content = generate(config)?;
+
+ // Ensure /etc/exports.d/ exists
+ if let Some(parent) = Path::new(EXPORTS_PATH).parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
+ }
+
+ fs::write(EXPORTS_PATH, content)
+ .with_context(|| format!("Failed to write {EXPORTS_PATH}"))?;
+
+ Ok(())
+}
diff --git a/warpgate/src/services/samba.rs b/warpgate/src/services/samba.rs
new file mode 100644
index 0000000..7a3d12e
--- /dev/null
+++ b/warpgate/src/services/samba.rs
@@ -0,0 +1,72 @@
+//! Generate Samba (SMB) configuration.
+
+use std::fmt::Write as _;
+use std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+
+/// Default output path for generated smb.conf.
+pub const SMB_CONF_PATH: &str = "/etc/samba/smb.conf";
+
+/// Generate smb.conf content that shares the rclone FUSE mount point.
+pub fn generate(config: &Config) -> Result {
+ let mount_point = config.mount.point.display();
+
+ let mut conf = String::new();
+
+ // [global] section
+ writeln!(conf, "# Generated by Warpgate — do not edit manually.")?;
+ writeln!(conf, "[global]")?;
+ writeln!(conf, " workgroup = WORKGROUP")?;
+ writeln!(conf, " server string = Warpgate NAS Cache")?;
+ writeln!(conf, " server role = standalone server")?;
+ writeln!(conf)?;
+ writeln!(conf, " # Require SMB2+ (disable insecure SMB1)")?;
+ writeln!(conf, " server min protocol = SMB2_02")?;
+ writeln!(conf)?;
+ writeln!(conf, " # Guest / map-to-guest for simple setups")?;
+ writeln!(conf, " map to guest = Bad User")?;
+ writeln!(conf)?;
+ writeln!(conf, " # Logging")?;
+ writeln!(conf, " log file = /var/log/samba/log.%m")?;
+ writeln!(conf, " max log size = 1000")?;
+ writeln!(conf)?;
+ writeln!(conf, " # Disable printer sharing")?;
+ writeln!(conf, " load printers = no")?;
+ writeln!(conf, " printing = bsd")?;
+ writeln!(conf, " printcap name = /dev/null")?;
+ writeln!(conf, " disable spoolss = yes")?;
+ writeln!(conf)?;
+
+ // [nas-photos] share section
+ writeln!(conf, "[nas-photos]")?;
+ writeln!(conf, " comment = Warpgate cached NAS share")?;
+ writeln!(conf, " path = {mount_point}")?;
+ writeln!(conf, " browseable = yes")?;
+ writeln!(conf, " read only = no")?;
+ writeln!(conf, " guest ok = yes")?;
+ writeln!(conf, " force user = root")?;
+ writeln!(conf, " create mask = 0644")?;
+ writeln!(conf, " directory mask = 0755")?;
+
+ Ok(conf)
+}
+
+/// Write smb.conf to disk.
+pub fn write_config(config: &Config) -> Result<()> {
+ let content = generate(config)?;
+
+ // Ensure parent directory exists
+ if let Some(parent) = Path::new(SMB_CONF_PATH).parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
+ }
+
+ fs::write(SMB_CONF_PATH, content)
+ .with_context(|| format!("Failed to write {SMB_CONF_PATH}"))?;
+
+ Ok(())
+}
diff --git a/warpgate/src/services/systemd.rs b/warpgate/src/services/systemd.rs
new file mode 100644
index 0000000..5a3db77
--- /dev/null
+++ b/warpgate/src/services/systemd.rs
@@ -0,0 +1,78 @@
+//! Generate systemd unit files for Warpgate services.
+
+use std::fmt::Write as _;
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+
+/// Target directory for systemd unit files.
+pub const SYSTEMD_DIR: &str = "/etc/systemd/system";
+
+/// Single unified service unit name.
+pub const RUN_SERVICE: &str = "warpgate.service";
+
+/// Generate the single `warpgate.service` unit that runs `warpgate run`.
+///
+/// This replaces the old multi-unit approach. The `warpgate run` supervisor
+/// manages rclone mount, SMB, NFS, and WebDAV internally.
+pub fn generate_run_unit(_config: &Config) -> Result {
+ let mut unit = String::new();
+ writeln!(unit, "# Generated by Warpgate — do not edit manually.")?;
+ writeln!(unit, "[Unit]")?;
+ writeln!(unit, "Description=Warpgate NAS cache proxy")?;
+ writeln!(unit, "After=network-online.target")?;
+ writeln!(unit, "Wants=network-online.target")?;
+ writeln!(unit)?;
+ writeln!(unit, "[Service]")?;
+ writeln!(unit, "Type=simple")?;
+ writeln!(unit, "ExecStart=/usr/local/bin/warpgate run")?;
+ writeln!(unit, "Restart=on-failure")?;
+ writeln!(unit, "RestartSec=10")?;
+ writeln!(unit, "KillMode=mixed")?;
+ writeln!(unit, "TimeoutStopSec=30")?;
+ writeln!(unit)?;
+ writeln!(unit, "[Install]")?;
+ writeln!(unit, "WantedBy=multi-user.target")?;
+
+ Ok(unit)
+}
+
+/// Install the single `warpgate.service` unit and reload systemd.
+pub fn install_run_unit(config: &Config) -> Result<()> {
+ let systemd_dir = Path::new(SYSTEMD_DIR);
+
+ let unit_content = generate_run_unit(config)?;
+ fs::write(systemd_dir.join(RUN_SERVICE), unit_content)
+ .with_context(|| format!("Failed to write {RUN_SERVICE}"))?;
+
+ // Reload systemd daemon
+ let status = Command::new("systemctl")
+ .arg("daemon-reload")
+ .status()
+ .context("Failed to run systemctl daemon-reload")?;
+
+ if !status.success() {
+ anyhow::bail!("systemctl daemon-reload failed with exit code: {}", status);
+ }
+
+ Ok(())
+}
+
+/// Enable and start the single `warpgate.service`.
+pub fn enable_and_start_run() -> Result<()> {
+ let status = Command::new("systemctl")
+ .args(["enable", "--now", RUN_SERVICE])
+ .status()
+ .with_context(|| format!("Failed to run systemctl enable --now {RUN_SERVICE}"))?;
+
+ if !status.success() {
+ anyhow::bail!("systemctl enable --now {RUN_SERVICE} failed with exit code: {status}");
+ }
+
+ Ok(())
+}
+
diff --git a/warpgate/src/services/webdav.rs b/warpgate/src/services/webdav.rs
new file mode 100644
index 0000000..fbed3a7
--- /dev/null
+++ b/warpgate/src/services/webdav.rs
@@ -0,0 +1,24 @@
+//! WebDAV service management via rclone serve webdav.
+
+use crate::config::Config;
+
+/// Build the `rclone serve webdav` command arguments.
+pub fn build_serve_args(config: &Config) -> Vec {
+ let mount_point = config.mount.point.display().to_string();
+ let addr = format!("0.0.0.0:{}", config.protocols.webdav_port);
+
+ vec![
+ "serve".into(),
+ "webdav".into(),
+ mount_point,
+ "--addr".into(),
+ addr,
+ "--read-only=false".into(),
+ ]
+}
+
+/// Build the full command string (for systemd ExecStart).
+pub fn build_serve_command(config: &Config) -> String {
+ let args = build_serve_args(config);
+ format!("/usr/bin/rclone {}", args.join(" "))
+}
diff --git a/warpgate/src/supervisor.rs b/warpgate/src/supervisor.rs
new file mode 100644
index 0000000..f3efdd4
--- /dev/null
+++ b/warpgate/src/supervisor.rs
@@ -0,0 +1,420 @@
+//! `warpgate run` — single-process supervisor for all services.
+//!
+//! Manages rclone mount + protocol services in one process tree with
+//! coordinated startup and shutdown. Designed to run as a systemd unit
+//! or standalone (Docker-friendly).
+
+use std::process::{Child, Command};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::thread;
+use std::time::{Duration, Instant};
+
+use anyhow::{Context, Result};
+
+use crate::config::Config;
+use crate::rclone::mount::{build_mount_args, is_mounted};
+use crate::services::{nfs, samba, webdav};
+
+/// Mount ready timeout.
+const MOUNT_TIMEOUT: Duration = Duration::from_secs(30);
+/// Supervision loop poll interval.
+const POLL_INTERVAL: Duration = Duration::from_secs(2);
+/// Grace period for SIGTERM before escalating to SIGKILL.
+const SIGTERM_GRACE: Duration = Duration::from_secs(3);
+/// Max restart attempts before giving up on a protocol service.
+const MAX_RESTARTS: u32 = 3;
+/// Reset restart counter after this period of stable running.
+const RESTART_STABLE_PERIOD: Duration = Duration::from_secs(300);
+
+/// Tracks restart attempts for a supervised child process.
+struct RestartTracker {
+ count: u32,
+ last_restart: Option,
+}
+
+impl RestartTracker {
+ fn new() -> Self {
+ Self {
+ count: 0,
+ last_restart: None,
+ }
+ }
+
+ /// Returns true if another restart is allowed. Resets counter if the
+ /// service has been stable for `RESTART_STABLE_PERIOD`.
+ fn can_restart(&mut self) -> bool {
+ if let Some(last) = self.last_restart
+ && last.elapsed() >= RESTART_STABLE_PERIOD
+ {
+ self.count = 0;
+ }
+ self.count < MAX_RESTARTS
+ }
+
+ fn record_restart(&mut self) {
+ self.count += 1;
+ self.last_restart = Some(Instant::now());
+ }
+}
+
+/// Child processes for protocol servers managed by the supervisor.
+///
+/// Implements `Drop` to kill any spawned children — prevents orphaned
+/// processes if startup fails partway through `start_protocols()`.
+struct ProtocolChildren {
+ smbd: Option,
+ webdav: Option,
+}
+
+impl Drop for ProtocolChildren {
+ fn drop(&mut self) {
+ for child in [&mut self.smbd, &mut self.webdav].into_iter().flatten() {
+ graceful_kill(child);
+ }
+ }
+}
+
+/// Entry point — called from main.rs for `warpgate run`.
+pub fn run(config: &Config) -> Result<()> {
+ let shutdown = Arc::new(AtomicBool::new(false));
+
+ // Install signal handler (SIGTERM + SIGINT)
+ let shutdown_flag = Arc::clone(&shutdown);
+ ctrlc::set_handler(move || {
+ eprintln!("Signal received, shutting down...");
+ shutdown_flag.store(true, Ordering::SeqCst);
+ })
+ .context("Failed to set signal handler")?;
+
+ // Phase 1: Preflight — generate configs, create dirs
+ println!("Preflight checks...");
+ preflight(config)?;
+
+ // Phase 2: Start rclone mount and wait for it to become ready
+ println!("Starting rclone mount...");
+ let mut mount_child = start_and_wait_mount(config, &shutdown)?;
+ println!("Mount ready at {}", config.mount.point.display());
+
+ // Phase 3: Start protocol services
+ if shutdown.load(Ordering::SeqCst) {
+ println!("Shutdown signal received during mount.");
+ let _ = mount_child.kill();
+ let _ = mount_child.wait();
+ return Ok(());
+ }
+ println!("Starting protocol services...");
+ let mut protocols = start_protocols(config)?;
+
+ // Phase 4: Supervision loop
+ println!("Supervision active. Press Ctrl+C to stop.");
+ let result = supervise(config, &mut mount_child, &mut protocols, Arc::clone(&shutdown));
+
+ // Phase 5: Teardown (always runs)
+ println!("Shutting down...");
+ shutdown_services(config, &mut mount_child, &mut protocols);
+
+ result
+}
+
+/// Write configs and create directories. Reuses existing modules.
+fn preflight(config: &Config) -> Result<()> {
+ // Ensure mount point exists
+ std::fs::create_dir_all(&config.mount.point).with_context(|| {
+ format!(
+ "Failed to create mount point: {}",
+ config.mount.point.display()
+ )
+ })?;
+
+ // Ensure cache directory exists
+ std::fs::create_dir_all(&config.cache.dir).with_context(|| {
+ format!(
+ "Failed to create cache dir: {}",
+ config.cache.dir.display()
+ )
+ })?;
+
+ // Generate rclone config
+ crate::rclone::config::write_config(config)?;
+
+ // Generate protocol configs
+ if config.protocols.enable_smb {
+ samba::write_config(config)?;
+ }
+ if config.protocols.enable_nfs {
+ nfs::write_config(config)?;
+ }
+
+ Ok(())
+}
+
+/// Spawn rclone mount process and poll until the FUSE mount appears.
+fn start_and_wait_mount(config: &Config, shutdown: &AtomicBool) -> Result {
+ let args = build_mount_args(config);
+
+ let mut child = Command::new("rclone")
+ .args(&args)
+ .spawn()
+ .context("Failed to spawn rclone mount")?;
+
+ // Poll for mount readiness
+ let deadline = Instant::now() + MOUNT_TIMEOUT;
+ loop {
+ // Check for shutdown signal (e.g. Ctrl+C during mount wait)
+ if shutdown.load(Ordering::SeqCst) {
+ let _ = child.kill();
+ let _ = child.wait();
+ anyhow::bail!("Interrupted while waiting for mount");
+ }
+
+ if Instant::now() > deadline {
+ let _ = child.kill();
+ let _ = child.wait();
+ anyhow::bail!(
+ "Timed out waiting for mount at {} ({}s)",
+ config.mount.point.display(),
+ MOUNT_TIMEOUT.as_secs()
+ );
+ }
+
+ // Detect early rclone exit (e.g. bad config, auth failure)
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ anyhow::bail!("rclone mount exited immediately ({status}). Check remote/auth config.");
+ }
+ Ok(None) => {} // still running, good
+ Err(e) => {
+ anyhow::bail!("Failed to check rclone mount status: {e}");
+ }
+ }
+
+ match is_mounted(config) {
+ Ok(true) => break,
+ Ok(false) => {}
+ Err(e) => eprintln!("Warning: mount check failed: {e}"),
+ }
+
+ thread::sleep(Duration::from_millis(500));
+ }
+
+ Ok(child)
+}
+
+/// Spawn smbd as a foreground child process.
+fn spawn_smbd() -> Result {
+ Command::new("smbd")
+ .args(["-F", "-S", "-N", "-s", samba::SMB_CONF_PATH])
+ .spawn()
+ .context("Failed to spawn smbd")
+}
+
+/// Start protocol services after the mount is ready.
+///
+/// - SMB: spawn `smbd -F` as a child process
+/// - NFS: `exportfs -ra`
+/// - WebDAV: spawn `rclone serve webdav` as a child process
+fn start_protocols(config: &Config) -> Result {
+ let smbd = if config.protocols.enable_smb {
+ let child = spawn_smbd()?;
+ println!(" SMB: started");
+ Some(child)
+ } else {
+ None
+ };
+
+ if config.protocols.enable_nfs {
+ let status = Command::new("exportfs")
+ .arg("-ra")
+ .status()
+ .context("Failed to run exportfs -ra")?;
+ if !status.success() {
+ anyhow::bail!("exportfs -ra failed: {status}");
+ }
+ println!(" NFS: exported");
+ }
+
+ let webdav = if config.protocols.enable_webdav {
+ let child = spawn_webdav(config)?;
+ println!(" WebDAV: started");
+ Some(child)
+ } else {
+ None
+ };
+
+ Ok(ProtocolChildren { smbd, webdav })
+}
+
+/// Spawn a `rclone serve webdav` child process.
+fn spawn_webdav(config: &Config) -> Result {
+ let args = webdav::build_serve_args(config);
+ Command::new("rclone")
+ .args(&args)
+ .spawn()
+ .context("Failed to spawn rclone serve webdav")
+}
+
+/// Main supervision loop. Polls child processes every 2s.
+///
+/// - If rclone mount dies → full shutdown (data safety: dirty files may be in flight).
+/// - If smbd/WebDAV dies → restart up to 3 times (counter resets after 5 min stable).
+/// - Checks shutdown flag set by signal handler.
+fn supervise(
+ config: &Config,
+ mount: &mut Child,
+ protocols: &mut ProtocolChildren,
+ shutdown: Arc,
+) -> Result<()> {
+ let mut smbd_tracker = RestartTracker::new();
+ let mut webdav_tracker = RestartTracker::new();
+
+ loop {
+ // Check for shutdown signal
+ if shutdown.load(Ordering::SeqCst) {
+ println!("Shutdown signal received.");
+ return Ok(());
+ }
+
+ // Check rclone mount process
+ match mount.try_wait() {
+ Ok(Some(status)) => {
+ anyhow::bail!(
+ "rclone mount exited unexpectedly ({}). Initiating full shutdown for data safety.",
+ status
+ );
+ }
+ Ok(None) => {} // still running
+ Err(e) => {
+ anyhow::bail!("Failed to check rclone mount status: {e}");
+ }
+ }
+
+ // Check smbd process (if enabled)
+ if let Some(child) = &mut protocols.smbd {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ eprintln!("smbd exited ({status}).");
+ if smbd_tracker.can_restart() {
+ smbd_tracker.record_restart();
+ let delay = smbd_tracker.count * 2;
+ eprintln!(
+ "Restarting smbd in {delay}s ({}/{MAX_RESTARTS})...",
+ smbd_tracker.count,
+ );
+ thread::sleep(Duration::from_secs(delay.into()));
+ match spawn_smbd() {
+ Ok(new_child) => *child = new_child,
+ Err(e) => {
+ eprintln!("Failed to restart smbd: {e}");
+ protocols.smbd = None;
+ }
+ }
+ } else {
+ eprintln!(
+ "smbd exceeded max restarts ({MAX_RESTARTS}), giving up."
+ );
+ protocols.smbd = None;
+ }
+ }
+ Ok(None) => {} // still running
+ Err(e) => eprintln!("Warning: failed to check smbd status: {e}"),
+ }
+ }
+
+ // Check WebDAV process (if enabled)
+ if let Some(child) = &mut protocols.webdav {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ eprintln!("WebDAV exited ({status}).");
+ if webdav_tracker.can_restart() {
+ webdav_tracker.record_restart();
+ let delay = webdav_tracker.count * 2;
+ eprintln!(
+ "Restarting WebDAV in {delay}s ({}/{MAX_RESTARTS})...",
+ webdav_tracker.count,
+ );
+ thread::sleep(Duration::from_secs(delay.into()));
+ match spawn_webdav(config) {
+ Ok(new_child) => *child = new_child,
+ Err(e) => {
+ eprintln!("Failed to restart WebDAV: {e}");
+ protocols.webdav = None;
+ }
+ }
+ } else {
+ eprintln!(
+ "WebDAV exceeded max restarts ({MAX_RESTARTS}), giving up."
+ );
+ protocols.webdav = None;
+ }
+ }
+ Ok(None) => {} // still running
+ Err(e) => eprintln!("Warning: failed to check WebDAV status: {e}"),
+ }
+ }
+
+ thread::sleep(POLL_INTERVAL);
+ }
+}
+
+/// Send SIGTERM, wait up to `SIGTERM_GRACE`, then SIGKILL if still alive.
+///
+/// smbd forks worker processes per client connection — SIGTERM lets
+/// the parent signal its children to exit cleanly. SIGKILL would
+/// orphan those workers.
+fn graceful_kill(child: &mut Child) {
+ let pid = child.id() as i32;
+ // SAFETY: sending a signal to a known child PID is safe.
+ unsafe { libc::kill(pid, libc::SIGTERM) };
+
+ let deadline = Instant::now() + SIGTERM_GRACE;
+ loop {
+ match child.try_wait() {
+ Ok(Some(_)) => return, // exited cleanly
+ Ok(None) => {}
+ Err(_) => break,
+ }
+ if Instant::now() > deadline {
+ break;
+ }
+ thread::sleep(Duration::from_millis(100));
+ }
+
+ // Still alive after grace period — escalate
+ let _ = child.kill(); // SIGKILL
+ let _ = child.wait();
+}
+
+/// Reverse-order teardown of all services.
+///
+/// Order: stop smbd → unexport NFS → kill WebDAV → unmount FUSE → kill rclone.
+fn shutdown_services(config: &Config, mount: &mut Child, protocols: &mut ProtocolChildren) {
+ // Stop SMB
+ if let Some(child) = &mut protocols.smbd {
+ graceful_kill(child);
+ println!(" SMB: stopped");
+ }
+
+ // Unexport NFS
+ if config.protocols.enable_nfs {
+ let _ = Command::new("exportfs").arg("-ua").status();
+ println!(" NFS: unexported");
+ }
+
+ // Kill WebDAV
+ if let Some(child) = &mut protocols.webdav {
+ graceful_kill(child);
+ println!(" WebDAV: stopped");
+ }
+
+ // Lazy unmount FUSE
+ let mount_point = config.mount.point.display().to_string();
+ let _ = Command::new("fusermount")
+ .args(["-uz", &mount_point])
+ .status();
+ println!(" FUSE: unmounted");
+
+ // Gracefully stop rclone
+ graceful_kill(mount);
+ println!(" rclone: stopped");
+}
diff --git a/warpgate/templates/config.toml.default b/warpgate/templates/config.toml.default
new file mode 100644
index 0000000..e679fad
--- /dev/null
+++ b/warpgate/templates/config.toml.default
@@ -0,0 +1,72 @@
+# Warpgate Configuration
+# See: https://github.com/user/warpgate for documentation
+
+[connection]
+# Remote NAS Tailscale IP or hostname
+nas_host = "100.x.x.x"
+# SFTP username
+nas_user = "admin"
+# SFTP password (prefer key_file for security)
+# nas_pass = "your-password"
+# Path to SSH private key (recommended)
+# nas_key_file = "/root/.ssh/id_ed25519"
+# Target directory on NAS
+remote_path = "/volume1/photos"
+# SFTP port
+sftp_port = 22
+# SFTP connection pool size
+sftp_connections = 8
+
+[cache]
+# Cache storage directory (should be on SSD, prefer btrfs/ZFS filesystem)
+dir = "/mnt/ssd/warpgate"
+# Max cache size (leave room for dirty files during offline writes)
+max_size = "200G"
+# Max cache retention time
+max_age = "720h"
+# Minimum free space on cache disk
+min_free = "10G"
+
+[read]
+# Chunk size for large file reads
+chunk_size = "256M"
+# Max chunk auto-growth limit
+chunk_limit = "1G"
+# Read-ahead buffer for sequential reads
+read_ahead = "512M"
+# In-memory buffer size
+buffer_size = "256M"
+
+[bandwidth]
+# Upload (write-back) speed limit ("0" = unlimited)
+limit_up = "0"
+# Download (cache pull) speed limit ("0" = unlimited)
+limit_down = "0"
+# Enable adaptive write-back throttling (auto-reduce on congestion)
+adaptive = true
+
+[writeback]
+# Delay before async write-back to NAS
+write_back = "5s"
+# Concurrent upload transfers
+transfers = 4
+
+[directory_cache]
+# Directory listing cache TTL (lower = faster remote change detection)
+cache_time = "1h"
+
+[protocols]
+# Enable SMB (Samba) sharing — primary for macOS/Windows
+enable_smb = true
+# Enable NFS export — for Linux clients
+enable_nfs = false
+# Enable WebDAV service — for mobile clients
+enable_webdav = false
+# NFS allowed network CIDR
+nfs_allowed_network = "192.168.0.0/24"
+# WebDAV listen port
+webdav_port = 8080
+
+[mount]
+# FUSE mount point (all protocols share this)
+point = "/mnt/nas-photos"