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"