Add Spacedrive server with embedded daemon

- Introduce an Axum-based HTTP server with an embedded daemon and a
JSON-RPC proxy to the daemon via a Unix socket - Bundle web UI assets
into the server with an assets feature and a build.rs that builds the
frontend using pnpm - Add multi-stage Dockerfile, docker-compose.yml,
and a Distroless runtime image - Provide TrueNAS deployment support with
a build script and setup guide - Add a new web UI (apps/web) with a
Vite-based dev/build flow and a web platform shim for the frontend -
Implement server logic (apps/server/src/main.rs): health, auth, /rpc
proxy and data-dir/socket-path wiring - Include server-specific
Cargo.toml and a comprehensive server README - Add architecture and
memory-focused docs to guide usage and design - Minor core tweak:
simplify location/resource event emission in
core/src/location/manager.rs to align with new flow - Tauri app: adjust
menus to add an Edit submenu and remove unused items
This commit is contained in:
Jamie Pine committed 2025-11-23 11:01:01 -08:00
1 parent 8a3387ca69
commit bcab31462e
29 files changed
+2693 -187

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
# Git
.git
.gitignore
.gitattributes
# Build artifacts
target/
**/target/
dist/
**/dist/
build/
**/build/
# Dependencies
node_modules/
**/node_modules/
.pnpm-store/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
*.log
logs/
# Test files
coverage/
.nyc_output/
# Documentation (not needed in container)
docs/
*.md
!README.md
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
# Development files
.env
.env.local
.env.*.local
# Rust
**/*.rs.bk
Cargo.lock.orig
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
+380
View File
@@ -0,0 +1,380 @@
# Spacedrive Server Architecture
## Overview
The Spacedrive Server is a production-ready HTTP server that embeds the Spacedrive daemon and serves the web interface. It's designed for headless deployments, NAS systems, and container environments.
## Design Principles
1. **Embedded Daemon** - No separate process management needed
2. **Single Binary** - Web assets bundled via `include_dir` when built with `--features assets`
3. **Platform Abstraction** - Uses same `@sd/interface` as Tauri, with web-specific platform impl
4. **Security First** - HTTP Basic Auth for all endpoints (except health check)
5. **Container Native** - Docker-first design with distroless runtime image
## Components
### 1. HTTP Server (`apps/server/src/main.rs`)
Built with Axum, provides:
- **`GET /health`** - Health check (no auth required)
- **`POST /rpc`** - JSON-RPC proxy to daemon Unix socket
- **`GET /*`** - Static asset serving (SPA fallback to index.html)
**Flow:**
```
Browser → HTTP Request → Axum Router → Basic Auth Middleware → Handler
┌─────────────────────────┴────────┐
↓ ↓
Static Assets RPC Proxy
(serve from ↓
ASSETS_DIR) Unix Socket → Daemon
```
### 2. Embedded Daemon
Unlike Tauri (which spawns `sd-daemon` as a child process), the server runs the daemon in-process:
```rust
tokio::spawn(async move {
sd_core::infra::daemon::bootstrap::start_default_server(
socket_path,
data_dir,
enable_p2p,
).await
});
```
**Benefits:**
- Single container image
- Simplified lifecycle management
- Shared memory space (more efficient)
- Graceful shutdown via `tokio::select!`
**Daemon lifecycle:**
1. Check if socket already exists (reuse existing daemon)
2. If not, spawn daemon in background task
3. Wait for socket file to appear (max 3s)
4. Return handle for graceful shutdown
### 3. Web Client (`apps/web/`)
Minimal React app using `@sd/interface`:
```tsx
// apps/web/src/main.tsx
<PlatformProvider platform={webPlatform}>
<Explorer />
</PlatformProvider>
```
**Platform implementation:**
```typescript
// apps/web/src/platform.ts
export const platform: Platform = {
platform: "web",
openLink(url) { window.open(url) },
confirm(msg, cb) { cb(window.confirm(msg)) },
// No native file pickers, daemon control, etc.
};
```
**Build process:**
1. Vite bundles React app → `apps/web/dist/`
2. `build.rs` runs `pnpm build` before compiling server
3. `include_dir!` macro embeds `dist/` into binary at compile time
4. Axum serves embedded files from memory
### 4. RPC Proxy
Browsers can't connect to Unix sockets, so the server proxies:
```
Browser Server Daemon
│ │ │
│ POST /rpc │ │
├────────────────────>│ │
│ (JSON-RPC) │ Unix Socket Write │
│ ├────────────────────────>│
│ │ │
│ │ Unix Socket Read │
│ │<────────────────────────┤
│ 200 OK │ │
│<────────────────────┤ │
│ (JSON-RPC result) │ │
```
**Implementation:**
```rust
async fn daemon_rpc(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let mut stream = UnixStream::connect(&state.socket_path).await?;
stream.write_all(format!("{}\n", serde_json::to_string(&payload)?).as_bytes()).await?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response).await?;
Ok(Json(serde_json::from_str(&response)?))
}
```
## Comparison: Server vs Tauri vs CLI
| Aspect | Server | Tauri | CLI |
|--------|--------|-------|-----|
| **Process Model** | Embedded daemon | Spawned daemon | Connects to daemon |
| **UI** | Web (React in browser) | WebView (React) | Terminal (TUI) |
| **Daemon Communication** | Unix socket (proxied) | Unix socket (direct) | Unix socket (direct) |
| **Platform Abstraction** | `platform: "web"` | `platform: "tauri"` | N/A |
| **Access Model** | Remote (HTTP) | Local only | Local only |
| **Auth** | HTTP Basic Auth | Not needed | Not needed |
| **Deployment** | Docker, systemd | App bundle | Binary |
## Authentication Flow
```
1. Browser makes request without auth
2. basic_auth middleware checks state.auth
3. If empty → allow (auth disabled)
If populated → require Basic Auth header
4. Extract credentials from Authorization header
5. Compare with state.auth HashMap
6. Match → proceed to handler
No match → 401 Unauthorized
```
**Security considerations:**
- Credentials stored in memory as `SecStr` (zeroed on drop)
- Basic Auth over HTTPS recommended for production
- Socket file has filesystem permissions (only accessible to server user)
## Docker Architecture
### Multi-stage Build
```dockerfile
# Stage 1: Builder (Debian + Rust + Node)
FROM debian:bookworm-slim AS builder
RUN install Rust, Node, pnpm
COPY workspace
RUN pnpm build (web)
RUN cargo build --release --features assets
# Stage 2: Runtime (Distroless)
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /build/target/release/sd-server
ENTRYPOINT ["/usr/bin/sd-server"]
```
**Benefits:**
- **Small image** - Distroless base (~50MB vs ~1GB for full Debian)
- **Secure** - No shell, no package manager, minimal attack surface
- **Fast** - Cached layers for dependencies
- **Reproducible** - Locked versions via `Cargo.lock` and `pnpm-lock.yaml`
### Volume Mounts
```yaml
volumes:
- spacedrive-data:/data # Persistent library data
- /mnt/storage:/storage:ro # Optional: Read-only media access
```
**Data layout:**
```
/data/
├── daemon/
│ └── daemon.sock # Unix socket for RPC
├── libraries/
│ └── *.sdlibrary/ # SQLite databases
│ ├── library.db
│ └── sidecars/ # Thumbnails, previews
├── logs/
│ ├── daemon.log
│ └── indexing.log
└── current_library_id.txt
```
## Development vs Production
### Development Mode
```bash
# Terminal 1: Web dev server (hot reload)
cd apps/web
pnpm dev # → http://localhost:3000
# Terminal 2: API server
cargo run -p sd-server
# → http://localhost:8080
# Vite proxies /rpc to 8080
```
**Workflow:**
1. Edit React components → Vite hot reloads
2. Edit server code → `cargo run` rebuilds
3. No need to rebuild web assets during development
### Production Build
```bash
# Build with bundled assets
cargo build --release -p sd-server --features assets
# Single binary contains:
# - Axum HTTP server
# - Embedded daemon
# - Bundled web UI (React app)
```
**Deployment:**
```bash
./target/release/sd-server \
--data-dir /var/lib/spacedrive \
--port 8080
```
## Platform Abstraction
Both Tauri and Web use `@sd/interface`, but with different platform implementations:
### Tauri Platform (`apps/tauri/src/platform.ts`)
```typescript
{
platform: "tauri",
openDirectoryPickerDialog: async () => open({ directory: true }),
revealFile: async (path) => invoke("reveal_file", { path }),
getCurrentLibraryId: async () => invoke("get_current_library_id"),
getDaemonStatus: async () => invoke("get_daemon_status"),
// Full native capabilities
}
```
### Web Platform (`apps/web/src/platform.ts`)
```typescript
{
platform: "web",
openLink: (url) => window.open(url),
confirm: (msg, cb) => cb(window.confirm(msg)),
// Minimal browser-only capabilities
}
```
**Interface components adapt:**
```tsx
function FilePickerButton() {
const platform = usePlatform();
if (platform.platform === "tauri") {
// Show native picker button
return <button onClick={platform.openDirectoryPickerDialog}>Pick</button>;
} else {
// Web: no native picker, show manual path input
return <input type="text" placeholder="Enter path..." />;
}
}
```
## Error Handling
### HTTP Errors
```rust
async fn daemon_rpc(...) -> Result<Json<Value>, (StatusCode, String)> {
let stream = UnixStream::connect(&socket_path)
.await
.map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?;
// ...
}
```
**Responses:**
- `503 Service Unavailable` - Daemon not running
- `400 Bad Request` - Invalid JSON
- `500 Internal Server Error` - RPC failed
- `401 Unauthorized` - Auth failed
### Daemon Errors
Daemon errors are passed through RPC response:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32603,
"message": "Library not found"
}
}
```
## Performance Considerations
1. **Static Assets** - Served from memory (embedded via `include_dir`)
2. **Socket Pooling** - Each RPC request opens new socket (TODO: connection pool)
3. **Async I/O** - Tokio runtime handles concurrent requests
4. **Graceful Shutdown** - Waits for in-flight requests before terminating
## Future Enhancements
1. **WebSocket Support** - Real-time event streaming (vs polling)
2. **HTTPS** - TLS termination (currently expects reverse proxy)
3. **Connection Pool** - Reuse Unix sockets for RPC
4. **Multi-tenancy** - Separate libraries per user
5. **SSE Events** - Server-sent events for daemon notifications
## Security Model
**Trust Boundaries:**
```
Internet ←[TLS]→ Reverse Proxy ←[HTTP+Auth]→ Server ←[Unix Socket]→ Daemon
(❌) (✅) (✅) (✅) (✅)
```
**Assumptions:**
- Server runs on trusted network OR behind reverse proxy with TLS
- Unix socket accessible only to server process (filesystem permissions)
- HTTP Basic Auth sufficient for home/NAS use
- For public internet: Use nginx/Caddy with Let's Encrypt
## Monitoring
**Health Check:**
```bash
curl http://localhost:8080/health
# → "OK"
```
**Logs:**
```bash
# Docker
docker logs spacedrive -f
# Systemd
journalctl -u spacedrive -f
# Native
RUST_LOG=debug ./sd-server
```
**Metrics:** (TODO)
- Request count/latency
- Daemon socket errors
- Active connections
## Related Documentation
- [README.md](./README.md) - Setup and usage
- [../../docs/core/architecture.md](../../docs/core/architecture.md) - Core VDFS design
- [../tauri/DAEMON_SETUP.md](../tauri/DAEMON_SETUP.md) - Tauri daemon integration
+50
View File
@@ -0,0 +1,50 @@
[package]
name = "sd-server"
version = "0.1.0"
edition = "2021"
[features]
default = []
# Include bundled web assets (production builds)
assets = []
[dependencies]
# Spacedrive core
sd-core = { path = "../../core", features = ["ffmpeg", "heif"] }
# HTTP server
axum = "0.7"
axum-extra = { version = "0.9", features = ["typed-header"] }
http = "1.1"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "sync", "io-util"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs", "cors"] }
# Auth
secstr = "0.5"
# Static assets
include_dir = "0.7"
mime_guess = "2.0"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1"
thiserror = "1"
# CLI
clap = { version = "4", features = ["derive"] }
# Dev dependencies
tempfile = "3"
[[bin]]
name = "sd-server"
path = "src/main.rs"
+100
View File
@@ -0,0 +1,100 @@
# Spacedrive Server Docker Image
# Multi-stage build for minimal production image
ARG RUST_VERSION=1.83
ARG NODE_VERSION=22
#--
# Base image with common dependencies
#--
FROM debian:bookworm-slim AS base
# Configure apt for non-interactive use and caching
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
RUN rm -f /etc/apt/apt.conf.d/docker-clean
RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
apt-get update && apt-get upgrade -y
#--
# Build environment
#--
FROM base AS builder
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
apt-get install -y \
build-essential \
curl \
git \
pkg-config \
libssl-dev \
ca-certificates
# Install Rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:$PATH"
# Install Node.js and pnpm
RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y nodejs
RUN npm install -g pnpm@latest
WORKDIR /build
# Copy workspace configuration
COPY Cargo.toml Cargo.lock ./
COPY .cargo ./.cargo
# Copy source code
COPY core ./core
COPY crates ./crates
COPY apps/server ./apps/server
COPY apps/web ./apps/web
COPY packages ./packages
# Build web assets first
WORKDIR /build/apps/web
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm build
# Build server with embedded assets
WORKDIR /build
RUN --mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/root/.cargo/git \
--mount=type=cache,target=/build/target \
cargo build --release --features assets -p sd-server && \
cp target/release/sd-server /usr/local/bin/sd-server
#--
# Runtime image
#--
FROM gcr.io/distroless/cc-debian12:nonroot
# Copy binary
COPY --from=builder /usr/local/bin/sd-server /usr/bin/sd-server
# Environment
ENV DATA_DIR=/data \
PORT=8080 \
RUST_LOG=info,sd_core=debug
# Expose HTTP port
EXPOSE 8080
# Expose P2P port
EXPOSE 7373
# Volume for persistent data
VOLUME ["/data"]
# Run as non-root user
USER nonroot:nonroot
# Start server
ENTRYPOINT ["/usr/bin/sd-server"]
CMD ["--data-dir", "/data"]
+319
View File
@@ -0,0 +1,319 @@
# Spacedrive Server
HTTP server for Spacedrive with embedded daemon and web interface.
## Overview
`sd-server` runs the Spacedrive daemon and serves a web interface over HTTP. Perfect for:
- **NAS deployments** (TrueNAS, Unraid, Synology, etc.)
- **Headless servers**
- **Remote access** to your Spacedrive libraries
- **Docker/container environments**
## Architecture
```
┌─────────────────────────────────────────┐
│ sd-server (HTTP Server) │
│ ┌───────────────────────────────────┐ │
│ │ Axum HTTP Server (Port 8080) │ │
│ │ ├─ /health (healthcheck) │ │
│ │ ├─ /rpc (proxy to daemon) │ │
│ │ └─ /* (web UI assets) │ │
│ └───────────────────────────────────┘ │
│ ↓ │
│ ┌───────────────────────────────────┐ │
│ │ Embedded Daemon │ │
│ │ (Unix socket: daemon.sock) │ │
│ │ ├─ Core VDFS │ │
│ │ ├─ Indexing │ │
│ │ ├─ P2P Networking │ │
│ │ └─ File Operations │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
Unlike Tauri (desktop app), the server:
- Embeds the daemon instead of spawning a separate process
- Serves the web UI as static assets (when built with `--features assets`)
- Proxies RPC requests from browser to daemon via Unix socket
- Provides basic auth for security
## Quick Start
### Development (without Docker)
1. **Build the server:**
```bash
# Without web assets (for API-only usage)
cargo build -p sd-server
# With bundled web UI
cargo build -p sd-server --features assets
```
2. **Run the server:**
```bash
# Development mode (creates temp data dir)
cargo run -p sd-server
# Production mode (requires DATA_DIR)
DATA_DIR=/path/to/data cargo run -p sd-server --release --features assets
```
3. **Access the web UI:**
- Open http://localhost:8080
- Default auth: disabled in dev mode
### Docker Deployment (Recommended)
Perfect for TrueNAS, Unraid, or any Docker-compatible NAS.
1. **Create a `.env` file:**
```bash
# REQUIRED: Set your credentials
SD_AUTH=admin:your-secure-password
# Optional: Change port
PORT=8080
# Optional: Disable auth (NOT RECOMMENDED)
# SD_AUTH=disabled
```
2. **Start with docker-compose:**
```bash
cd apps/server
docker-compose up -d
```
3. **Access the server:**
- Navigate to `http://your-nas-ip:8080`
- Login with credentials from `.env`
## Configuration
### Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `DATA_DIR` | Path to Spacedrive data directory | `/data` (in Docker) | Yes (production) |
| `PORT` | HTTP server port | `8080` | No |
| `SD_AUTH` | Authentication credentials (format: `user:pass,user2:pass2`) | None | Recommended |
| `SD_P2P` | Enable P2P networking | `true` | No |
| `RUST_LOG` | Log level | `info,sd_core=debug` | No |
### Authentication
**IMPORTANT:** Always set `SD_AUTH` in production!
```bash
# Single user
SD_AUTH=admin:securepassword123
# Multiple users
SD_AUTH=admin:pass1,user:pass2,readonly:pass3
# Disable (NOT RECOMMENDED - only for trusted networks)
SD_AUTH=disabled
```
Uses HTTP Basic Authentication. The server will return `401 Unauthorized` if credentials don't match.
### Data Storage
The server stores all data in `DATA_DIR`:
```
$DATA_DIR/
├── daemon/
│ └── daemon.sock # Unix socket for RPC
├── libraries/
│ └── *.sdlibrary/ # Library databases
├── logs/ # Application logs
└── current_library_id.txt # Last opened library
```
**Docker volumes:** Mounted at `/data` inside the container.
## TrueNAS Setup
### Using TrueNAS SCALE (Docker)
1. **Navigate to Apps** in TrueNAS web UI
2. **Click "Launch Docker Image"**
3. **Configure:**
- **Image:** Build locally or use pre-built image
- **Port:** Map `8080` to host
- **Volume:** Mount `/mnt/pool/spacedrive` to `/data`
- **Environment:**
- `SD_AUTH=admin:yourpassword`
- `TZ=America/New_York` (your timezone)
4. **Add storage pools** (optional):
- Mount your datasets as read-only volumes
- Example: `/mnt/tank/photos` → `/photos` in container
### Manual Docker Run
```bash
docker run -d \
--name spacedrive \
-p 8080:8080 \
-p 7373:7373 \
-v /mnt/pool/spacedrive:/data \
-v /mnt/pool/media:/media:ro \
-e SD_AUTH=admin:password \
-e TZ=UTC \
--restart unless-stopped \
spacedrive/server:latest
```
## Building
### With Web Assets (Production)
```bash
# Build everything (web UI + server)
cargo build --release -p sd-server --features assets
# The binary includes bundled web assets
./target/release/sd-server --data-dir /path/to/data
```
### Without Assets (API Only)
```bash
# Build server without web UI
cargo build --release -p sd-server
# Serve API endpoints only
./target/release/sd-server --data-dir /path/to/data
```
In this mode, you can connect with:
- `sd-cli` (CLI client)
- Custom HTTP clients via `/rpc`
- Tauri desktop app configured to connect to this server
## Development Workflow
1. **Run web dev server:**
```bash
cd apps/web
pnpm dev
```
This starts Vite on http://localhost:3000 with hot reload.
2. **Run API server:**
```bash
cargo run -p sd-server
```
This starts the HTTP server on http://localhost:8080.
3. **Develop:**
- Edit React components in `apps/web/src`
- Edit server code in `apps/server/src`
- Vite proxies `/rpc` requests to the server
## API Endpoints
### `GET /health`
Health check endpoint.
**Response:** `200 OK` with body `"OK"`
### `POST /rpc`
JSON-RPC proxy to daemon.
**Request:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "query:libraries.list",
"params": { "include_stats": false }
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": [...]
}
```
### `GET /*` (with `--features assets`)
Serves the bundled web UI. All non-API routes fallback to `index.html` for SPA routing.
## Comparison: Server vs Tauri
| Feature | Server | Tauri |
|---------|--------|-------|
| **Platform** | Linux/Docker | macOS/Windows/Linux |
| **UI** | Web (React in browser) | Native webview |
| **Daemon** | Embedded in process | Spawned as child process |
| **Access** | Remote over HTTP | Local only |
| **Auth** | HTTP Basic Auth | Not needed (local) |
| **Use Case** | NAS, headless servers | Desktop workstations |
Both use the same Spacedrive core and `@sd/interface` package!
## Troubleshooting
### Server won't start
- Check `DATA_DIR` exists and is writable
- Verify port 8080 is not in use: `lsof -i :8080`
- Check logs: `RUST_LOG=debug cargo run -p sd-server`
### Can't connect to daemon
- Ensure `daemon.sock` exists in `$DATA_DIR/daemon/`
- Check daemon logs in `$DATA_DIR/logs/`
- Try removing stale socket: `rm $DATA_DIR/daemon/daemon.sock`
### Authentication failing
- Verify `SD_AUTH` format: `username:password`
- Check browser is sending Basic Auth header
- Test with curl:
```bash
curl -u admin:password http://localhost:8080/health
```
### Docker build failing
- Ensure you're building from repository root:
```bash
docker build -f apps/server/Dockerfile .
```
- Check Docker has enough memory (4GB+ recommended)
## Contributing
The server app is part of the Spacedrive v2 monorepo.
**Project structure:**
```
apps/server/
├── src/
│ └── main.rs # Server implementation
├── Cargo.toml # Dependencies
├── build.rs # Web bundling script
├── Dockerfile # Container image
└── docker-compose.yml # Docker setup
apps/web/
├── src/
│ ├── main.tsx # Web entry point
│ └── platform.ts # Web platform implementation
├── package.json
└── vite.config.ts
```
**Making changes:**
1. Server code: Edit `apps/server/src/main.rs`
2. Web UI: Edit `apps/web/src/*` (uses `@sd/interface`)
3. Platform integration: Edit `apps/web/src/platform.ts`
## License
AGPL-3.0 - See LICENSE file in repository root.
+406
View File
@@ -0,0 +1,406 @@
# TrueNAS SCALE Deployment Guide
Quick reference for deploying Spacedrive Server on TrueNAS SCALE using the GUI.
## Quick Start
### 1. Build and Transfer (on your Mac)
```bash
cd ~/Projects/spacedrive/apps/server
# Build for TrueNAS
./build-for-truenas.sh
# Transfer to TrueNAS (replace with your IP)
scp spacedrive-server-*.tar.gz root@192.168.1.100:/tmp/
```
### 2. Load Image (on TrueNAS)
```bash
# SSH into TrueNAS
ssh root@192.168.1.100
# Load the image
gunzip -c /tmp/spacedrive-server-*.tar.gz | docker load
# Verify
docker images | grep spacedrive
# Should show: spacedrive-server latest ...
```
### 3. Deploy via GUI
Go to: **Apps****Discover Apps****Launch Docker Image**
---
## GUI Configuration
### Container Images
| Field | Value |
|-------|-------|
| Image Repository | `spacedrive-server` |
| Image Tag | `latest` |
| **Image Pull Policy** | **Never** ⚠️ (Use local image!) |
### Container Settings
| Field | Value |
|-------|-------|
| Container Name | `spacedrive` |
| Restart Policy | Unless Stopped |
### Networking
#### Port Forwarding
| Container Port | Protocol | Node Port | Description |
|---------------|----------|-----------|-------------|
| `8080` | TCP | `8080` | Web UI & API |
| `7373` | TCP | `7373` | P2P Networking |
**Access your server at:** `http://TRUENAS-IP:8080`
### Storage
#### Primary Data Volume
| Field | Value |
|-------|-------|
| Type | Host Path (or ixVolume) |
| Host Path | `/mnt/your-pool/spacedrive` |
| Mount Path | `/data` |
| Read Only | ❌ (needs write access) |
**This stores:**
- Library databases (`.sdlibrary/`)
- Daemon socket
- Logs
- Thumbnails/sidecars
#### Optional: Media Access
Mount your existing media as read-only:
| Field | Value |
|-------|-------|
| Type | Host Path |
| Host Path | `/mnt/your-pool/media` |
| Mount Path | `/media` |
| Read Only | ✅ |
Repeat for other datasets:
- `/mnt/your-pool/photos``/photos`
- `/mnt/your-pool/documents``/documents`
### Environment Variables
**Required:**
| Name | Value | Description |
|------|-------|-------------|
| `SD_AUTH` | `admin:CHANGE_THIS_PASSWORD` | Authentication (username:password) |
**Recommended:**
| Name | Value | Description |
|------|-------|-------------|
| `TZ` | `America/New_York` | Your timezone |
| `RUST_LOG` | `info,sd_core=debug` | Log level |
**Optional:**
| Name | Value | Description |
|------|-------|-------------|
| `PORT` | `8080` | HTTP port (if you want to change it) |
| `SD_P2P` | `true` | Enable P2P (default: true) |
### Health Check (Optional)
| Field | Value |
|-------|-------|
| Type | HTTP |
| Port | `8080` |
| Path | `/health` |
| Initial Delay | `30` seconds |
| Timeout | `10` seconds |
| Period | `30` seconds |
### Resource Limits (Optional)
**Recommended for NAS stability:**
| Resource | Limit | Reservation |
|----------|-------|-------------|
| Memory | `2 GB` | `512 MB` |
| CPU | - | - |
---
## Post-Installation
### 1. Verify Container is Running
In TrueNAS GUI:
- **Apps** → **Installed** → Should see **spacedrive** with green status
Or via shell:
```bash
docker ps | grep spacedrive
```
### 2. Check Logs
In GUI: Click **spacedrive****Logs**
Or via shell:
```bash
docker logs spacedrive
```
Should see:
```
🚀 Spacedrive Server listening on http://localhost:8080
✓ Daemon started successfully
```
### 3. Access Web UI
Open browser: `http://YOUR-TRUENAS-IP:8080`
Login with credentials from `SD_AUTH`:
- Username: `admin`
- Password: (whatever you set)
---
## Updating the Server
When you rebuild on your Mac:
### 1. Build new image
```bash
cd ~/Projects/spacedrive/apps/server
./build-for-truenas.sh
```
### 2. Transfer and load
```bash
# Transfer new tar
scp spacedrive-server-*.tar.gz root@TRUENAS-IP:/tmp/
# SSH and load
ssh root@TRUENAS-IP
gunzip -c /tmp/spacedrive-server-*.tar.gz | docker load
```
### 3. Restart container in GUI
**Apps****Installed****spacedrive****Stop****Start**
Or via shell:
```bash
docker restart spacedrive
```
The container will use the updated `spacedrive-server:latest` image.
---
## Troubleshooting
### Container won't start
**Check logs:**
```bash
docker logs spacedrive
```
**Common issues:**
- Permission denied on `/data` → Check host path exists and is writable
- Port already in use → Change `8080` to something else
- Auth error → Verify `SD_AUTH` format is `username:password`
### Can't connect to web UI
1. Verify container is running: `docker ps | grep spacedrive`
2. Check port mapping: Should show `0.0.0.0:8080->8080/tcp`
3. Test from TrueNAS shell: `curl http://localhost:8080/health`
4. Check firewall rules (TrueNAS should allow by default)
### Daemon not starting
**Check socket:**
```bash
docker exec spacedrive ls -la /data/daemon/
```
Should see `daemon.sock`
**Check daemon logs:**
```bash
docker exec spacedrive cat /data/logs/daemon.log
```
### Wrong architecture error
Make sure you built with `--platform linux/amd64`:
```bash
docker inspect spacedrive-server:latest | grep Architecture
# Should show: "Architecture": "amd64"
```
If not, rebuild:
```bash
./build-for-truenas.sh
```
---
## File Locations
**On TrueNAS host:**
```
/mnt/your-pool/spacedrive/
├── daemon/
│ └── daemon.sock # Unix socket
├── libraries/
│ └── My Library.sdlibrary/
│ ├── library.db # SQLite database
│ └── sidecars/ # Thumbnails
└── logs/
└── daemon.log
```
**Inside container:**
```
/data/ # Maps to host path above
/media/ # Your media mounts (if configured)
/photos/
/documents/
```
---
## Advanced Configuration
### Custom Port
If port 8080 is taken:
1. Change environment variable: `PORT=9000`
2. Update port forwarding: `9000 → 9000`
3. Access at: `http://TRUENAS-IP:9000`
### Multiple Instances
Run multiple Spacedrive instances with different data dirs:
**Instance 1 (Personal):**
- Container name: `spacedrive-personal`
- Host path: `/mnt/pool/spacedrive-personal`
- Ports: `8080:8080`, `7373:7373`
**Instance 2 (Work):**
- Container name: `spacedrive-work`
- Host path: `/mnt/pool/spacedrive-work`
- Ports: `8081:8080`, `7374:7373`
- Env: `INSTANCE=work`
### Reverse Proxy (HTTPS)
If you want HTTPS access, put behind nginx/Caddy:
**Caddy example:**
```
spacedrive.yourdomain.com {
reverse_proxy localhost:8080
basicauth {
admin $2a$14$... # hashed password
}
}
```
---
## Security Notes
**⚠️ IMPORTANT:**
- **Always set SD_AUTH** - never use `SD_AUTH=disabled` on a network-accessible server
- **Use strong passwords** - not `admin:changeme`
- Consider **firewall rules** if exposing to internet
- Run behind **reverse proxy with HTTPS** for public access
- **Read-only mounts** for media you don't want Spacedrive to modify
**Network access:**
- `8080` → Web UI (needs auth)
- `7373` → P2P (encrypted via QUIC/TLS)
---
## Backup
**What to backup:**
```
/mnt/your-pool/spacedrive/libraries/
```
This contains your library databases and metadata.
**How:**
- TrueNAS snapshots (recommended)
- Or periodic `rsync`/`tar` backup
**Not needed:**
- `daemon.sock` (recreated on start)
- `logs/` (optional)
---
## Support
**View logs:**
```bash
docker logs -f spacedrive
```
**Shell access:**
```bash
docker exec -it spacedrive sh
```
**Check daemon status:**
```bash
curl -u admin:yourpassword http://TRUENAS-IP:8080/health
```
Should return: `OK`
---
## Quick Reference Card
```
┌─────────────────────────────────────────────────────────────┐
│ TrueNAS SCALE: Spacedrive Server │
├─────────────────────────────────────────────────────────────┤
│ │
│ Image: spacedrive-server:latest │
│ Pull: Never (use local image) │
│ │
│ Ports: 8080 (Web UI), 7373 (P2P) │
│ │
│ Volume: /mnt/pool/spacedrive → /data │
│ │
│ Env: SD_AUTH=admin:password (REQUIRED) │
│ TZ=America/New_York │
│ │
│ Access: http://TRUENAS-IP:8080 │
│ │
└─────────────────────────────────────────────────────────────┘
```
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
# Build Spacedrive Server for TrueNAS deployment
# Usage: ./build-for-truenas.sh [truenas-ip]
set -e
TRUENAS_IP="${1:-}"
IMAGE_NAME="spacedrive-server"
IMAGE_TAG="latest"
TAR_FILE="spacedrive-server-$(date +%Y%m%d-%H%M%S).tar.gz"
echo "🏗️ Building Spacedrive Server for linux/amd64..."
echo ""
# Check if buildx is available
if ! docker buildx version &> /dev/null; then
echo "❌ docker buildx not found. Installing..."
docker buildx create --use
fi
# Build the image for linux/amd64 (TrueNAS architecture)
echo "📦 Building Docker image..."
cd ../.. # Go to repository root
docker buildx build \
--platform linux/amd64 \
-f apps/server/Dockerfile \
-t ${IMAGE_NAME}:${IMAGE_TAG} \
--load \
.
echo ""
echo "✅ Build complete!"
echo ""
# Save the image to a tar file
echo "💾 Saving image to ${TAR_FILE}..."
docker save ${IMAGE_NAME}:${IMAGE_TAG} | gzip > "apps/server/${TAR_FILE}"
IMAGE_SIZE=$(du -h "apps/server/${TAR_FILE}" | cut -f1)
echo "✅ Image saved: apps/server/${TAR_FILE} (${IMAGE_SIZE})"
echo ""
# If TrueNAS IP provided, offer to transfer
if [ -n "$TRUENAS_IP" ]; then
echo "📤 Transfer to TrueNAS?"
echo " Target: root@${TRUENAS_IP}"
echo ""
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Transferring..."
scp "apps/server/${TAR_FILE}" "root@${TRUENAS_IP}:/tmp/"
echo ""
echo "✅ Transfer complete!"
echo ""
echo "📋 Next steps on TrueNAS:"
echo " 1. SSH into TrueNAS: ssh root@${TRUENAS_IP}"
echo " 2. Load the image: gunzip -c /tmp/${TAR_FILE} | docker load"
echo " 3. Deploy via TrueNAS SCALE Apps UI (see instructions below)"
echo ""
fi
else
echo "📋 Manual deployment steps:"
echo ""
echo "1. Transfer the image to TrueNAS:"
echo " scp apps/server/${TAR_FILE} root@YOUR-TRUENAS-IP:/tmp/"
echo ""
echo "2. SSH into TrueNAS:"
echo " ssh root@YOUR-TRUENAS-IP"
echo ""
echo "3. Load the image:"
echo " gunzip -c /tmp/${TAR_FILE} | docker load"
echo ""
echo "4. Verify it loaded:"
echo " docker images | grep spacedrive"
echo ""
fi
echo "═══════════════════════════════════════════════════════════════"
echo "🎯 TrueNAS SCALE GUI Deployment Instructions"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "After loading the image on TrueNAS:"
echo ""
echo "1. Go to: Apps → Discover Apps → Launch Docker Image"
echo ""
echo "2. Container Settings:"
echo " • Image Repository: ${IMAGE_NAME}"
echo " • Image Tag: ${IMAGE_TAG}"
echo " • Image Pull Policy: Never (important - use local image!)"
echo " • Container Name: spacedrive"
echo ""
echo "3. Port Forwarding:"
echo " • Container Port: 8080 → Node Port: 8080"
echo " • Container Port: 7373 → Node Port: 7373"
echo ""
echo "4. Storage (Host Path Volumes):"
echo " • Host Path: /mnt/YOUR-POOL/spacedrive"
echo " Mount Path: /data"
echo " Type: ixVolume (or Host Path)"
echo ""
echo " Optional - Mount your media:"
echo " • Host Path: /mnt/YOUR-POOL/media"
echo " Mount Path: /media"
echo " Read Only: ✓"
echo ""
echo "5. Environment Variables:"
echo " • SD_AUTH = admin:changeme (CHANGE THIS!)"
echo " • TZ = America/New_York (your timezone)"
echo " • RUST_LOG = info,sd_core=debug"
echo ""
echo "6. Health Check (optional but recommended):"
echo " • Type: HTTP"
echo " • Port: 8080"
echo " • Path: /health"
echo ""
echo "7. Click 'Install'"
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "🌐 Access: http://YOUR-TRUENAS-IP:8080"
echo "🔐 Login with credentials from SD_AUTH"
echo ""
+50
View File
@@ -0,0 +1,50 @@
fn main() {
// Only build web assets if the assets feature is enabled
#[cfg(feature = "assets")]
{
use std::process::Command;
println!("cargo:rerun-if-changed=../web/src");
println!("cargo:rerun-if-changed=../web/index.html");
println!("cargo:rerun-if-changed=../web/package.json");
// Build the web app
let web_dir = std::env::current_dir()
.expect("Failed to get current dir")
.join("../web");
// Check if pnpm is available
let pnpm_check = Command::new("pnpm")
.arg("--version")
.output();
if pnpm_check.is_err() {
panic!("pnpm is required to build web assets. Install it with: npm install -g pnpm");
}
// Install dependencies
println!("Installing web dependencies...");
let install = Command::new("pnpm")
.arg("install")
.current_dir(&web_dir)
.status()
.expect("Failed to run pnpm install");
if !install.success() {
panic!("pnpm install failed");
}
// Build the web app
println!("Building web app...");
let build = Command::new("pnpm")
.arg("build")
.current_dir(&web_dir)
.status()
.expect("Failed to run pnpm build");
if !build.success() {
panic!("pnpm build failed");
}
println!("Web assets built successfully");
}
}
+60
View File
@@ -0,0 +1,60 @@
services:
spacedrive:
build:
context: ../..
dockerfile: apps/server/Dockerfile
container_name: spacedrive-server
restart: unless-stopped
# Ports
ports:
- "8080:8080" # HTTP server
- "7373:7373" # P2P networking
# Volumes
volumes:
- spacedrive-data:/data
# Optional: Mount your file systems here
# - /mnt/storage:/storage:ro
# Environment variables
environment:
# Data directory (inside container)
- DATA_DIR=/data
# HTTP server port
- PORT=8080
# Authentication (REQUIRED for security!)
# Format: "username:password,username2:password2"
# Or set to "disabled" to disable auth (NOT RECOMMENDED)
- SD_AUTH=${SD_AUTH:-admin:changeme}
# Enable P2P networking
- SD_P2P=true
# Logging
- RUST_LOG=info,sd_core=debug
# Timezone
- TZ=UTC
# Health check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Resource limits (adjust as needed)
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 512M
volumes:
spacedrive-data:
driver: local
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Quick start script for running Spacedrive Server in Docker
# Usage: ./docker-run.sh
set -e
echo "🚀 Starting Spacedrive Server with Docker Compose..."
# Check if .env exists
if [ ! -f .env ]; then
echo "⚠️ No .env file found. Creating from template..."
cp .env.example .env
echo ""
echo "📝 IMPORTANT: Edit .env and set your SD_AUTH credentials!"
echo " Default is 'admin:changeme' - please change this."
echo ""
read -p "Press enter to continue or Ctrl+C to abort..."
fi
# Check if docker-compose is available
if ! command -v docker-compose &> /dev/null; then
if ! command -v docker &> /dev/null; then
echo "❌ Docker not found. Please install Docker first."
exit 1
fi
# Try docker compose (newer syntax)
COMPOSE_CMD="docker compose"
else
COMPOSE_CMD="docker-compose"
fi
echo "🏗️ Building and starting container..."
$COMPOSE_CMD up -d --build
echo ""
echo "✅ Spacedrive Server is running!"
echo ""
echo "📍 Access your server at: http://localhost:8080"
echo "🔐 Login credentials: Check your .env file (SD_AUTH)"
echo ""
echo "Useful commands:"
echo " - View logs: $COMPOSE_CMD logs -f spacedrive"
echo " - Stop server: $COMPOSE_CMD down"
echo " - Restart: $COMPOSE_CMD restart"
echo " - Shell access: $COMPOSE_CMD exec spacedrive sh"
echo ""
+427
View File
@@ -0,0 +1,427 @@
use axum::{
body::Body,
extract::{FromRequestParts, Request, State},
http::{header, HeaderValue, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use axum_extra::{headers::authorization::Basic, headers::Authorization, TypedHeader};
use clap::Parser;
use secstr::SecStr;
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixStream,
signal,
sync::RwLock,
};
use tracing::{info, warn};
#[cfg(feature = "assets")]
static ASSETS_DIR: include_dir::Dir<'static> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/../web/dist");
#[derive(Clone)]
struct AppState {
auth: HashMap<String, SecStr>,
socket_path: PathBuf,
}
/// Basic auth middleware
async fn basic_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
let request = if !state.auth.is_empty() {
let (mut parts, body) = request.into_parts();
let Ok(TypedHeader(Authorization(hdr))) =
TypedHeader::<Authorization<Basic>>::from_request_parts(&mut parts, &()).await
else {
return Response::builder()
.status(401)
.header("WWW-Authenticate", "Basic realm=\"Spacedrive\"")
.body("Unauthorized".into_response().into_body())
.expect("hardcoded response will be valid");
};
let request = Request::from_parts(parts, body);
if state
.auth
.get(hdr.username())
.map(|pass| *pass == SecStr::from(hdr.password()))
!= Some(true)
{
return Response::builder()
.status(401)
.header("WWW-Authenticate", "Basic realm=\"Spacedrive\"")
.body("Unauthorized".into_response().into_body())
.expect("hardcoded response will be valid");
}
request
} else {
request
};
next.run(request).await
}
/// Health check endpoint
async fn health() -> &'static str {
"OK"
}
/// Proxy RPC requests to the daemon via Unix socket
async fn daemon_rpc(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Connect to daemon
let mut stream = UnixStream::connect(&state.socket_path)
.await
.map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?;
// Send request
let request_line = serde_json::to_string(&payload)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)))?;
stream
.write_all(format!("{}\n", request_line).as_bytes())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write failed: {}", e)))?;
// Read response
let mut reader = BufReader::new(stream);
let mut response_line = String::new();
reader
.read_line(&mut response_line)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)))?;
// Parse and return
let response: serde_json::Value = serde_json::from_str(&response_line)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid response: {}", e)))?;
Ok(Json(response))
}
#[cfg(feature = "assets")]
async fn serve_asset(path: String) -> Response {
let path = path.trim_start_matches('/');
match ASSETS_DIR.get_file(path) {
Some(file) => Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(
mime_guess::from_path(path)
.first_or_text_plain()
.as_ref(),
)
.unwrap(),
)
.body(Body::from(file.contents()))
.unwrap(),
None => {
// Fallback to index.html for SPA routing
match ASSETS_DIR.get_file("index.html") {
Some(file) => Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str("text/html").unwrap(),
)
.body(Body::from(file.contents()))
.unwrap(),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
}
}
}
}
#[cfg(feature = "assets")]
async fn serve_index() -> Response {
match ASSETS_DIR.get_file("index.html") {
Some(file) => Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str("text/html").unwrap(),
)
.body(Body::from(file.contents()))
.unwrap(),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
}
}
#[derive(Parser, Debug)]
#[command(name = "spacedrive-server", about = "Spacedrive HTTP server")]
struct Args {
/// Path to spacedrive data directory
#[arg(long, env = "DATA_DIR")]
data_dir: Option<PathBuf>,
/// Port to bind HTTP server (default: 8080)
#[arg(long, env = "PORT", default_value = "8080")]
port: u16,
/// Authentication credentials (format: "username:password,username2:password2")
/// Set to "disabled" to disable auth (not recommended in production)
#[arg(long, env = "SD_AUTH")]
auth: Option<String>,
/// Daemon instance name (for running multiple instances)
#[arg(long)]
instance: Option<String>,
/// Enable P2P networking
#[arg(long, env = "SD_P2P", default_value = "true")]
p2p: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,sd_core=debug,sd_server=debug".into()),
)
.init();
let args = Args::parse();
// Resolve data directory
let base_data_dir = args
.data_dir
.unwrap_or_else(|| {
#[cfg(not(debug_assertions))]
{
std::env::var("DATA_DIR")
.expect("DATA_DIR must be set in production")
.into()
}
#[cfg(debug_assertions)]
{
std::env::var("DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let temp = tempfile::tempdir().expect("Failed to create temp dir");
temp.path().to_path_buf()
})
}
});
// Calculate instance-specific paths
let (data_dir, socket_path) = if let Some(instance) = &args.instance {
let instance_data_dir = base_data_dir.join("instances").join(instance);
let socket_path = base_data_dir
.join("daemon")
.join(format!("daemon-{}.sock", instance));
(instance_data_dir, socket_path)
} else {
let socket_path = base_data_dir.join("daemon/daemon.sock");
(base_data_dir.clone(), socket_path)
};
info!("Data directory: {:?}", data_dir);
info!("Socket path: {:?}", socket_path);
// Parse authentication
let (auth, _disabled) = parse_auth(args.auth.as_deref());
// Require credentials in production builds (unless explicitly disabled)
#[cfg(not(debug_assertions))]
if auth.is_empty() && !_disabled {
warn!("The 'SD_AUTH' environment variable is not set!");
warn!("If you want to disable auth set 'SD_AUTH=disabled', or");
warn!("Provide your credentials in the following format 'SD_AUTH=username:password,username2:password2'");
std::process::exit(1);
}
// Ensure daemon directory exists
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Start the daemon if not already running
let daemon_handle = start_daemon_if_needed(socket_path.clone(), data_dir.clone(), args.p2p).await?;
// Build HTTP router
let state = AppState {
auth,
socket_path: socket_path.clone(),
};
let app = Router::new()
.route("/health", get(health))
.route("/rpc", post(daemon_rpc));
// Add asset serving routes if assets feature is enabled
#[cfg(feature = "assets")]
let app = app
.route("/", get(serve_index))
.route("/*path", get(|axum::extract::Path(path): axum::extract::Path<String>| serve_asset(path)));
#[cfg(not(feature = "assets"))]
let app = app.route("/", get(|| async { "Spacedrive Server - Use with web client" }));
let app = app
.fallback(|| async {
(
StatusCode::NOT_FOUND,
"404 Not Found: We're past the event horizon...",
)
})
.layer(middleware::from_fn_with_state(state.clone(), basic_auth))
.with_state(state);
// Bind server
let mut addr = "[::]:8080".parse::<SocketAddr>().unwrap();
addr.set_port(args.port);
info!("🚀 Spacedrive Server listening on http://localhost:{}", args.port);
#[cfg(feature = "assets")]
info!("📦 Serving bundled web assets");
#[cfg(not(feature = "assets"))]
info!("📦 Asset serving disabled (use --features assets)");
// Setup graceful shutdown
let shutdown_signal = shutdown_signal(daemon_handle);
// Start server
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal)
.await?;
Ok(())
}
/// Parse authentication credentials from env var
fn parse_auth(auth_str: Option<&str>) -> (HashMap<String, SecStr>, bool) {
let Some(input) = auth_str else {
return (HashMap::new(), false);
};
if input == "disabled" {
return (HashMap::new(), true);
}
let credentials = input
.split(',')
.enumerate()
.filter_map(|(i, s)| {
if s.is_empty() {
return None;
}
let mut parts = s.split(':');
let result = parts
.next()
.and_then(|user| parts.next().map(|pass| (user.to_string(), SecStr::from(pass))));
if result.is_none() {
warn!("Found invalid credential {i}. Skipping...");
}
result
})
.collect();
(credentials, false)
}
/// Start the daemon if it's not already running
async fn start_daemon_if_needed(
socket_path: PathBuf,
data_dir: PathBuf,
enable_p2p: bool,
) -> Result<Option<Arc<RwLock<tokio::task::JoinHandle<()>>>>, Box<dyn std::error::Error>> {
// Check if daemon is already running
if socket_path.exists() {
match UnixStream::connect(&socket_path).await {
Ok(_) => {
info!("✓ Daemon already running");
return Ok(None);
}
Err(_) => {
warn!("Stale socket file found, removing...");
std::fs::remove_file(&socket_path).ok();
}
}
}
info!("Starting embedded daemon...");
// Start daemon in background task
let socket_path_clone = socket_path.clone();
let data_dir_clone = data_dir.clone();
let handle = tokio::spawn(async move {
if let Err(e) = sd_core::infra::daemon::bootstrap::start_default_server(
socket_path_clone,
data_dir_clone,
enable_p2p,
)
.await
{
tracing::error!("Daemon failed: {}", e);
}
});
// Wait for socket to be created (daemon startup)
for i in 0..30 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if socket_path.exists() {
info!("✓ Daemon started successfully");
return Ok(Some(Arc::new(RwLock::new(handle))));
}
if i == 10 {
warn!("Daemon taking longer than expected to start...");
}
}
Err("Daemon failed to start (socket not created after 3 seconds)".into())
}
/// Graceful shutdown handler
async fn shutdown_signal(daemon_handle: Option<Arc<RwLock<tokio::task::JoinHandle<()>>>>) {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {
info!("Received Ctrl+C, shutting down gracefully...");
}
() = terminate => {
info!("Received SIGTERM, shutting down gracefully...");
}
}
// Abort daemon task if we started it
if let Some(handle) = daemon_handle {
handle.write().await.abort();
}
}