Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ MATURIN := .venv/bin/maturin
# Pinned so lint results don't shift when ruff changes its default rule set.
RUFF_VERSION := ruff==0.15.15

.PHONY: help build install dev test clean daemon-build daemon-release test-e2e test-e2e-tunnel docker-build docker-down
.PHONY: help build dev test clean daemon-build daemon-release test-e2e test-e2e-tunnel docker-build docker-down

help:
@echo "SandD - Sandbox Daemon - Build Commands"
@echo ""
@echo " make build - Build Python package (debug mode)"
@echo " make install - Install Python package locally"
@echo " make dev - Install in development mode with hot reload"
@echo " make test - Run unit and integration tests (fast, no Docker)"
@echo " make test-e2e - Run direct-mode end-to-end tests with Docker (slow)"
Expand Down
93 changes: 65 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,63 @@ Rust-powered WebSocket server with Python API for remote command execution and i
└───────┘ └───────┘ └───────┘
```

## Quick Start

Run a server and a daemon on one machine and execute a command. You need Rust
(https://rustup.rs) and Python 3.8+.

**1. Build both from source:**

```bash
git clone https://github.com/InftyAI/SandD
cd SandD
pip install . # server: the `sandd` Python package
make daemon-release # daemon: ./target/release/sandd
```

**2. Terminal 1, start the server:**

```python
from sandd import Server

server = Server() # listens on 0.0.0.0:8765
print("waiting for worker-1...")
server.wait_for_daemon("worker-1", timeout=120)

result = server.exec("worker-1", "uname -a")
print(result.stdout)
```

**3. Terminal 2, start a daemon:**

```bash
./target/release/sandd --server-url ws://127.0.0.1:8765/ws --daemon-id worker-1
```

Terminal 1 prints the daemon's `uname -a`. On real machines, run the daemon on each
worker with `ws://<server-ip>:8765/ws`; workers dial out, so only the server's
port needs to be reachable.

## Installation

### Python Package (Controller)
The server is a Python package (`from sandd import Server`); the daemon is a
separate binary, also named `sandd`, that runs on each worker.

### Server (Python package)

Install from PyPI:
From PyPI:
```bash
pip install sandd
```

Or build from source:
From source (needs Rust):
```bash
git clone https://github.com/InftyAI/SandD
cd SandD
make install
pip install .
```

### Daemon Binary (Worker)
### Daemon (worker binary)

#### Quick Install

Expand All @@ -87,10 +127,10 @@ curl -fsSL https://raw.githubusercontent.com/InftyAI/SandD/main/hack/scripts/ins
curl -fsSL https://raw.githubusercontent.com/InftyAI/SandD/main/hack/scripts/install.sh | sudo bash -s -- --tunnel
```

This installs the latest release. To pin a specific version:
This installs the latest release to `/usr/local/bin/sandd`. To pin a specific version:

```bash
curl -fsSL https://raw.githubusercontent.com/InftyAI/SandD/main/hack/scripts/install.sh | sudo bash -s -- --version v0.1.0
curl -fsSL https://raw.githubusercontent.com/InftyAI/SandD/main/hack/scripts/install.sh | sudo bash -s -- --version v0.2.0
```

#### Alternative Methods
Expand All @@ -104,21 +144,20 @@ cargo install sandd
```bash
git clone https://github.com/InftyAI/SandD
cd SandD
make daemon-release
# Binary at: ./target/release/sandd
make daemon-release # binary at ./target/release/sandd
# or put it on your PATH (~/.cargo/bin/sandd):
cargo install --path sandd
```

**Build from the main branch:**
```bash
cargo install --git https://github.com/InftyAI/SandD sandd
```

## Quick Start
## Usage

### Direct Mode (Development)

**Start controller:**

```python
from sandd import Server

