diff --git a/Cargo.lock b/Cargo.lock index 975c6cd..ac425a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,7 +1675,7 @@ dependencies = [ [[package]] name = "sandbox-server" -version = "0.0.1" +version = "0.2.0" dependencies = [ "anyhow", "axum", @@ -1700,7 +1700,7 @@ dependencies = [ [[package]] name = "sandd" -version = "0.0.1" +version = "0.2.0" dependencies = [ "anyhow", "base64", @@ -1729,7 +1729,7 @@ dependencies = [ [[package]] name = "sandd-protocol" -version = "0.0.1" +version = "0.2.0" dependencies = [ "base64", "serde", diff --git a/Makefile b/Makefile index 1823353..4bdd216 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md index d093883..1eaa2f9 100644 --- a/README.md +++ b/README.md @@ -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://: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 @@ -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 @@ -104,8 +144,9 @@ 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:** @@ -113,12 +154,10 @@ make daemon-release cargo install --git https://github.com/InftyAI/SandD sandd ``` -## Quick Start +## Usage ### Direct Mode (Development) -**Start controller:** - ```python from sandd import Server @@ -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) @@ -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`: @@ -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 @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4133d31..1181457 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,7 +56,7 @@ 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. @@ -64,7 +64,7 @@ The plan is to use Keycloak for authentication and authorization: - The daemon gets an access token from Keycloak and sends it as `Authorization: Bearer ` 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. diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md index 92bd26e..8f767f3 100644 --- a/docs/DEVELOP.md +++ b/docs/DEVELOP.md @@ -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 @@ -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() @@ -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 @@ -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 @@ -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) diff --git a/hack/docker/Dockerfile.daemon-tunnel-release b/hack/docker/Dockerfile.daemon-tunnel-release index 8b267e5..5e990a6 100644 --- a/hack/docker/Dockerfile.daemon-tunnel-release +++ b/hack/docker/Dockerfile.daemon-tunnel-release @@ -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 @@ -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 diff --git a/hack/docker/Dockerfile.server-tunnel-release b/hack/docker/Dockerfile.server-tunnel-release index 3c2697d..1c8a9f7 100644 --- a/hack/docker/Dockerfile.server-tunnel-release +++ b/hack/docker/Dockerfile.server-tunnel-release @@ -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 @@ -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 diff --git a/hack/docker/README.md b/hack/docker/README.md index dec0ce7..9d93d71 100644 --- a/hack/docker/README.md +++ b/hack/docker/README.md @@ -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 @@ -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) @@ -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 diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 2106326..5d3f239 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -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." diff --git a/pyproject.toml b/pyproject.toml index 441b3b9..63947ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/python/sandd/__init__.py b/python/sandd/__init__.py index 1921922..e3d0875 100644 --- a/python/sandd/__init__.py +++ b/python/sandd/__init__.py @@ -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__ = [ diff --git a/python/sandd/keys.py b/python/sandd/keys.py index 75d3e77..9255e3f 100644 --- a/python/sandd/keys.py +++ b/python/sandd/keys.py @@ -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"] diff --git a/python/sandd/models.py b/python/sandd/models.py index 837fb51..b03d3cd 100644 --- a/python/sandd/models.py +++ b/python/sandd/models.py @@ -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 diff --git a/python/sandd/server.py b/python/sandd/server.py index 1b3c9df..1096ba5 100644 --- a/python/sandd/server.py +++ b/python/sandd/server.py @@ -11,7 +11,7 @@ from ._core import Server as _RustServer, 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 diff --git a/sandd/Cargo.toml b/sandd/Cargo.toml index f366a61..a429811 100644 --- a/sandd/Cargo.toml +++ b/sandd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sandd" -version = "0.0.1" +version = "0.2.0" edition = "2021" license = "Apache-2.0" description = "A lightweight sandbox daemon for secure agent execution in isolated environments." @@ -30,7 +30,7 @@ path = "../examples/snapshot_real_project.rs" [dependencies] # `version` is required to publish: cargo strips `path` and resolves the # dependency from crates.io, so sandd-protocol must be published first. -sandd-protocol = { path = "../protocol", version = "0.0.1" } +sandd-protocol = { path = "../protocol", version = "0.2.0" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/server/Cargo.toml b/server/Cargo.toml index 56e8d38..c8d1960 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sandbox-server" -version = "0.0.1" +version = "0.2.0" edition = "2021" license = "Apache-2.0" description = "A lightweight sandbox daemon for secure agent execution in isolated environments."