Skip to content

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 }.

  1. WebView2 fetch → 127.0.0.1:7878, Authorization: Bearer <token>.
  2. axum routes to transfer_upload.
  3. Middleware auth::require_token checks the header.
  4. Handler reads local file via tokio::fs, stores TransferJob with status: running, emits transfer.started WS event.
  5. 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))).
  6. On success, updates TransferJob status: complete, emits remote.changed + transfer.completed events, logs.
  7. 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:

  1. 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.
  2. PKCE conditional: ProviderConfig.pkce: bool. Google + Microsoft send code_challenge (PKCE recommended for installed apps). Dropbox omits PKCE when Allow public clients is off on the app (default for confidential clients) — sending PKCE there returns a misleading invalid_response_type.
  3. Confidential clients also send client_secret on token exchange + refresh. Threaded through OauthSignInArgsexchange_code_for_token → stored on StoredToken so subsequent refreshes don't need the secret re-passed.
  4. Storage: StoredToken persisted to OS keychain under service ai.opensentinel.ftproxy.oauth, keyed by the SavedSite UUID. save() strips access_token before 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.
  5. 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 as info.password to the transport.
  6. Loopback callback: start_callback_listener binds on port 53682 (gcloud convention; falls back to a random port). Redirect URI uses localhost (Dropbox treats http://localhost as a wildcard for any port; Google + Microsoft honor loopback wildcards; 127.0.0.1 is treated literally so we use localhost).

Local cloud-sync detection

places::cloud_sync_folders() finds installed desktop clients on multiple OSes:

  • Dropbox (Win/Mac/Linux): reads info.json from %LOCALAPPDATA%\Dropbox\ / %APPDATA%\Dropbox\ / ~/.dropbox/, parses personal.path and business.path. Survives custom folder locations.
  • OneDrive (Win): reads registry HKCU\Software\Microsoft\OneDrive\Accounts\*\UserFolder via winreg crate. Picks up Personal + every Business account at their actual paths (with DisplayName for 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: )" pre-selected when found, or falls back to OAuth path when not. Save handler transforms 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_subscriber registered at run() — default env filter info,russh=warn,suppaftp=warn, override with RUST_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 as log WS events and exposed via GET /logs. Capped at 1000 entries.
  • Tauri-plugin-log is deliberately not installed — it fights with tracing-subscriber for the global log facade. See tasks/lessons.md.