Expand All @@ -129,18 +168,8 @@ result = server.exec("worker-1", "hostname")
print(result.stdout)
```

**Start daemon:**

```bash
# Direct mode
sandd --server-url ws://controller-ip:8765/ws --daemon-id worker-1

# Tunnel mode
sandd --server-url ws://10.200.0.1:8765/ws \
--daemon-id worker-1 \
--tunnel \
--tunnel-authkey YOUR_KEY \
--tunnel-server http://headscale:8080
sandd --server-url ws://server-ip:8765/ws --daemon-id worker-1
```

### Tunnel Mode (Production)
Expand All @@ -160,6 +189,14 @@ server = Server(connect="tunnel", tunnel_config=config)
# ✓ No public IPs required
```

```bash
sandd --server-url ws://10.200.0.1:8765/ws \
--daemon-id worker-1 \
--tunnel \
--tunnel-authkey YOUR_KEY \
--tunnel-server http://headscale:8080
```

In a cluster, don't bake a key into your image — mint one per start from a key
broker (`$SANDD_KEYBROKER_URL`) and take the headscale URL from
`$SANDD_TUNNEL_SERVER`:
Expand All @@ -174,13 +211,13 @@ See [Tunnel Mode Guide](./docs/proposals/TUNNEL.md) for setup instructions.

## Security

SandD does not authenticate daemons yet. The controller accepts any client that
SandD does not authenticate daemons yet. The server accepts any client that
speaks the `sandd.v1` protocol, and a daemon can register under any id, so anyone who
can reach the controller's port can join and receive commands meant for that id.
can reach the server's port can join and receive commands meant for that id.

- **Direct mode:** run the controller only on a trusted network.
- **Direct mode:** run the server only on a trusted network.
- **Tunnel mode:** only nodes admitted to your Headscale mesh can reach the
controller, and traffic is encrypted with WireGuard. The `authkey` is a Headscale
server, and traffic is encrypted with WireGuard. The `authkey` is a Headscale
key for joining the mesh, not a SandD credential.

