Implementation¶
Architecture and internal design of FTProxy.
Top-level shape¶
┌────────────────────────────────────────────────────────┐
│ Tauri 2 shell (main.rs / lib.rs) │
│ ├─ Webview (WebView2 on Windows) loads ../dist/*.html │
│ └─ Spawns a dedicated tokio runtime thread that runs │
│ the axum bridge │
│ │
│ Inside the tokio runtime: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ axum Router (bridge.rs) │ │
│ │ - bearer-token middleware (auth.rs) │ │
│ │ - CORS middleware │ │
│ │ - route handlers → SharedState │ │
│ │ │ │
│ │ SharedState = Arc<AppState> (state.rs) │ │
│ │ - SessionInfo (RwLock) │ │
│ │ - Box<dyn Transport> (Mutex) │ │
│ │ - TransferJob list (RwLock) │ │
│ │ - SavedSite list (RwLock) │ │
│ │ - LogEntry ring (RwLock) │ │
│ │ - broadcast::Sender<Value> (WS events) │ │
│ │ - Bearer token (String) │ │
│ │ - Paths (data dir, token file, etc.) │ │
│ │ - port (OnceLock<u16>) │ │
│ │ │ │
│ │ Transport trait (transport/mod.rs) │ │
│ │ ├─ SftpTransport (russh + russh-sftp) │ │
│ │ └─ FtpTransport (suppaftp async-tokio) │ │
│ │ │ │
│ │ localfs.rs — OS-side list/mkdir/rename/delete │ │
│ │ persist.rs — saved-sites JSON store │ │
│ │ credentials.rs — OS keychain via `keyring` │ │
│ │ events.rs — broadcast helper │ │
│ │ errors.rs — ApiError → IntoResponse │ │
│ │ config.rs — app data paths + token bootstrap │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Tauri commands (lib.rs): │
│ - bridge_token() — returns bearer token │
│ - bridge_url() — returns REST URL │
│ - bridge_ws_url() — returns WS URL │
└────────────────────────────────────────────────────────┘
Source tree¶
FTP/
├─ dist/ # frontend (embedded at compile time)
│ ├─ index.html # FileZilla-style layout
│ ├─ styles.css # classic-Windows skin
│ └─ app.js # event handling, tree views, API client
├─ src-tauri/
│ ├─ Cargo.toml # crate + feature flags
│ ├─ tauri.conf.json # window config, dragDropEnabled=false
│ ├─ capabilities/default.json
│ └─ src/
│ ├─ main.rs # #![windows_subsystem]; calls lib::run()
│ ├─ lib.rs # Tauri setup; spawns bridge runtime
│ ├─ bridge.rs # axum router (40 routes) + handlers
│ ├─ oauth.rs # OAuth2 + PKCE flow for Drive / OneDrive
│ ├─ bin/
│ │ ├─ ftproxy-bridge.rs # headless bridge (CI / embedded)
│ │ └─ ftproxy-mcp.rs # MCP stdio server wrapping the bridge
│ └─ transport/
│ ├─ sftp.rs # russh + russh-sftp (password OR key auth)
│ ├─ ftp.rs # suppaftp (plain FTP)
│ ├─ ftps.rs # suppaftp + futures-rustls (Explicit + Implicit)
│ ├─ webdav.rs / s3.rs / azure.rs / gcs.rs
│ ├─ dropbox.rs / gdrive.rs / onedrive.rs
│ ├─ state.rs # SessionInfo, TransferJob, AppState
│ ├─ errors.rs # ApiError + IntoResponse
│ ├─ auth.rs # bearer-token middleware
│ ├─ events.rs # WS event emission helper
│ ├─ config.rs # Paths + ensure_token
│ ├─ credentials.rs # `keyring` wrapper (Entry get/set/remove)
│ ├─ persist.rs # saved-sites JSON load/save
│ ├─ localfs.rs # local FS list/mkdir/rename/delete
│ └─ transport/
│ ├─ mod.rs # Transport trait, RemoteEntry, helpers
│ ├─ sftp.rs # russh + russh-sftp impl
│ └─ ftp.rs # suppaftp async-tokio impl
├─ scripts/
│ └─ test-endpoints.ps1 # 25 end-to-end endpoint assertions
├─ tasks/
│ ├─ memory.md # project memory / guidance
│ ├─ todo.md # next pass notes
│ └─ lessons.md # gotchas from this session
├─ API.md # endpoint reference
├─ CHANGELOG.md # release history
├─ CLAUDE.md # conventions for AI-assisted edits
├─ INTEGRATION.md # OpenSentinel integration guide
├─ IMPLEMENTATION.md # this file
└─ README.md # top-level overview
Request lifecycle¶
Example: POST /transfers/upload { localPath, remotePath }.
- WebView2 fetch → 127.0.0.1:7878,
Authorization: Bearer <token>. - axum routes to
transfer_upload. - Middleware
auth::require_tokenchecks the header. - Handler reads local file via
tokio::fs, storesTransferJobwithstatus: running, emitstransfer.startedWS event. - Acquires
state.transport.lock().await, calls.upload(dest, buf)on the active transport (SFTP:open_with_flags(CREATE|WRITE|TRUNCATE)→write_all; FTP:put_file(dest, &mut Cursor::new(buf))). - On success, updates TransferJob
status: complete, emitsremote.changed+transfer.completedevents, logs. - Returns JSON envelope.
The UI subscribes to the WS stream; transfer.completed triggers
refreshRemote(), which re-fetches /files/remote?path=....
Keyed design choices¶
Transport trait¶
Transport: Send + Sync with async_trait methods. The bridge holds at
most one Box<dyn Transport> per session slot. Adding a new protocol
means dropping a new struct into transport/ and wiring it in
post_connect's match. The rest of the router doesn't know the protocol.
Currently 12 transports plus a special-case translator:
| File | Protocol(s) | Notes |
|---|---|---|
sftp.rs |
sftp |
russh + russh-sftp (password / key) |
ftp.rs |
ftp |
suppaftp (plain) |
ftps.rs |
ftps |
suppaftp + futures-rustls (Explicit + Implicit) |
webdav.rs |
webdav |
reqwest |
smb.rs |
smb |
OS-native SMB client (net use / mount_smbfs / mount -t cifs) |
s3.rs |
s3 |
aws-sdk-s3, multipart resume |
azure.rs |
azure |
azure_storage_blobs |
gcs.rs |
gcs |
raw REST + service-account JWT |
dropbox.rs |
dropbox |
reqwest + Dropbox v2 API; bridge auto-injects fresh access_token via oauth::refresh_if_needed |
gdrive.rs |
gdrive |
reqwest + Drive v3; native OAuth refresh in transport |
onedrive.rs |
onedrive |
reqwest + MS Graph; native OAuth refresh in transport |
localcloud.rs |
dropbox-local, onedrive-local, gdrive-local, icloud-local, localcloud |
filesystem-backed wrapper around the desktop-client sync folder; extra.local_root + path-traversal sandbox |
| (translation only) | azure-files |
bridge post_connect rewrites account/share/access_key extras into SMB UNC + Azure\<account> username, then dispatches to smb.rs |
OAuth refresh-token model¶
oauth.rs is the single OAuth + PKCE flow shared by Dropbox / Google
Drive / OneDrive (and any future provider added to provider_config).
The flow:
- Per-user app: each user registers their own OAuth app at the provider's dev console (one-time). FTProxy doesn't ship bundled client IDs — keeps liability/quota/verification per-user.
- PKCE conditional:
ProviderConfig.pkce: bool. Google + Microsoft sendcode_challenge(PKCE recommended for installed apps). Dropbox omits PKCE whenAllow public clientsis off on the app (default for confidential clients) — sending PKCE there returns a misleadinginvalid_response_type. - Confidential clients also send
client_secreton token exchange + refresh. Threaded throughOauthSignInArgs→exchange_code_for_token→ stored onStoredTokenso subsequent refreshes don't need the secret re-passed. - Storage:
StoredTokenpersisted to OS keychain under serviceai.opensentinel.ftproxy.oauth, keyed by the SavedSite UUID.save()stripsaccess_tokenbefore persisting (Windows Credential Manager has a 2560-char attribute limit; Dropbox's ~1331-char short-lived tokens become 2662 bytes UTF-16, over the limit). Only the small refresh_token + client_id/secret/expires_at lives on disk. - Refresh-on-demand:
oauth::refresh_if_needed()is called by the bridge before every cloud-API connect. Refreshes when access_token is empty (just loaded from disk) or within 60s of expiry. Uses the stored refresh_token + client_id + (optional) client_secret to mint a fresh access_token, persists the rotation, returns the fresh token. Bridge passes it asinfo.passwordto the transport. - Loopback callback:
start_callback_listenerbinds on port 53682 (gcloud convention; falls back to a random port). Redirect URI useslocalhost(Dropbox treatshttp://localhostas a wildcard for any port; Google + Microsoft honor loopback wildcards;127.0.0.1is treated literally so we uselocalhost).
Local cloud-sync detection¶
places::cloud_sync_folders() finds installed desktop clients on
multiple OSes:
- Dropbox (Win/Mac/Linux): reads
info.jsonfrom%LOCALAPPDATA%\Dropbox\/%APPDATA%\Dropbox\/~/.dropbox/, parsespersonal.pathandbusiness.path. Survives custom folder locations. - OneDrive (Win): reads registry
HKCU\Software\Microsoft\OneDrive\Accounts\*\UserFolderviawinregcrate. Picks up Personal + every Business account at their actual paths (withDisplayNamefor friendly Business labels). - Google Drive for Desktop (Win): scans drive letters for
<drive>:\My Drive. Works for Streaming or Mirror mode regardless of drive letter chosen by the user. Empty volume label is fine. - macOS: scans
~/Library/CloudStorage/*(Apple's modern API for any cloud-sync vendor) plus iCloud Drive at~/Library/Mobile Documents/com~apple~CloudDocs.
The Site Form pre-fetches detection results on open via the
list_cloud_sync_folders Tauri command, then for Dropbox / GDrive /
OneDrive sites renders a mode toggle: "Use local sync (auto-detected:
protocol: "dropbox" to "dropbox-local"
with extras.local_root when local mode is selected.
Uploads/downloads¶
Vec<u8> based (not streaming). Keeps the trait simple and works for
typical FTP payloads. Large-file streaming would be an upgrade: add
download_to<W: AsyncWrite>(...) / upload_from<R: AsyncRead>(...)
variants rather than changing the existing ones.
Auth¶
48-char random bearer token written to <data dir>/token on first launch.
All endpoints except /health require Authorization: Bearer <token>.
WebSocket uses ?token=<token> query param because browsers do not let
you set headers on the WS handshake.
Credentials¶
Per-site passwords live in the OS keychain under service
ai.opensentinel.ftproxy, keyed by the SavedSite UUID. The bridge reads
it lazily — the UI only sends siteId on /session/connect and the
bridge pulls the password when it's time to authenticate.
Port binding¶
Tries FTPROXY_PORT env var (or API_PORT_PREFERRED = 7878), falls back
to an OS-assigned ephemeral port if busy. Actual port is stored in
AppState.port (OnceLock) and surfaced via bridge_url Tauri command
and the bridge.url discovery file.
Tauri drag-drop¶
app.windows[].dragDropEnabled = false in tauri.conf.json. Tauri's
default (true) intercepts OS drop events at the native window layer,
which blocks the webview's HTML5 drag-drop from firing. Turning it off
lets WebView2 handle both OS file drops and in-app row drags natively.
Frontend routing¶
All state is plain JS — no framework. Custom tiny el() factory for
DOM construction. $() is getElementById. Drag/drop uses both HTML5
dataTransfer and a window.__ftpDrag JS variable because WebView2
strips custom MIME types.
WebSocket event taxonomy¶
| Event | Data shape | Who emits |
|---|---|---|
hello |
bridge service name | on WS connect |
session.changed |
{ connected } |
connect/disconnect handlers |
remote.changed |
{ path } |
after any remote mkdir/rename/delete/upload |
transfer.started/completed/failed |
full TransferJob |
transfer handlers |
sites.changed |
full sites snapshot | sites CRUD handlers |
log |
LogEntry |
every AppState::push_log |
Data locations on Windows¶
| What | Where |
|---|---|
| Token | %APPDATA%\OpenSentinel\FTProxy\data\token |
| Saved sites | %APPDATA%\OpenSentinel\FTProxy\data\sites.json |
| Bridge URL | %APPDATA%\OpenSentinel\FTProxy\data\bridge.url |
| Passwords | Windows Credential Manager (service ai.opensentinel.ftproxy) |
| Known hosts | %APPDATA%\OpenSentinel\FTProxy\data\known_hosts (allocated; TOFU today) |
Build + run¶
npm run check # cargo check on the Rust crate
npm run tauri:dev # cargo tauri dev — full hot-reload loop
npm run tauri:build # release bundle
cargo run --manifest-path src-tauri/Cargo.toml # dev without tauri-cli
Rebuild the binary after editing dist/* — Tauri's
tauri::generate_context! embeds the frontend at compile time. If you
see a linker Access is denied, a previous ftproxy.exe is still
running; close it or Stop-Process ftproxy -Force.
Testing¶
Two layers today:
Unit tests — cargo test --lib¶
45 tests, all green, no external network. Coverage by module:
| Module | What's tested |
|---|---|
auth |
constant_eq equal / different / length-mismatch |
bridge |
normalize_protocol + normalize_remote, /health public + 200, bearer gate (missing/wrong/valid), /transfers empty list, /sites CRUD round-trip, /logs echo, /transfers/download when not connected → 409 not_connected |
config |
ensure_token creates a 48-char token, is stable, rewrites if empty |
errors |
Each ApiError variant maps to the right HTTP status; std::io::Error and anyhow::Error conversions |
localfs |
mkdir nested, list sorts dirs first, rename + delete round-trip, recursive delete, parent_of + join path normalization, missing path returns empty |
persist |
load missing → empty, save/load round-trip, invalid JSON → empty |
state |
SessionInfo::default sanity, push_log appends + broadcasts, 1000-entry cap, base_url / ws_url derive from bound port |
transport |
join_remote, remote_parent, sort_entries (dirs first, case-insensitive name) |
Integration-style handler tests use tower::ServiceExt::oneshot against
the real build_router — no live HTTP server, but the full middleware
stack (auth + CORS + trace + body limit) runs for each call.
End-to-end — scripts/test-endpoints.ps1¶
25 assertions against a live SFTP server via the saved GoGreenSuites site. Exercises: auth gate, session lifecycle, sites CRUD with keychain, local + remote FS ops, raw GET/PUT, upload/download round-trip, transfers list/clear, logs. All 25 pass.
Future additions: docker-compose target with atmoz/sftp +
stilliard/pure-ftpd so the end-to-end suite can run in CI without an
external host.
Logging¶
tracing_subscriberregistered atrun()— default env filterinfo,russh=warn,suppaftp=warn, override withRUST_LOG=....tower_http::TraceLayer::new_for_http()wraps the router — every request gets a span with method, URI, status, and duration.#[tracing::instrument]on key async handlers:post_connect,transfer_upload,transfer_download. Fields: protocol/host/port/ user for connects; local/remote paths for transfers.- User-visible log entries (
AppState::push_log) are broadcast aslogWS events and exposed viaGET /logs. Capped at 1000 entries. - Tauri-plugin-log is deliberately not installed — it fights with
tracing-subscriber for the global
logfacade. Seetasks/lessons.md.