Daemon authentication and authorization with Keycloak is planned (see
Expand All @@ -198,7 +235,7 @@ Daemon authentication and authorization with Keycloak is planned (see
## Roadmap

- [ ] **Authentication & Authorization** - Keycloak-issued tokens for daemons (see [Security](#security))
- [ ] **File Transfer** - Upload and download files between controller and workers
- [ ] **File Transfer** - Upload and download files between server and workers
- [ ] **Audit Logging** - Track all commands, sessions, and file transfers
- [ ] **Metrics** - Prometheus-compatible metrics for monitoring
- [ ] **Resource Limits** - CPU/memory/timeout controls per daemon
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ Forward raw SSH traffic through WebSocket to daemon's sshd.

### Future: Authentication

Today the controller admits any client that speaks `sandd.v1`, and a daemon can
Today the server admits any client that speaks `sandd.v1`, and a daemon can
register under any id. Run it on a trusted network, or in tunnel mode, where only mesh
members can reach it.

The plan is to use Keycloak for authentication and authorization:

- The daemon gets an access token from Keycloak and sends it as
`Authorization: Bearer <token>` on the WebSocket upgrade.
- The controller verifies the token against Keycloak's JWKS endpoint before upgrading,
- The server verifies the token against Keycloak's JWKS endpoint before upgrading,
and rejects a bad one with HTTP 401.
- The token's claims decide which daemon id the connection registers as, and what it
is allowed to do.
12 changes: 6 additions & 6 deletions docs/DEVELOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ SandD/
├── protocol/ # Shared message types (sandd-protocol crate)
│ └── src/lib.rs
│
├── server/ # Controller: Rust WebSocket server + PyO3 bindings
├── server/ # Server: Rust WebSocket server + PyO3 bindings
│ ├── src/
│ │ ├── lib.rs # Crate root
│ │ ├── server.rs # WebSocket server (axum): /ws, /stats, /health
Expand Down Expand Up @@ -77,7 +77,7 @@ make daemon-build
### 3. Try It

```bash
# Terminal 1: start a controller
# Terminal 1: start a server
python3 -c "
from sandd import Server
server = Server()
Expand Down Expand Up @@ -183,7 +183,7 @@ tunnel stack. `make test` runs `-m "not e2e"`.
RUST_LOG=debug ./target/debug/sandd --server-url ws://127.0.0.1:8765/ws
```

**Controller logs:** `Server(verbose=True)` (the default) logs at INFO; set
**Server logs:** `Server(verbose=True)` (the default) logs at INFO; set
`RUST_LOG=sandbox_server=debug` for more.

## Known Issues & Limitations
Expand All @@ -192,7 +192,7 @@ RUST_LOG=debug ./target/debug/sandd --server-url ws://127.0.0.1:8765/ws
`Server.download_file` exist, and the protocol messages are defined, but the
feature is unfinished: don't rely on either call.
2. **No daemon authentication.** Any client that speaks `sandd.v1` can register as any
daemon id. Run the controller on a trusted network or in tunnel mode. See
daemon id. Run the server on a trusted network or in tunnel mode. See
[ARCHITECTURE.md](ARCHITECTURE.md#future-authentication).

### Warnings
Expand Down Expand Up @@ -248,10 +248,10 @@ Use a `feat:`, `fix:` or `chore:` prefix, as in the existing history.
### User Issues

**Daemon won't connect:**
- Check the controller is up: `curl http://controller-host:8765/health`
- Check the server is up: `curl http://server-host:8765/health`
- Verify firewall allows outbound WebSocket connections
- Check daemon logs: `RUST_LOG=info ./target/release/sandd ...`
- Check what the controller sees: `curl http://controller-host:8765/stats`
- Check what the server sees: `curl http://server-host:8765/stats`

**Commands timing out:**
- Increase `timeout` parameter in `exec()` (in seconds)
Expand Down
8 changes: 4 additions & 4 deletions hack/docker/Dockerfile.daemon-tunnel-release
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# Downloads pre-built binary from GitHub releases
#
# Build: docker build -f hack/docker/Dockerfile.daemon-tunnel-release \
# --build-arg SANDD_VERSION=v0.1.0 \
# -t inftyai/sandd-tunnel:v0.1.0 .
# Run: docker run --cap-add NET_ADMIN --device /dev/net/tun inftyai/sandd-tunnel:v0.1.0
# --build-arg SANDD_VERSION=v0.2.0 \
# -t inftyai/sandd-tunnel:v0.2.0 .
# Run: docker run --cap-add NET_ADMIN --device /dev/net/tun inftyai/sandd-tunnel:v0.2.0

FROM debian:bookworm-slim

Expand All @@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y \
RUN curl -fsSL https://tailscale.com/install.sh | sh

# Download pre-built daemon binary from GitHub releases
ARG SANDD_VERSION=v0.1.0
ARG SANDD_VERSION=v0.2.0
ARG TARGETOS=linux
ARG TARGETARCH=amd64

Expand Down
8 changes: 4 additions & 4 deletions hack/docker/Dockerfile.server-tunnel-release
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# Uses published PyPI package instead of building from source
#
# Build: docker build -f hack/docker/Dockerfile.server-tunnel-release \
# --build-arg SANDD_VERSION=0.1.0 \
# -t inftyai/sandd-server-tunnel:v0.1.0 .
# Run: docker run --cap-add NET_ADMIN --device /dev/net/tun inftyai/sandd-server-tunnel:v0.1.0
# --build-arg SANDD_VERSION=0.2.0 \
# -t inftyai/sandd-server-tunnel:v0.2.0 .
# Run: docker run --cap-add NET_ADMIN --device /dev/net/tun inftyai/sandd-server-tunnel:v0.2.0

FROM python:3.11-slim-bookworm

Expand All @@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y \
RUN curl -fsSL https://tailscale.com/install.sh | sh

# Install SandD from PyPI (specify version or use latest)
ARG SANDD_VERSION=0.0.0
ARG SANDD_VERSION=0.2.0
RUN pip install --no-cache-dir sandd==${SANDD_VERSION}

# Create directory for Tailscale state
Expand Down
6 changes: 3 additions & 3 deletions hack/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This directory contains Docker-related files for building and testing SandD.

- **`Dockerfile.server-tunnel-release`** - Server with Tailscale (uses PyPI release)
- Use: Production deployments
- Build: `docker build -f hack/docker/Dockerfile.server-tunnel-release --build-arg SANDD_VERSION=0.1.0 -t inftyai/sandd-server-tunnel:v0.1.0 .`
- Build: `docker build -f hack/docker/Dockerfile.server-tunnel-release --build-arg SANDD_VERSION=0.2.0 -t inftyai/sandd-server-tunnel:v0.2.0 .`

#### Daemon (Worker) Images

Expand All @@ -25,7 +25,7 @@ This directory contains Docker-related files for building and testing SandD.

- **`Dockerfile.daemon-tunnel-release`** - Daemon with Tailscale (uses GitHub release)
- Use: Production deployments
- Build: `docker build -f hack/docker/Dockerfile.daemon-tunnel-release --build-arg SANDD_VERSION=v0.1.0 -t inftyai/sandd-tunnel:v0.1.0 .`
- Build: `docker build -f hack/docker/Dockerfile.daemon-tunnel-release --build-arg SANDD_VERSION=v0.2.0 -t inftyai/sandd-tunnel:v0.2.0 .`

#### Test Images (Direct Mode)

Expand Down Expand Up @@ -68,7 +68,7 @@ Overridable variables: `SERVER_TUNNEL_IMG`, `SERVER_TUNNEL_TAG`, `PLATFORMS`,

```bash
make docker-push-server-tunnel \
SERVER_TUNNEL_IMG=myrepo/sandd-server-tunnel SERVER_TUNNEL_TAG=v0.1.0
SERVER_TUNNEL_IMG=myrepo/sandd-server-tunnel SERVER_TUNNEL_TAG=v0.2.0
```

To iterate locally you need a **runnable** image, which a multi-platform build cannot
Expand Down
2 changes: 1 addition & 1 deletion protocol/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sandd-protocol"
version = "0.0.1"
version = "0.2.0"
edition = "2021"
license = "Apache-2.0"
description = "Shared wire protocol types for the SandD sandbox daemon and controller."
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "sandd"
version = "0.0.1"
version = "0.2.0"
description = "A lightweight sandbox daemon for secure agent execution in isolated environments."
readme = {file = "README.md", content-type = "text/markdown"}
requires-python = ">=3.8"
Expand Down
2 changes: 1 addition & 1 deletion python/sandd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from ._core import Session, TunnelConfig
except ImportError as e:
raise ImportError(
"Failed to import Rust extension. Please build the package with: make install"
"Failed to import Rust extension. Please build it with: make dev (source checkout) or pip install ."
) from e

__all__ = [
Expand Down
2 changes: 1 addition & 1 deletion python/sandd/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from ._core import TunnelConfig
except ImportError as e: # pragma: no cover - mirrors server.py's import guard
raise ImportError(
"Failed to import Rust extension. Please build the package with: make install"
"Failed to import Rust extension. Please build it with: make dev (source checkout) or pip install ."
) from e

__all__ = ["mint_authkey", "tunnel_config_from_env"]
Expand Down
2 changes: 1 addition & 1 deletion python/sandd/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from ._core import PyCommandResult, PyStats
except ImportError as e:
raise ImportError(
"Failed to import Rust extension. Please build the package with: make install"
"Failed to import Rust extension. Please build it with: make dev (source checkout) or pip install ."
) from e


Expand Down
Loading
Loading