CLI
Fast command-line client for code execution and interactive sessions. 42+ languages, 30+ shells/REPLs.
Official OpenAPI Swagger Docs ↗Quick Start — Rust
# Download + setup
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/rust/sync/src/lib.rs
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Compile first
rustc un.rs -o un
# Run code
./un script.rs
Features
- 42+ languages - Python, JS, Go, Rust, C++, Java...
- Sessions - 30+ shells/REPLs, tmux persistence
- Files - Upload files, collect artifacts
- Services - Persistent containers with domains
- Snapshots - Point-in-time backups
- Images - Publish, share, transfer
Integration Quickstart ⚡
Add unsandbox superpowers to your existing Rust app:
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/rust/sync/src/lib.rs
# Option A: Environment variables
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Option B: Config file (persistent)
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
// In your Rust app:
mod un;
fn main() {
let result = un::execute_code("rust", r#"println!("Hello from Rust running on unsandbox!");"#);
println!("{}", result.stdout); // Hello from Rust running on unsandbox!
}
rustc main.rs -o myapp && ./myapp
d50c53f5417e2daac8e96f240208b80b
SHA256: 1ee5206c976d39273972b916e75632e6a1a042fa9e3178cbef79ff81d002b9ca
// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
//
// unsandbox.com Rust SDK (Synchronous)
//
// Library Usage:
// use un::{Credentials, execute_code, resolve_credentials};
//
// // Resolve credentials (4-tier priority)
// let creds = resolve_credentials(None, None)?;
//
// // Execute code synchronously
// let result = execute_code("python", r#"print("hello")"#, &creds)?;
// println!("Output: {}", result.output);
//
// // Execute asynchronously
// let job_id = execute_async("javascript", r#"console.log("hello")"#, &creds)?;
//
// // Wait for job completion
// let result = wait_for_job(&job_id, &creds, None)?;
//
// // List all jobs
// let jobs = list_jobs(&creds)?;
//
// // Get supported languages (cached 1 hour)
// let languages = get_languages(&creds)?;
//
// // Detect language from filename
// let lang = detect_language("script.py"); // Some("python")
//
// // Snapshot operations
// let snapshot = session_snapshot(&session_id, &creds, Some("my_snapshot"), false)?;
// let snapshots = list_snapshots(&creds)?;
// let result = restore_snapshot(&snapshot_id, &creds)?;
// delete_snapshot(&snapshot_id, &creds)?;
//
// Authentication Priority (4-tier):
// 1. Function arguments (public_key, secret_key)
// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
// 3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
// 4. Local directory (./accounts.csv, line 0 by default)
//
// Format: public_key,secret_key (one per line)
// Account selection: UNSANDBOX_ACCOUNT=N env var (0-based index)
//
// Request Authentication (HMAC-SHA256):
// Authorization: Bearer <public_key> (identifies account)
// X-Timestamp: <unix_seconds> (replay prevention)
// X-Signature: HMAC-SHA256(secret_key, msg) (proves secret + body integrity)
//
// Message format: "timestamp:METHOD:path:body"
// - timestamp: seconds since epoch
// - METHOD: GET, POST, DELETE, etc. (uppercase)
// - path: e.g., "/execute", "/jobs/123"
// - body: JSON payload (empty string for GET/DELETE)
//
// Languages Cache:
// - Cached in ~/.unsandbox/languages.json
// - TTL: 1 hour
// - Updated on successful API calls
use hmac::{Hmac, Mac};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io::{self, BufRead, BufReader, Write};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// API base URL
const API_BASE: &str = "https://api.unsandbox.com";
/// Languages cache TTL in seconds (1 hour)
const LANGUAGES_CACHE_TTL: u64 = 3600;
/// Polling delays in milliseconds for exponential backoff
const POLL_DELAYS_MS: &[u64] = &[300, 450, 700, 900, 650, 1600, 2000];
/// Default timeout for wait_for_job in seconds
const DEFAULT_TIMEOUT_SECS: u64 = 300;
type HmacSha256 = Hmac<Sha256>;
// =============================================================================
// Error Types
// =============================================================================
/// Error type for unsandbox SDK operations
#[derive(Debug, thiserror::Error)]
pub enum UnsandboxError {
/// No credentials found in any of the 4 tiers
#[error("No credentials found. Please provide via:\n 1. Function arguments (public_key, secret_key)\n 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n 3. ~/.unsandbox/accounts.csv\n 4. ./accounts.csv")]
NoCredentials,
/// HTTP request failed
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
/// API returned an error response
#[error("API error (HTTP {status}): {message}")]
ApiError { status: u16, message: String },
/// JSON serialization/deserialization failed
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
/// I/O error (file operations)
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
/// Job timed out while waiting
#[error("Job timed out after {0} seconds")]
Timeout(u64),
/// Missing expected field in response
#[error("Missing field in response: {0}")]
MissingField(String),
}
/// Result type for unsandbox SDK operations
pub type Result<T> = std::result::Result<T, UnsandboxError>;
// =============================================================================
// Credentials
// =============================================================================
/// API credentials for authentication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
/// Public key (unsb-pk-xxxx-xxxx-xxxx-xxxx) - used as Bearer token
pub public_key: String,
/// Secret key (unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx) - used for HMAC signing, never transmitted
pub secret_key: String,
}
impl Credentials {
/// Create new credentials from public and secret keys
pub fn new(public_key: impl Into<String>, secret_key: impl Into<String>) -> Self {
Self {
public_key: public_key.into(),
secret_key: secret_key.into(),
}
}
}
// =============================================================================
// Response Types
// =============================================================================
/// Result of code execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteResult {
/// Job ID
pub job_id: String,
/// Execution status: "completed", "failed", "timeout", "cancelled"
pub status: String,
/// Combined stdout/stderr output
#[serde(default)]
pub output: String,
/// Exit code (0 = success)
#[serde(default)]
pub exit_code: i32,
/// Execution time in milliseconds
#[serde(default)]
pub execution_time_ms: u64,
}
/// Job status from /jobs/{id} endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobStatus {
/// Job ID
pub job_id: String,
/// Current status: "pending", "running", "completed", "failed", "timeout", "cancelled"
pub status: String,
/// Language used
#[serde(default)]
pub language: String,
/// Combined output (available when completed)
#[serde(default)]
pub output: String,
/// Exit code (available when completed)
#[serde(default)]
pub exit_code: i32,
/// Execution time in milliseconds
#[serde(default)]
pub execution_time_ms: u64,
/// Created timestamp
#[serde(default)]
pub created_at: String,
}
/// Job summary from /jobs list endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
/// Job ID
pub job_id: String,
/// Current status
pub status: String,
/// Language used
#[serde(default)]
pub language: String,
/// Created timestamp
#[serde(default)]
pub created_at: String,
}
/// Snapshot information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
/// Snapshot ID
pub snapshot_id: String,
/// Snapshot name
#[serde(default)]
pub name: String,
/// Source type: "session" or "service"
#[serde(default)]
pub source_type: String,
/// Source ID (session_id or service_id)
#[serde(default)]
pub source_id: String,
/// Whether this is a hot (ephemeral) snapshot
#[serde(default)]
pub hot: bool,
/// Created timestamp
#[serde(default)]
pub created_at: String,
/// Size in bytes
#[serde(default)]
pub size_bytes: u64,
}
/// Result of restoring a snapshot
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestoreResult {
/// New session or service ID
pub id: String,
/// Type: "session" or "service"
#[serde(rename = "type")]
pub restore_type: String,
/// Status message
#[serde(default)]
pub message: String,
}
/// Session information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
/// Session ID
pub session_id: String,
/// Container name (e.g., "unsb-vm-abc123")
#[serde(default)]
pub container_name: String,
/// Session status: "running", "frozen", "stopped"
#[serde(default)]
pub status: String,
/// Network mode: "zerotrust" or "semitrusted"
#[serde(default)]
pub network_mode: String,
/// Shell type (e.g., "bash", "python3")
#[serde(default)]
pub shell: String,
/// Number of vCPUs
#[serde(default)]
pub vcpu: u32,
/// Memory in MB
#[serde(default)]
pub memory_mb: u32,
/// Whether the session is boosted
#[serde(default)]
pub boosted: bool,
/// Created timestamp
#[serde(default)]
pub created_at: String,
/// Last activity timestamp
#[serde(default)]
pub last_activity: String,
}
/// Service information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
/// Service ID
pub service_id: String,
/// Service name
#[serde(default)]
pub name: String,
/// Container name
#[serde(default)]
pub container_name: String,
/// Service status: "running", "frozen", "stopped", "locked"
#[serde(default)]
pub status: String,
/// Exposed ports
#[serde(default)]
pub ports: Vec<u16>,
/// Custom domains
#[serde(default)]
pub domains: Vec<String>,
/// Network mode
#[serde(default)]
pub network_mode: String,
/// Number of vCPUs
#[serde(default)]
pub vcpu: u32,
/// Memory in MB
#[serde(default)]
pub memory_mb: u32,
/// Whether the service is locked (cannot be modified)
#[serde(default)]
pub locked: bool,
/// Whether the service automatically unfreezes on incoming HTTP requests
#[serde(default)]
pub unfreeze_on_demand: bool,
/// Public URL for the service
#[serde(default)]
pub url: String,
/// Created timestamp
#[serde(default)]
pub created_at: String,
}
/// Result of shell command execution in a session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellResult {
/// Command output (stdout + stderr)
#[serde(default)]
pub output: String,
/// Exit code
#[serde(default)]
pub exit_code: i32,
}
/// Result of validating API keys
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeysValid {
/// Whether the keys are valid
pub valid: bool,
/// Account ID associated with the keys
#[serde(default)]
pub account_id: String,
/// Account email (if available)
#[serde(default)]
pub email: String,
/// Account plan/tier
#[serde(default)]
pub plan: String,
/// Error message if invalid
#[serde(default)]
pub error: String,
}
/// Options for creating a session
#[derive(Debug, Clone, Default)]
pub struct SessionCreateOptions {
/// Network mode: "zerotrust" (default) or "semitrusted"
pub network_mode: Option<String>,
/// Shell to use (e.g., "bash", "python3")
pub shell: Option<String>,
/// Number of vCPUs (default: 1)
pub vcpu: Option<u32>,
/// Whether to use tmux multiplexer
pub tmux: Option<bool>,
/// Whether to use screen multiplexer
pub screen: Option<bool>,
}
/// Options for creating a service
#[derive(Debug, Clone, Default)]
pub struct ServiceCreateOptions {
/// Network mode: "zerotrust" (default) or "semitrusted"
pub network_mode: Option<String>,
/// Number of vCPUs (default: 1)
pub vcpu: Option<u32>,
/// Custom domains for the service
pub domains: Option<Vec<String>>,
/// Bootstrap script content
pub bootstrap: Option<String>,
/// Bootstrap script URL
pub bootstrap_url: Option<String>,
/// Whether to enable automatic unfreezing on incoming HTTP requests
pub unfreeze_on_demand: Option<bool>,
}
/// Options for updating a service
#[derive(Debug, Clone, Default)]
pub struct ServiceUpdateOptions {
/// New service name
pub name: Option<String>,
/// New ports
pub ports: Option<Vec<u16>>,
/// New domains
pub domains: Option<Vec<String>>,
/// New vCPU count
pub vcpu: Option<u32>,
}
/// Options for AI image generation
#[derive(Debug, Clone, Default)]
pub struct ImageOptions {
/// Model to use (optional)
pub model: Option<String>,
/// Image size (default: "1024x1024")
pub size: Option<String>,
/// Quality: "standard" or "hd" (default: "standard")
pub quality: Option<String>,
/// Number of images to generate (default: 1)
pub n: Option<i32>,
}
/// Result of image generation
#[derive(Debug, Clone, Deserialize)]
pub struct ImageResult {
/// Generated images (base64 or URLs)
pub images: Vec<String>,
/// Timestamp when images were created
pub created_at: String,
}
/// LXD container image information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LxdImage {
/// Image ID
pub image_id: String,
/// Image name
#[serde(default)]
pub name: String,
/// Image description
#[serde(default)]
pub description: String,
/// Source type: "session" or "service"
#[serde(default)]
pub source_type: String,
/// Source ID (session_id or service_id)
#[serde(default)]
pub source_id: String,
/// Image visibility: "private", "shared", or "public"
#[serde(default)]
pub visibility: String,
/// Whether the image is locked (cannot be modified/deleted)
#[serde(default)]
pub locked: bool,
/// Owner API key
#[serde(default)]
pub owner_key: String,
/// List of API keys with access to this image
#[serde(default)]
pub trusted_keys: Vec<String>,
/// Created timestamp
#[serde(default)]
pub created_at: String,
/// Size in bytes
#[serde(default)]
pub size_bytes: u64,
}
/// Result of spawning a service from an image
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnFromImageResult {
/// Service ID of the spawned service
pub service_id: String,
/// Service name
#[serde(default)]
pub name: String,
/// Service URL
#[serde(default)]
pub url: String,
/// Service status
#[serde(default)]
pub status: String,
}
// =============================================================================
// Internal Response Types
// =============================================================================
#[derive(Debug, Deserialize)]
struct ExecuteResponse {
job_id: String,
status: String,
#[serde(default)]
output: String,
#[serde(default)]
exit_code: i32,
#[serde(default)]
execution_time_ms: u64,
}
#[derive(Debug, Deserialize)]
struct JobsListResponse {
jobs: Vec<Job>,
}
#[derive(Debug, Deserialize)]
struct LanguagesResponse {
languages: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SnapshotsListResponse {
snapshots: Vec<Snapshot>,
}
#[derive(Debug, Deserialize)]
struct SnapshotCreateResponse {
snapshot_id: String,
#[serde(default)]
name: String,
#[serde(default)]
source_type: String,
#[serde(default)]
source_id: String,
#[serde(default)]
hot: bool,
#[serde(default)]
created_at: String,
#[serde(default)]
size_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct LanguagesCache {
languages: Vec<String>,
timestamp: u64,
}
#[derive(Debug, Deserialize)]
struct SessionsListResponse {
sessions: Vec<Session>,
}
#[derive(Debug, Deserialize)]
struct ServicesListResponse {
services: Vec<Service>,
}
#[derive(Debug, Deserialize)]
struct EnvResponse {
env: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct EnvExportResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ImagesListResponse {
images: Vec<LxdImage>,
}
#[derive(Debug, Deserialize)]
struct TrustedKeysResponse {
trusted_keys: Vec<String>,
}
// =============================================================================
// Language Detection
// =============================================================================
/// Language extension mapping
fn get_language_map() -> HashMap<&'static str, &'static str> {
[
("py", "python"),
("js", "javascript"),
("ts", "typescript"),
("rb", "ruby"),
("php", "php"),
("pl", "perl"),
("sh", "bash"),
("r", "r"),
("lua", "lua"),
("go", "go"),
("rs", "rust"),
("c", "c"),
("cpp", "cpp"),
("cc", "cpp"),
("cxx", "cpp"),
("java", "java"),
("kt", "kotlin"),
("m", "objc"),
("cs", "csharp"),
("fs", "fsharp"),
("hs", "haskell"),
("ml", "ocaml"),
("clj", "clojure"),
("scm", "scheme"),
("ss", "scheme"),
("erl", "erlang"),
("ex", "elixir"),
("exs", "elixir"),
("jl", "julia"),
("d", "d"),
("nim", "nim"),
("zig", "zig"),
("v", "v"),
("cr", "crystal"),
("dart", "dart"),
("groovy", "groovy"),
("f90", "fortran"),
("f95", "fortran"),
("lisp", "commonlisp"),
("lsp", "commonlisp"),
("cob", "cobol"),
("tcl", "tcl"),
("raku", "raku"),
("pro", "prolog"),
("p", "prolog"),
("4th", "forth"),
("forth", "forth"),
("fth", "forth"),
]
.into_iter()
.collect()
}
/// Detect programming language from filename extension.
///
/// # Arguments
/// * `filename` - Filename to detect language from (e.g., "script.py")
///
/// # Returns
/// Language identifier (e.g., "python") or None if unknown
///
/// # Examples
/// ```
/// use un::detect_language;
///
/// assert_eq!(detect_language("hello.py"), Some("python"));
/// assert_eq!(detect_language("script.js"), Some("javascript"));
/// assert_eq!(detect_language("main.go"), Some("go"));
/// assert_eq!(detect_language("unknown"), None);
/// ```
pub fn detect_language(filename: &str) -> Option<&'static str> {
let ext = filename.rsplit('.').next()?;
if ext == filename {
return None; // No extension found
}
let ext_lower = ext.to_lowercase();
get_language_map().get(ext_lower.as_str()).copied()
}
// =============================================================================
// Credentials Resolution
// =============================================================================
/// Get the ~/.unsandbox directory path
fn get_unsandbox_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".unsandbox"))
}
/// Ensure ~/.unsandbox directory exists
fn ensure_unsandbox_dir() -> Option<PathBuf> {
let dir = get_unsandbox_dir()?;
fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Load credentials from a CSV file (public_key,secret_key per line)
fn load_credentials_from_csv(path: &PathBuf, account_index: usize) -> Option<Credentials> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut current_index = 0;
for line in reader.lines().map_while(|l| l.ok()) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if current_index == account_index {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2 {
let pk = parts[0].trim();
let sk = parts[1].trim();
if pk.starts_with("unsb-pk-") && sk.starts_with("unsb-sk-") {
return Some(Credentials::new(pk, sk));
}
}
}
current_index += 1;
}
None
}
/// Resolve credentials using 4-tier priority system.
///
/// # Priority
/// 1. Function arguments (if both provided)
/// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
/// 3. ~/.unsandbox/accounts.csv
/// 4. ./accounts.csv
///
/// # Arguments
/// * `public_key` - Optional public key from function argument
/// * `secret_key` - Optional secret key from function argument
///
/// # Returns
/// Credentials if found, UnsandboxError::NoCredentials otherwise
///
/// # Examples
/// ```ignore
/// // Use environment variables or config file
/// let creds = resolve_credentials(None, None)?;
///
/// // Use explicit credentials
/// let creds = resolve_credentials(
/// Some("unsb-pk-xxxx"),
/// Some("unsb-sk-xxxx")
/// )?;
/// ```
pub fn resolve_credentials(
public_key: Option<&str>,
secret_key: Option<&str>,
) -> Result<Credentials> {
// Tier 1: Function arguments
if let (Some(pk), Some(sk)) = (public_key, secret_key) {
if !pk.is_empty() && !sk.is_empty() {
return Ok(Credentials::new(pk, sk));
}
}
// Tier 2: Environment variables
let env_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok();
let env_sk = env::var("UNSANDBOX_SECRET_KEY").ok();
if let (Some(pk), Some(sk)) = (env_pk, env_sk) {
if !pk.is_empty() && !sk.is_empty() {
return Ok(Credentials::new(pk, sk));
}
}
// Determine account index
let account_index: usize = env::var("UNSANDBOX_ACCOUNT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// Tier 3: ~/.unsandbox/accounts.csv
if let Some(dir) = get_unsandbox_dir() {
let csv_path = dir.join("accounts.csv");
if let Some(creds) = load_credentials_from_csv(&csv_path, account_index) {
return Ok(creds);
}
}
// Tier 4: ./accounts.csv
let local_csv = PathBuf::from("accounts.csv");
if let Some(creds) = load_credentials_from_csv(&local_csv, account_index) {
return Ok(creds);
}
Err(UnsandboxError::NoCredentials)
}
// =============================================================================
// HMAC Signing
// =============================================================================
/// Sign a request using HMAC-SHA256.
///
/// Message format: "timestamp:METHOD:path:body"
fn sign_request(secret_key: &str, timestamp: u64, method: &str, path: &str, body: &str) -> String {
let message = format!("{}:{}:{}:{}", timestamp, method, path, body);
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
/// Get current Unix timestamp in seconds
fn get_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs()
}
// =============================================================================
// HTTP Client
// =============================================================================
/// Make an authenticated HTTP request to the API
fn make_request<T: for<'de> Deserialize<'de>>(
method: &str,
path: &str,
creds: &Credentials,
body: Option<&impl Serialize>,
) -> Result<T> {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let body_str = match body {
Some(b) => serde_json::to_string(b)?,
None => String::new(),
};
let signature = sign_request(&creds.secret_key, timestamp, method, path, &body_str);
let mut request = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PATCH" => client.patch(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
request = request
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.header("User-Agent", "un-rust-sync/2.0");
if !body_str.is_empty() {
request = request.body(body_str);
}
let response = request.send()?;
let status = response.status().as_u16();
let response_text = response.text()?;
if status < 200 || status >= 300 {
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let result: T = serde_json::from_str(&response_text)?;
Ok(result)
}
/// Make an authenticated HTTP request that may require sudo OTP confirmation
fn make_destructive_request<T: for<'de> Deserialize<'de>>(
method: &str,
path: &str,
creds: &Credentials,
body: Option<&impl Serialize>,
) -> Result<T> {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let body_str = match body {
Some(b) => serde_json::to_string(b)?,
None => String::new(),
};
let signature = sign_request(&creds.secret_key, timestamp, method, path, &body_str);
let mut request = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PATCH" => client.patch(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
request = request
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.header("User-Agent", "un-rust-sync/2.0");
if !body_str.is_empty() {
request = request.body(body_str.clone());
}
let response = request.send()?;
let status = response.status().as_u16();
let response_text = response.text()?;
// Handle 428 sudo challenge
if status == 428 {
return handle_sudo_challenge(method, path, creds, body, &response_text);
}
if status < 200 || status >= 300 {
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let result: T = serde_json::from_str(&response_text)?;
Ok(result)
}
/// Handle 428 sudo OTP challenge - prompts user for OTP and retries request
fn handle_sudo_challenge<T: for<'de> Deserialize<'de>>(
method: &str,
path: &str,
creds: &Credentials,
body: Option<&impl Serialize>,
response_text: &str,
) -> Result<T> {
// Extract challenge_id from response
#[derive(Deserialize)]
struct ChallengeResponse {
challenge_id: Option<String>,
}
let challenge: ChallengeResponse = serde_json::from_str(response_text)
.unwrap_or(ChallengeResponse { challenge_id: None });
let challenge_id = challenge.challenge_id.unwrap_or_default();
// Prompt user for OTP
eprintln!("\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m");
eprint!("Enter OTP: ");
io::stderr().flush().ok();
let mut otp = String::new();
io::stdin().read_line(&mut otp).map_err(|e| UnsandboxError::IoError(e))?;
let otp = otp.trim();
if otp.is_empty() {
return Err(UnsandboxError::ApiError {
status: 428,
message: "Operation cancelled".to_string(),
});
}
// Retry the request with sudo headers
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let body_str = match body {
Some(b) => serde_json::to_string(b)?,
None => String::new(),
};
let signature = sign_request(&creds.secret_key, timestamp, method, path, &body_str);
let mut request = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PATCH" => client.patch(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
request = request
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.header("User-Agent", "un-rust-sync/2.0")
.header("X-Sudo-OTP", otp)
.header("X-Sudo-Challenge", &challenge_id);
if !body_str.is_empty() {
request = request.body(body_str);
}
let response = request.send()?;
let status = response.status().as_u16();
let response_text = response.text()?;
if status < 200 || status >= 300 {
// Try to extract error message
#[derive(Deserialize)]
struct ErrorResponse {
error: Option<String>,
}
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&response_text) {
if let Some(err_msg) = err_resp.error {
return Err(UnsandboxError::ApiError {
status,
message: err_msg,
});
}
}
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
eprintln!("\x1b[32mOperation completed successfully\x1b[0m");
let result: T = serde_json::from_str(&response_text)?;
Ok(result)
}
// =============================================================================
// Languages Cache
// =============================================================================
/// Get path to languages cache file
fn get_languages_cache_path() -> Option<PathBuf> {
get_unsandbox_dir().map(|d| d.join("languages.json"))
}
/// Load languages from cache if valid (< 1 hour old)
fn load_languages_cache() -> Option<Vec<String>> {
let cache_path = get_languages_cache_path()?;
let content = fs::read_to_string(&cache_path).ok()?;
let cache: LanguagesCache = serde_json::from_str(&content).ok()?;
let now = get_timestamp();
if now - cache.timestamp < LANGUAGES_CACHE_TTL {
Some(cache.languages)
} else {
None
}
}
/// Save languages to cache
fn save_languages_cache(languages: &[String]) {
if let Some(cache_path) = get_languages_cache_path() {
let _ = ensure_unsandbox_dir();
let cache = LanguagesCache {
languages: languages.to_vec(),
timestamp: get_timestamp(),
};
if let Ok(content) = serde_json::to_string_pretty(&cache) {
let _ = fs::write(cache_path, content);
}
}
}
// =============================================================================
// Public API Functions
// =============================================================================
/// Execute code synchronously (blocks until completion).
///
/// # Arguments
/// * `language` - Programming language (e.g., "python", "javascript")
/// * `code` - Source code to execute
/// * `creds` - API credentials
///
/// # Returns
/// ExecuteResult with output and exit code
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = execute_code("python", r#"print("Hello, World!")"#, &creds)?;
/// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code);
/// ```
pub fn execute_code(language: &str, code: &str, creds: &Credentials) -> Result<ExecuteResult> {
let body = serde_json::json!({
"language": language,
"code": code
});
let response: ExecuteResponse = make_request("POST", "/execute", creds, Some(&body))?;
// If job is still pending/running, poll until completion
if response.status == "pending" || response.status == "running" {
return wait_for_job(&response.job_id, creds, None);
}
Ok(ExecuteResult {
job_id: response.job_id,
status: response.status,
output: response.output,
exit_code: response.exit_code,
execution_time_ms: response.execution_time_ms,
})
}
/// Execute code asynchronously (returns immediately with job_id).
///
/// # Arguments
/// * `language` - Programming language
/// * `code` - Source code to execute
/// * `creds` - API credentials
///
/// # Returns
/// Job ID string for polling
///
/// # Examples
/// ```ignore
/// let job_id = execute_async("python", "import time; time.sleep(5); print('done')", &creds)?;
/// // Do other work...
/// let result = wait_for_job(&job_id, &creds, None)?;
/// ```
pub fn execute_async(language: &str, code: &str, creds: &Credentials) -> Result<String> {
let body = serde_json::json!({
"language": language,
"code": code
});
let response: ExecuteResponse = make_request("POST", "/execute", creds, Some(&body))?;
Ok(response.job_id)
}
/// Get current status/result of a job (single poll, no waiting).
///
/// # Arguments
/// * `job_id` - Job ID from execute_async
/// * `creds` - API credentials
///
/// # Returns
/// JobStatus with current state
pub fn get_job(job_id: &str, creds: &Credentials) -> Result<JobStatus> {
let path = format!("/jobs/{}", job_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Wait for job completion with exponential backoff polling.
///
/// Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
///
/// # Arguments
/// * `job_id` - Job ID from execute_async
/// * `creds` - API credentials
/// * `timeout` - Optional timeout in seconds (default: 300)
///
/// # Returns
/// ExecuteResult when job completes
///
/// # Errors
/// Returns UnsandboxError::Timeout if job doesn't complete within timeout
pub fn wait_for_job(
job_id: &str,
creds: &Credentials,
timeout: Option<u64>,
) -> Result<ExecuteResult> {
let timeout_secs = timeout.unwrap_or(DEFAULT_TIMEOUT_SECS);
let start = std::time::Instant::now();
let mut poll_count = 0;
loop {
// Check timeout
if start.elapsed().as_secs() >= timeout_secs {
return Err(UnsandboxError::Timeout(timeout_secs));
}
// Sleep before polling
let delay_idx = poll_count.min(POLL_DELAYS_MS.len() - 1);
thread::sleep(Duration::from_millis(POLL_DELAYS_MS[delay_idx]));
poll_count += 1;
let status = get_job(job_id, creds)?;
match status.status.as_str() {
"completed" | "failed" | "timeout" | "cancelled" => {
return Ok(ExecuteResult {
job_id: status.job_id,
status: status.status,
output: status.output,
exit_code: status.exit_code,
execution_time_ms: status.execution_time_ms,
});
}
_ => continue, // Still running, continue polling
}
}
}
/// Cancel a running job.
///
/// # Arguments
/// * `job_id` - Job ID to cancel
/// * `creds` - API credentials
pub fn cancel_job(job_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/jobs/{}", job_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?;
Ok(())
}
/// List all jobs for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Job summaries
pub fn list_jobs(creds: &Credentials) -> Result<Vec<Job>> {
let response: JobsListResponse = make_request("GET", "/jobs", creds, None::<&()>)?;
Ok(response.jobs)
}
/// Get list of supported programming languages.
///
/// Results are cached for 1 hour in ~/.unsandbox/languages.json
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of language identifiers
pub fn get_languages(creds: &Credentials) -> Result<Vec<String>> {
// Try cache first
if let Some(cached) = load_languages_cache() {
return Ok(cached);
}
let response: LanguagesResponse = make_request("GET", "/languages", creds, None::<&()>)?;
// Cache the result
save_languages_cache(&response.languages);
Ok(response.languages)
}
/// Create a snapshot of a session.
///
/// # Arguments
/// * `session_id` - Session ID to snapshot
/// * `creds` - API credentials
/// * `name` - Optional snapshot name
/// * `ephemeral` - If true, create a hot (ephemeral) snapshot
///
/// # Returns
/// Snapshot information
pub fn session_snapshot(
session_id: &str,
creds: &Credentials,
name: Option<&str>,
ephemeral: bool,
) -> Result<Snapshot> {
let mut body = serde_json::json!({
"session_id": session_id,
"hot": ephemeral
});
if let Some(n) = name {
body["name"] = serde_json::json!(n);
}
let response: SnapshotCreateResponse = make_request("POST", "/snapshots", creds, Some(&body))?;
Ok(Snapshot {
snapshot_id: response.snapshot_id,
name: response.name,
source_type: response.source_type,
source_id: response.source_id,
hot: response.hot,
created_at: response.created_at,
size_bytes: response.size_bytes,
})
}
/// Create a snapshot of a service.
///
/// # Arguments
/// * `service_id` - Service ID to snapshot
/// * `creds` - API credentials
/// * `name` - Optional snapshot name
///
/// # Returns
/// Snapshot information
pub fn service_snapshot(
service_id: &str,
creds: &Credentials,
name: Option<&str>,
) -> Result<Snapshot> {
let mut body = serde_json::json!({
"service_id": service_id,
"hot": false
});
if let Some(n) = name {
body["name"] = serde_json::json!(n);
}
let response: SnapshotCreateResponse = make_request("POST", "/snapshots", creds, Some(&body))?;
Ok(Snapshot {
snapshot_id: response.snapshot_id,
name: response.name,
source_type: response.source_type,
source_id: response.source_id,
hot: response.hot,
created_at: response.created_at,
size_bytes: response.size_bytes,
})
}
/// List all snapshots for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Snapshot information
pub fn list_snapshots(creds: &Credentials) -> Result<Vec<Snapshot>> {
let response: SnapshotsListResponse = make_request("GET", "/snapshots", creds, None::<&()>)?;
Ok(response.snapshots)
}
/// Get details of a specific snapshot.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// Snapshot information
pub fn get_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}", snapshot_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Restore a snapshot to create a new session or service.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to restore
/// * `creds` - API credentials
///
/// # Returns
/// RestoreResult with new session/service ID
pub fn restore_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<RestoreResult> {
let path = format!("/snapshots/{}/restore", snapshot_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Delete a snapshot.
///
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to delete
/// * `creds` - API credentials
pub fn delete_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/snapshots/{}", snapshot_id);
let _: serde_json::Value = make_destructive_request("DELETE", &path, creds, None::<&()>)?;
Ok(())
}
/// Lock a snapshot to prevent deletion.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to lock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Snapshot information
pub fn lock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}/lock", snapshot_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Unlock a snapshot to allow deletion.
///
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to unlock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Snapshot information
pub fn unlock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}/unlock", snapshot_id);
let body = serde_json::json!({});
make_destructive_request("POST", &path, creds, Some(&body))
}
/// Clone a snapshot to create a new snapshot with a different name.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to clone
/// * `name` - Name for the new snapshot
/// * `creds` - API credentials
///
/// # Returns
/// New Snapshot information
pub fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}/clone", snapshot_id);
let body = serde_json::json!({
"name": name
});
make_request("POST", &path, creds, Some(&body))
}
// =============================================================================
// Images API Functions (LXD Container Images)
// =============================================================================
/// Publish an LXD container image from a session or service.
///
/// Creates a reusable container image from an existing session or service.
/// The image can later be used to spawn new services.
///
/// # Arguments
/// * `source_type` - Source type: "session" or "service"
/// * `source_id` - ID of the session or service to publish from
/// * `name` - Name for the new image
/// * `description` - Optional description for the image
/// * `creds` - API credentials
///
/// # Returns
/// LxdImage information for the published image
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let image = image_publish("service", "svc-abc123", "my-app-image", Some("Production app v1.0"), &creds)?;
/// println!("Published image: {}", image.image_id);
/// ```
pub fn image_publish(
source_type: &str,
source_id: &str,
name: &str,
description: Option<&str>,
creds: &Credentials,
) -> Result<LxdImage> {
let mut body = serde_json::json!({
"source_type": source_type,
"source_id": source_id,
"name": name
});
if let Some(desc) = description {
body["description"] = serde_json::json!(desc);
}
make_request("POST", "/images", creds, Some(&body))
}
/// List LXD container images.
///
/// Returns images based on the filter type:
/// - None or "owned": Images owned by the authenticated user
/// - "shared": Images shared with the authenticated user
/// - "public": Publicly available images
/// - "all": All images accessible to the authenticated user
///
/// # Arguments
/// * `filter_type` - Optional filter: "owned", "shared", "public", or "all"
/// * `creds` - API credentials
///
/// # Returns
/// Vector of LxdImage information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // List owned images (default)
/// let my_images = list_images(None, &creds)?;
///
/// // List shared images
/// let shared = list_images(Some("shared"), &creds)?;
///
/// // List all accessible images
/// let all = list_images(Some("all"), &creds)?;
/// ```
pub fn list_images(filter_type: Option<&str>, creds: &Credentials) -> Result<Vec<LxdImage>> {
let path = match filter_type {
Some(ft) => format!("/images/{}", ft),
None => "/images".to_string(),
};
let response: ImagesListResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.images)
}
/// Get details of a specific LXD container image.
///
/// # Arguments
/// * `image_id` - Image ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// LxdImage information
pub fn get_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}", image_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Delete an LXD container image.
///
/// The image must be unlocked to be deleted.
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `image_id` - Image ID to delete
/// * `creds` - API credentials
pub fn delete_image(image_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/images/{}", image_id);
let _: serde_json::Value = make_destructive_request("DELETE", &path, creds, None::<&()>)?;
Ok(())
}
/// Lock an LXD container image to prevent modification or deletion.
///
/// # Arguments
/// * `image_id` - Image ID to lock
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn lock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}/lock", image_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Unlock an LXD container image to allow modification or deletion.
///
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `image_id` - Image ID to unlock
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}/unlock", image_id);
let body = serde_json::json!({});
make_destructive_request("POST", &path, creds, Some(&body))
}
/// Set the visibility of an LXD container image.
///
/// # Arguments
/// * `image_id` - Image ID to update
/// * `visibility` - New visibility: "private", "shared", or "public"
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Make image public
/// set_image_visibility("img-abc123", "public", &creds)?;
///
/// // Make image private
/// set_image_visibility("img-abc123", "private", &creds)?;
/// ```
pub fn set_image_visibility(
image_id: &str,
visibility: &str,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/visibility", image_id);
let body = serde_json::json!({
"visibility": visibility
});
make_request("POST", &path, creds, Some(&body))
}
/// Grant access to an LXD container image for another API key.
///
/// The image visibility must be "shared" for this to take effect.
///
/// # Arguments
/// * `image_id` - Image ID to grant access to
/// * `trusted_api_key` - API key (public key) to grant access
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn grant_image_access(
image_id: &str,
trusted_api_key: &str,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/grant", image_id);
let body = serde_json::json!({
"trusted_api_key": trusted_api_key
});
make_request("POST", &path, creds, Some(&body))
}
/// Revoke access to an LXD container image from another API key.
///
/// # Arguments
/// * `image_id` - Image ID to revoke access from
/// * `trusted_api_key` - API key (public key) to revoke access
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn revoke_image_access(
image_id: &str,
trusted_api_key: &str,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/revoke", image_id);
let body = serde_json::json!({
"trusted_api_key": trusted_api_key
});
make_request("POST", &path, creds, Some(&body))
}
/// List API keys that have access to an LXD container image.
///
/// # Arguments
/// * `image_id` - Image ID to list trusted keys for
/// * `creds` - API credentials
///
/// # Returns
/// Vector of trusted API keys (public keys)
pub fn list_image_trusted(image_id: &str, creds: &Credentials) -> Result<Vec<String>> {
let path = format!("/images/{}/trusted", image_id);
let response: TrustedKeysResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.trusted_keys)
}
/// Transfer ownership of an LXD container image to another API key.
///
/// After transfer, the original owner loses ownership but may retain
/// access if they are in the trusted keys list.
///
/// # Arguments
/// * `image_id` - Image ID to transfer
/// * `to_api_key` - API key (public key) of the new owner
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn transfer_image(image_id: &str, to_api_key: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}/transfer", image_id);
let body = serde_json::json!({
"to_api_key": to_api_key
});
make_request("POST", &path, creds, Some(&body))
}
/// Spawn a new service from an LXD container image.
///
/// Creates a new running service based on the specified image.
///
/// # Arguments
/// * `image_id` - Image ID to spawn from
/// * `name` - Name for the new service
/// * `ports` - Optional list of ports to expose
/// * `bootstrap` - Optional bootstrap script to run after spawn
/// * `network_mode` - Optional network mode: "zerotrust" or "semitrusted"
/// * `creds` - API credentials
///
/// # Returns
/// SpawnFromImageResult with the new service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Spawn with default options
/// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?;
/// println!("Service URL: {}", result.url);
///
/// // Spawn with custom ports and bootstrap
/// let result = spawn_from_image(
/// "img-abc123",
/// "web-server",
/// Some(&[80, 443]),
/// Some("systemctl start nginx"),
/// Some("semitrusted"),
/// &creds
/// )?;
/// ```
pub fn spawn_from_image(
image_id: &str,
name: &str,
ports: Option<&[u16]>,
bootstrap: Option<&str>,
network_mode: Option<&str>,
creds: &Credentials,
) -> Result<SpawnFromImageResult> {
let path = format!("/images/{}/spawn", image_id);
let mut body = serde_json::json!({
"name": name
});
if let Some(p) = ports {
body["ports"] = serde_json::json!(p);
}
if let Some(b) = bootstrap {
body["bootstrap"] = serde_json::json!(b);
}
if let Some(nm) = network_mode {
body["network_mode"] = serde_json::json!(nm);
}
make_request("POST", &path, creds, Some(&body))
}
/// Clone an LXD container image to create a new image with a different name.
///
/// # Arguments
/// * `image_id` - Image ID to clone
/// * `name` - Name for the new image
/// * `description` - Optional description for the new image
/// * `creds` - API credentials
///
/// # Returns
/// New LxdImage information
pub fn clone_image(
image_id: &str,
name: &str,
description: Option<&str>,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/clone", image_id);
let mut body = serde_json::json!({
"name": name
});
if let Some(desc) = description {
body["description"] = serde_json::json!(desc);
}
make_request("POST", &path, creds, Some(&body))
}
// =============================================================================
// Session API Functions
// =============================================================================
/// List all sessions for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Session information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let sessions = list_sessions(&creds)?;
/// for session in sessions {
/// println!("{}: {} ({})", session.session_id, session.container_name, session.status);
/// }
/// ```
pub fn list_sessions(creds: &Credentials) -> Result<Vec<Session>> {
let response: SessionsListResponse = make_request("GET", "/sessions", creds, None::<&()>)?;
Ok(response.sessions)
}
/// Get details of a specific session.
///
/// # Arguments
/// * `session_id` - Session ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// Session information
pub fn get_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}", session_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Create a new interactive session.
///
/// # Arguments
/// * `language` - Programming language/shell (e.g., "bash", "python")
/// * `creds` - API credentials
/// * `opts` - Optional session creation options
///
/// # Returns
/// Created Session information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Create a basic bash session
/// let session = create_session("bash", &creds, None)?;
///
/// // Create a session with options
/// let opts = SessionCreateOptions {
/// network_mode: Some("semitrusted".to_string()),
/// tmux: Some(true),
/// ..Default::default()
/// };
/// let session = create_session("bash", &creds, Some(opts))?;
/// ```
pub fn create_session(
language: &str,
creds: &Credentials,
opts: Option<SessionCreateOptions>,
) -> Result<Session> {
let mut body = serde_json::json!({
"language": language
});
if let Some(opts) = opts {
if let Some(network_mode) = opts.network_mode {
body["network_mode"] = serde_json::json!(network_mode);
}
if let Some(shell) = opts.shell {
body["shell"] = serde_json::json!(shell);
}
if let Some(vcpu) = opts.vcpu {
body["vcpu"] = serde_json::json!(vcpu);
}
if let Some(tmux) = opts.tmux {
body["tmux"] = serde_json::json!(tmux);
}
if let Some(screen) = opts.screen {
body["screen"] = serde_json::json!(screen);
}
}
make_request("POST", "/sessions", creds, Some(&body))
}
/// Delete (terminate) a session.
///
/// # Arguments
/// * `session_id` - Session ID to delete
/// * `creds` - API credentials
pub fn delete_session(session_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/sessions/{}", session_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?;
Ok(())
}
/// Freeze a session to save resources while preserving state.
///
/// Frozen sessions can be unfrozen later to resume work.
///
/// # Arguments
/// * `session_id` - Session ID to freeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub fn freeze_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/freeze", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Unfreeze a frozen session to resume work.
///
/// # Arguments
/// * `session_id` - Session ID to unfreeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub fn unfreeze_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/unfreeze", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Boost a session's resources (increase vCPU and memory).
///
/// Memory is derived from vCPU: vcpu * 2048MB.
///
/// # Arguments
/// * `session_id` - Session ID to boost
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub fn boost_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/boost", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Remove boost from a session (return to base resources).
///
/// # Arguments
/// * `session_id` - Session ID to unboost
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub fn unboost_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/unboost", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Execute a shell command in a session.
///
/// # Arguments
/// * `session_id` - Session ID to execute command in
/// * `command` - Command to execute
/// * `creds` - API credentials
///
/// # Returns
/// ShellResult with output and exit code
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = shell_session("session-123", "ls -la", &creds)?;
/// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code);
/// ```
pub fn shell_session(session_id: &str, command: &str, creds: &Credentials) -> Result<ShellResult> {
let path = format!("/sessions/{}/shell", session_id);
let body = serde_json::json!({
"command": command
});
make_request("POST", &path, creds, Some(&body))
}
// =============================================================================
// Service API Functions
// =============================================================================
/// List all services for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let services = list_services(&creds)?;
/// for service in services {
/// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url);
/// }
/// ```
pub fn list_services(creds: &Credentials) -> Result<Vec<Service>> {
let response: ServicesListResponse = make_request("GET", "/services", creds, None::<&()>)?;
Ok(response.services)
}
/// Create a new persistent service.
///
/// # Arguments
/// * `name` - Service name (used in URL: name.on.unsandbox.com)
/// * `ports` - Ports to expose
/// * `bootstrap` - Bootstrap script content to run on startup
/// * `creds` - API credentials
/// * `opts` - Optional service creation options
///
/// # Returns
/// Created Service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Create a simple web service
/// let service = create_service(
/// "myapp",
/// &[8080],
/// "python3 -m http.server 8080",
/// &creds,
/// None
/// )?;
/// println!("Service URL: {}", service.url);
///
/// // Create with options
/// let opts = ServiceCreateOptions {
/// network_mode: Some("semitrusted".to_string()),
/// vcpu: Some(2),
/// domains: Some(vec!["example.com".to_string()]),
/// ..Default::default()
/// };
/// let service = create_service("myapp", &[80, 443], bootstrap, &creds, Some(opts))?;
/// ```
pub fn create_service(
name: &str,
ports: &[u16],
bootstrap: &str,
creds: &Credentials,
opts: Option<ServiceCreateOptions>,
) -> Result<Service> {
let mut body = serde_json::json!({
"name": name,
"ports": ports,
"bootstrap": bootstrap
});
if let Some(opts) = opts {
if let Some(network_mode) = opts.network_mode {
body["network_mode"] = serde_json::json!(network_mode);
}
if let Some(vcpu) = opts.vcpu {
body["vcpu"] = serde_json::json!(vcpu);
}
if let Some(domains) = opts.domains {
body["domains"] = serde_json::json!(domains);
}
if let Some(bootstrap_url) = opts.bootstrap_url {
body["bootstrap_url"] = serde_json::json!(bootstrap_url);
}
if let Some(unfreeze_on_demand) = opts.unfreeze_on_demand {
body["unfreeze_on_demand"] = serde_json::json!(unfreeze_on_demand);
}
}
make_request("POST", "/services", creds, Some(&body))
}
/// Get details of a specific service.
///
/// # Arguments
/// * `service_id` - Service ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// Service information
pub fn get_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}", service_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Update a service's configuration.
///
/// # Arguments
/// * `service_id` - Service ID to update
/// * `creds` - API credentials
/// * `opts` - Update options (name, ports, domains, vcpu)
///
/// # Returns
/// Updated Service information
pub fn update_service(
service_id: &str,
creds: &Credentials,
opts: ServiceUpdateOptions,
) -> Result<Service> {
let path = format!("/services/{}", service_id);
let mut body = serde_json::Map::new();
if let Some(name) = opts.name {
body.insert("name".to_string(), serde_json::json!(name));
}
if let Some(ports) = opts.ports {
body.insert("ports".to_string(), serde_json::json!(ports));
}
if let Some(domains) = opts.domains {
body.insert("domains".to_string(), serde_json::json!(domains));
}
if let Some(vcpu) = opts.vcpu {
body.insert("vcpu".to_string(), serde_json::json!(vcpu));
}
make_request("PATCH", &path, creds, Some(&serde_json::Value::Object(body)))
}
/// Delete (destroy) a service.
///
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `service_id` - Service ID to delete
/// * `creds` - API credentials
pub fn delete_service(service_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/services/{}", service_id);
let _: serde_json::Value = make_destructive_request("DELETE", &path, creds, None::<&()>)?;
Ok(())
}
/// Freeze a service to save resources while preserving state.
///
/// # Arguments
/// * `service_id` - Service ID to freeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub fn freeze_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/freeze", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Unfreeze a frozen service to resume operation.
///
/// # Arguments
/// * `service_id` - Service ID to unfreeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub fn unfreeze_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/unfreeze", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Lock a service to prevent modifications.
///
/// # Arguments
/// * `service_id` - Service ID to lock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub fn lock_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/lock", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Unlock a service to allow modifications.
///
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `service_id` - Service ID to unlock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub fn unlock_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/unlock", service_id);
let body = serde_json::json!({});
make_destructive_request("POST", &path, creds, Some(&body))
}
/// Set the unfreeze_on_demand flag for a service.
///
/// When enabled, the service will automatically unfreeze when it receives
/// an incoming HTTP request while frozen.
///
/// # Arguments
/// * `service_id` - Service ID to update
/// * `enabled` - Whether to enable automatic unfreezing on demand
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub fn set_unfreeze_on_demand(service_id: &str, enabled: bool, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}", service_id);
let body = serde_json::json!({
"unfreeze_on_demand": enabled
});
make_request("PATCH", &path, creds, Some(&body))
}
/// Get bootstrap logs for a service.
///
/// # Arguments
/// * `service_id` - Service ID to get logs for
/// * `all` - If true, get all logs; if false, get last 9000 lines
/// * `creds` - API credentials
///
/// # Returns
/// Log content as string
pub fn get_service_logs(service_id: &str, all: bool, creds: &Credentials) -> Result<String> {
let path = if all {
format!("/services/{}/logs?all=true", service_id)
} else {
format!("/services/{}/logs", service_id)
};
#[derive(Deserialize)]
struct LogsResponse {
#[serde(default)]
logs: String,
}
let response: LogsResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.logs)
}
/// Get environment variables for a service.
///
/// # Arguments
/// * `service_id` - Service ID to get env for
/// * `creds` - API credentials
///
/// # Returns
/// HashMap of environment variable key-value pairs
pub fn get_service_env(service_id: &str, creds: &Credentials) -> Result<HashMap<String, String>> {
let path = format!("/services/{}/env", service_id);
let response: EnvResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.env)
}
/// Set environment variables for a service.
///
/// # Arguments
/// * `service_id` - Service ID to set env for
/// * `env` - HashMap of environment variable key-value pairs
/// * `creds` - API credentials
pub fn set_service_env(
service_id: &str,
env: &HashMap<String, String>,
creds: &Credentials,
) -> Result<()> {
let path = format!("/services/{}/env", service_id);
// Convert to .env format
let content: String = env
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("\n");
// Use PUT with text/plain content type
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let signature = sign_request(&creds.secret_key, timestamp, "PUT", &path, &content);
let response = client
.put(&url)
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "text/plain")
.header("User-Agent", "un-rust-sync/2.0")
.body(content)
.send()?;
let status = response.status().as_u16();
if status < 200 || status >= 300 {
let response_text = response.text()?;
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
Ok(())
}
/// Delete environment variables for a service.
///
/// # Arguments
/// * `service_id` - Service ID to delete env for
/// * `keys` - List of environment variable keys to delete
/// * `creds` - API credentials
pub fn delete_service_env(
service_id: &str,
keys: &[&str],
creds: &Credentials,
) -> Result<()> {
let path = format!("/services/{}/env", service_id);
let body = serde_json::json!({
"keys": keys
});
let _: serde_json::Value = make_request("DELETE", &path, creds, Some(&body))?;
Ok(())
}
/// Export environment variables for a service in .env format.
///
/// # Arguments
/// * `service_id` - Service ID to export env for
/// * `creds` - API credentials
///
/// # Returns
/// Environment variables in .env format string
pub fn export_service_env(service_id: &str, creds: &Credentials) -> Result<String> {
let path = format!("/services/{}/env/export", service_id);
let body = serde_json::json!({});
let response: EnvExportResponse = make_request("POST", &path, creds, Some(&body))?;
Ok(response.content)
}
/// Redeploy a service with a new bootstrap script.
///
/// # Arguments
/// * `service_id` - Service ID to redeploy
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub fn redeploy_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/redeploy", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Resize a service by changing its vCPU count.
///
/// # Arguments
/// * `service_id` - Service ID to resize
/// * `vcpu` - New vCPU count (1-8)
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let service = resize_service("service-123", 4, &creds)?;
/// println!("Service now has {} vCPUs", service.vcpu);
/// ```
pub fn resize_service(service_id: &str, vcpu: u32, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/resize", service_id);
let body = serde_json::json!({
"vcpu": vcpu
});
make_request("POST", &path, creds, Some(&body))
}
/// Execute a command in a service container.
///
/// # Arguments
/// * `service_id` - Service ID to execute command in
/// * `command` - Command to execute
/// * `creds` - API credentials
///
/// # Returns
/// ExecuteResult with output and exit code
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = execute_in_service("service-123", "ls -la /app", &creds)?;
/// println!("Output: {}", result.output);
/// ```
pub fn execute_in_service(
service_id: &str,
command: &str,
creds: &Credentials,
) -> Result<ExecuteResult> {
let path = format!("/services/{}/execute", service_id);
let body = serde_json::json!({
"command": command,
"timeout": 30000
});
make_request("POST", &path, creds, Some(&body))
}
// =============================================================================
// Key Validation API Functions
// =============================================================================
/// Validate API keys.
///
/// # Arguments
/// * `creds` - API credentials to validate
///
/// # Returns
/// KeysValid with validation result and account info
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = validate_keys(&creds)?;
/// if result.valid {
/// println!("Keys valid for account: {}", result.account_id);
/// } else {
/// println!("Invalid keys: {}", result.error);
/// }
/// ```
pub fn validate_keys(creds: &Credentials) -> Result<KeysValid> {
// Note: This endpoint is on the portal, not the API
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let url = "https://api.unsandbox.com/keys/validate";
let path = "/keys/validate";
let timestamp = get_timestamp();
let body_str = "";
let signature = sign_request(&creds.secret_key, timestamp, "POST", path, body_str);
let response = client
.post(url)
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.header("User-Agent", "un-rust-sync/2.0")
.body("")
.send()?;
let status = response.status().as_u16();
let response_text = response.text()?;
if status < 200 || status >= 300 {
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let result: KeysValid = serde_json::from_str(&response_text)?;
Ok(result)
}
// =============================================================================
// AI Image Generation API Functions
// =============================================================================
/// Generate images from a text prompt using AI.
///
/// # Arguments
/// * `prompt` - Text description of the image to generate
/// * `creds` - API credentials
/// * `opts` - Optional generation parameters
///
/// # Returns
/// * `Result<ImageResult>` - Generated images
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = image("A sunset over mountains", &creds, None)?;
/// for img in result.images {
/// println!("Image: {}", img);
/// }
///
/// // With options
/// let opts = ImageOptions {
/// size: Some("512x512".to_string()),
/// quality: Some("hd".to_string()),
/// n: Some(2),
/// ..Default::default()
/// };
/// let result = image("A futuristic city", &creds, Some(opts))?;
/// ```
pub fn image(prompt: &str, creds: &Credentials, opts: Option<ImageOptions>) -> Result<ImageResult> {
let opts = opts.unwrap_or_default();
let payload = serde_json::json!({
"prompt": prompt,
"size": opts.size.unwrap_or_else(|| "1024x1024".to_string()),
"quality": opts.quality.unwrap_or_else(|| "standard".to_string()),
"n": opts.n.unwrap_or(1),
"model": opts.model,
});
make_request("POST", "/image", creds, Some(&payload))
}
// =============================================================================
// PaaS Logs API Functions
// =============================================================================
/// Options for fetching logs.
#[derive(Debug, Default)]
pub struct LogsFetchOptions {
/// Number of lines to fetch (1-10000)
pub lines: Option<u32>,
/// Time window ("1m", "5m", "1h", "1d")
pub since: Option<String>,
/// Optional filter pattern
pub grep: Option<String>,
}
/// Response from logs fetch.
#[derive(Debug, Clone, Deserialize)]
pub struct LogsEntry {
/// Log source
#[serde(default)]
pub source: String,
/// Log line content
#[serde(default)]
pub line: String,
/// Timestamp
#[serde(default)]
pub timestamp: String,
}
/// Fetch batch logs from the portal.
///
/// # Arguments
/// * `source` - Log source ("all", "api", "portal", "pool/cammy", "pool/ai")
/// * `opts` - Fetch options (can be None for defaults)
/// * `creds` - API credentials
///
/// # Returns
/// Vector of log entries
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Fetch last 100 lines from all sources
/// let opts = LogsFetchOptions {
/// lines: Some(100),
/// since: Some("1h".to_string()),
/// ..Default::default()
/// };
/// let logs = logs_fetch("all", Some(opts), &creds)?;
/// for entry in logs {
/// println!("[{}] {}", entry.source, entry.line);
/// }
/// ```
pub fn logs_fetch(source: &str, opts: Option<LogsFetchOptions>, creds: &Credentials) -> Result<Vec<LogsEntry>> {
let mut path = String::from("/paas/logs");
let mut params = vec![];
if !source.is_empty() {
params.push(format!("source={}", source));
}
if let Some(opts) = opts {
if let Some(lines) = opts.lines {
params.push(format!("lines={}", lines));
}
if let Some(since) = opts.since {
params.push(format!("since={}", since));
}
if let Some(grep) = opts.grep {
params.push(format!("grep={}", grep));
}
}
if !params.is_empty() {
path = format!("{}?{}", path, params.join("&"));
}
#[derive(Deserialize)]
struct LogsResponse {
#[serde(default)]
logs: Vec<LogsEntry>,
}
let response: LogsResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.logs)
}
/// Callback function for streaming logs.
pub type LogCallback = fn(source: &str, line: &str);
/// Stream logs via Server-Sent Events.
///
/// This function blocks until the stream is closed or an error occurs.
/// Note: This is a simplified implementation that reads until EOF.
///
/// # Arguments
/// * `source` - Log source ("all", "api", "portal", "pool/cammy", "pool/ai")
/// * `grep` - Optional filter pattern (empty string for no filter)
/// * `callback` - Function called for each log line
/// * `creds` - API credentials
///
/// # Returns
/// Ok(()) on clean shutdown, error on failure
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// fn handle_log(source: &str, line: &str) {
/// println!("[{}] {}", source, line);
/// }
///
/// logs_stream("all", "", handle_log, &creds)?;
/// ```
pub fn logs_stream(source: &str, grep: &str, callback: LogCallback, creds: &Credentials) -> Result<()> {
let mut path = String::from("/paas/logs/stream");
let mut params = vec![];
if !source.is_empty() {
params.push(format!("source={}", source));
}
if !grep.is_empty() {
params.push(format!("grep={}", grep));
}
if !params.is_empty() {
path = format!("{}?{}", path, params.join("&"));
}
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let signature = sign_request(&creds.secret_key, timestamp, "GET", &path, "");
let client = reqwest::blocking::Client::builder()
.timeout(None) // No timeout for streaming
.build()?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Accept", "text/event-stream")
.header("User-Agent", "un-rust-sync/2.0")
.send()?;
let status = response.status().as_u16();
if status < 200 || status >= 300 {
let response_text = response.text()?;
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let reader = BufReader::new(response);
let mut current_source = source.to_string();
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
// Parse SSE format
if let Some(event) = line.strip_prefix("event:") {
current_source = event.trim().to_string();
} else if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
if !data.is_empty() {
callback(¤t_source, data);
}
}
}
Ok(())
}
// =============================================================================
// Utility Functions
// =============================================================================
/// SDK version string
pub const SDK_VERSION: &str = "4.2.0";
/// Compute HMAC-SHA256 signature for a message.
///
/// # Arguments
/// * `secret_key` - Secret key for signing
/// * `message` - Message to sign
///
/// # Returns
/// Lowercase hex-encoded signature string
///
/// # Examples
/// ```ignore
/// let sig = hmac_sign("my-secret", "hello");
/// println!("Signature: {}", sig);
/// ```
pub fn hmac_sign(secret_key: &str, message: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
let result = mac.finalize();
hex::encode(result.into_bytes())
}
/// Check if the API is reachable and responding.
///
/// # Returns
/// true if healthy, false otherwise
///
/// # Examples
/// ```ignore
/// if health_check() {
/// println!("API is healthy");
/// } else {
/// println!("API is unreachable");
/// }
/// ```
pub fn health_check() -> bool {
let client = match reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
match client.get(format!("{}/health", API_BASE)).send() {
Ok(resp) => resp.status().as_u16() == 200,
Err(_) => false,
}
}
/// Get the SDK version string.
///
/// # Returns
/// Version string (e.g., "4.2.0")
pub fn version() -> &'static str {
SDK_VERSION
}
/// Thread-local storage for the last error message.
thread_local! {
static LAST_ERROR: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
/// Set the last error message (internal use).
pub fn set_last_error(msg: &str) {
LAST_ERROR.with(|e| {
*e.borrow_mut() = msg.to_string();
});
}
/// Get the last error message from the SDK.
///
/// # Returns
/// Last error message, or empty string if none
///
/// # Examples
/// ```ignore
/// if let Err(_) = some_operation() {
/// println!("Error: {}", last_error());
/// }
/// ```
pub fn last_error() -> String {
LAST_ERROR.with(|e| e.borrow().clone())
}
// =============================================================================
// CLI Exit Codes
// =============================================================================
/// Exit code for success
pub const EXIT_SUCCESS: i32 = 0;
/// Exit code for general error
pub const EXIT_ERROR: i32 = 1;
/// Exit code for invalid arguments
pub const EXIT_INVALID_ARGS: i32 = 2;
/// Exit code for authentication error
pub const EXIT_AUTH_ERROR: i32 = 3;
/// Exit code for API error
pub const EXIT_API_ERROR: i32 = 4;
/// Exit code for timeout
pub const EXIT_TIMEOUT: i32 = 5;
// =============================================================================
// CLI Implementation
// =============================================================================
/// CLI options parsed from command line arguments
#[derive(Debug, Default)]
struct CliOptions {
// Global options
shell: Option<String>, // -s, --shell
env_vars: Vec<(String, String)>, // -e, --env
files: Vec<String>, // -f, --file
file_paths: Vec<String>, // -F, --file-path
artifacts: bool, // -a, --artifacts
output_dir: Option<String>, // -o, --output
public_key: Option<String>, // -p, --public-key
secret_key: Option<String>, // -k, --secret-key
network: Option<String>, // -n, --network
vcpu: Option<u32>, // -v, --vcpu
yes: bool, // -y, --yes
help: bool, // -h, --help
// Command and positional args
command: Option<String>,
subcommand: Option<String>,
positional: Vec<String>,
// Session options
session_list: bool,
session_attach: Option<String>,
session_kill: Option<String>,
session_freeze: Option<String>,
session_unfreeze: Option<String>,
session_boost: Option<String>,
session_unboost: Option<String>,
session_snapshot: Option<String>,
session_tmux: bool,
session_screen: bool,
snapshot_name: Option<String>,
snapshot_hot: bool,
audit: bool,
// Service options
service_list: bool,
service_name: Option<String>,
service_ports: Option<String>,
service_domains: Option<String>,
service_type: Option<String>,
service_bootstrap: Option<String>,
service_bootstrap_file: Option<String>,
service_env_file: Option<String>,
service_info: Option<String>,
service_logs: Option<String>,
service_tail: Option<String>,
service_freeze: Option<String>,
service_unfreeze: Option<String>,
service_destroy: Option<String>,
service_lock: Option<String>,
service_unlock: Option<String>,
service_resize: Option<String>,
service_redeploy: Option<String>,
service_execute: Option<String>,
service_execute_cmd: Option<String>,
service_snapshot: Option<String>,
// Snapshot options
snapshot_list: bool,
snapshot_info: Option<String>,
snapshot_delete: Option<String>,
snapshot_lock: Option<String>,
snapshot_unlock: Option<String>,
snapshot_clone: Option<String>,
clone_type: Option<String>,
clone_name: Option<String>,
clone_shell: Option<String>,
clone_ports: Option<String>,
// Image options
image_list: bool,
image_info: Option<String>,
image_delete: Option<String>,
image_lock: Option<String>,
image_unlock: Option<String>,
image_publish: Option<String>,
image_source_type: Option<String>,
image_visibility_id: Option<String>,
image_visibility_mode: Option<String>,
image_spawn: Option<String>,
image_clone: Option<String>,
image_name: Option<String>,
image_ports: Option<String>,
// Languages options
languages_json: bool,
}
fn print_help() {
println!("un - unsandbox.com CLI (Rust sync)
USAGE:
un [OPTIONS] <source_file> Execute code file
un [OPTIONS] -s LANG 'code' Execute inline code
un session [OPTIONS] Interactive session
un service [OPTIONS] Manage services
un snapshot [OPTIONS] Manage snapshots
un image [OPTIONS] Manage images
un key Check API key
un languages [--json] List supported languages
GLOBAL OPTIONS:
-s, --shell LANG Language for inline code execution
-e, --env KEY=VAL Set environment variable (can repeat)
-f, --file FILE Add input file to /tmp/
-F, --file-path FILE Add input file with path preserved
-a, --artifacts Return compiled artifacts
-o, --output DIR Output directory for artifacts
-p, --public-key KEY API public key
-k, --secret-key KEY API secret key
-n, --network MODE Network mode: zerotrust or semitrusted
-v, --vcpu N vCPU count (1-8)
-y, --yes Skip confirmation prompts
-h, --help Show this help
SESSION COMMANDS:
un session Start interactive bash session
un session --shell python3 Start Python REPL
un session --tmux Persistent session with tmux
un session --screen Persistent session with screen
un session --list List active sessions
un session --attach ID Reconnect to session
un session --kill ID Terminate session
un session --freeze ID Pause session
un session --unfreeze ID Resume session
un session --boost ID Add resources
un session --unboost ID Remove boost
un session --snapshot ID Create snapshot
SERVICE COMMANDS:
un service --list List all services
un service --name NAME --ports P Create service
un service --info ID Get service details
un service --logs ID Get all logs
un service --tail ID Get last 9000 lines
un service --freeze ID Pause service
un service --unfreeze ID Resume service
un service --destroy ID Delete service
un service --lock ID Prevent deletion
un service --unlock ID Allow deletion
un service --execute ID 'cmd' Run command
un service --redeploy ID Re-run bootstrap
un service --snapshot ID Create snapshot
un service env status ID Show vault status
un service env set ID Set env vars
un service env export ID Export env vars
un service env delete ID Delete vault
SNAPSHOT COMMANDS:
un snapshot --list List all snapshots
un snapshot --info ID Get snapshot details
un snapshot --delete ID Delete snapshot
un snapshot --lock ID Prevent deletion
un snapshot --unlock ID Allow deletion
un snapshot --clone ID Clone snapshot
IMAGE COMMANDS:
un image --list List all images
un image --info ID Get image details
un image --delete ID Delete an image
un image --lock ID Lock image to prevent deletion
un image --unlock ID Unlock image
un image --publish ID --source-type T Publish from service/snapshot
un image --visibility ID MODE Set visibility (private/unlisted/public)
un image --spawn ID --name NAME Spawn service from image
un image --clone ID --name NAME Clone an image
LANGUAGES COMMAND:
un languages List supported languages (one per line)
un languages --json List as JSON array
EXAMPLES:
un script.py Execute Python script
un -s bash 'echo hello' Run inline bash command
un -n semitrusted crawler.py Execute with network access
un session --tmux Start persistent session
un service --name web --ports 80 --bootstrap 'python -m http.server 80'
");
}
fn parse_args(args: &[String]) -> CliOptions {
let mut opts = CliOptions::default();
let mut i = 1; // Skip program name
while i < args.len() {
let arg = &args[i];
match arg.as_str() {
"-h" | "--help" => {
opts.help = true;
i += 1;
}
"-s" | "--shell" => {
if i + 1 < args.len() {
opts.shell = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-e" | "--env" => {
if i + 1 < args.len() {
let kv = &args[i + 1];
if let Some(pos) = kv.find('=') {
let key = kv[..pos].to_string();
let val = kv[pos + 1..].to_string();
opts.env_vars.push((key, val));
}
i += 2;
} else {
i += 1;
}
}
"-f" | "--file" => {
if i + 1 < args.len() {
opts.files.push(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-F" | "--file-path" => {
if i + 1 < args.len() {
opts.file_paths.push(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-a" | "--artifacts" => {
opts.artifacts = true;
i += 1;
}
"-o" | "--output" => {
if i + 1 < args.len() {
opts.output_dir = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-p" | "--public-key" => {
if i + 1 < args.len() {
opts.public_key = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-k" | "--secret-key" => {
if i + 1 < args.len() {
opts.secret_key = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-n" | "--network" => {
if i + 1 < args.len() {
opts.network = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"-v" | "--vcpu" => {
if i + 1 < args.len() {
opts.vcpu = args[i + 1].parse().ok();
i += 2;
} else {
i += 1;
}
}
"-y" | "--yes" => {
opts.yes = true;
i += 1;
}
"-l" | "--list" => {
// Used by session, service, snapshot, image
opts.session_list = true;
opts.service_list = true;
opts.snapshot_list = true;
opts.image_list = true;
i += 1;
}
"--attach" => {
if i + 1 < args.len() {
opts.session_attach = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--kill" => {
if i + 1 < args.len() {
opts.session_kill = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--freeze" => {
if i + 1 < args.len() {
// Context-dependent: session or service
opts.session_freeze = Some(args[i + 1].clone());
opts.service_freeze = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--unfreeze" => {
if i + 1 < args.len() {
opts.session_unfreeze = Some(args[i + 1].clone());
opts.service_unfreeze = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--boost" => {
if i + 1 < args.len() {
opts.session_boost = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--unboost" => {
if i + 1 < args.len() {
opts.session_unboost = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--tmux" => {
opts.session_tmux = true;
i += 1;
}
"--screen" => {
opts.session_screen = true;
i += 1;
}
"--snapshot" => {
if i + 1 < args.len() {
opts.session_snapshot = Some(args[i + 1].clone());
opts.service_snapshot = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--snapshot-name" => {
if i + 1 < args.len() {
opts.snapshot_name = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--hot" => {
opts.snapshot_hot = true;
i += 1;
}
"--audit" => {
opts.audit = true;
i += 1;
}
"--name" => {
if i + 1 < args.len() {
opts.service_name = Some(args[i + 1].clone());
opts.clone_name = Some(args[i + 1].clone());
opts.image_name = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--ports" => {
if i + 1 < args.len() {
opts.service_ports = Some(args[i + 1].clone());
opts.clone_ports = Some(args[i + 1].clone());
opts.image_ports = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--domains" => {
if i + 1 < args.len() {
opts.service_domains = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--type" => {
if i + 1 < args.len() {
opts.service_type = Some(args[i + 1].clone());
opts.clone_type = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--bootstrap" => {
if i + 1 < args.len() {
opts.service_bootstrap = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--bootstrap-file" => {
if i + 1 < args.len() {
opts.service_bootstrap_file = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--env-file" => {
if i + 1 < args.len() {
opts.service_env_file = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--info" => {
if i + 1 < args.len() {
opts.service_info = Some(args[i + 1].clone());
opts.snapshot_info = Some(args[i + 1].clone());
opts.image_info = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--logs" => {
if i + 1 < args.len() {
opts.service_logs = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--tail" => {
if i + 1 < args.len() {
opts.service_tail = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--destroy" => {
if i + 1 < args.len() {
opts.service_destroy = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--lock" => {
if i + 1 < args.len() {
opts.service_lock = Some(args[i + 1].clone());
opts.snapshot_lock = Some(args[i + 1].clone());
opts.image_lock = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--unlock" => {
if i + 1 < args.len() {
opts.service_unlock = Some(args[i + 1].clone());
opts.snapshot_unlock = Some(args[i + 1].clone());
opts.image_unlock = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--resize" => {
if i + 1 < args.len() {
opts.service_resize = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--redeploy" => {
if i + 1 < args.len() {
opts.service_redeploy = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--execute" => {
if i + 1 < args.len() {
opts.service_execute = Some(args[i + 1].clone());
if i + 2 < args.len() && !args[i + 2].starts_with('-') {
opts.service_execute_cmd = Some(args[i + 2].clone());
i += 3;
} else {
i += 2;
}
} else {
i += 1;
}
}
"--delete" => {
if i + 1 < args.len() {
opts.snapshot_delete = Some(args[i + 1].clone());
opts.image_delete = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--clone" => {
if i + 1 < args.len() {
opts.snapshot_clone = Some(args[i + 1].clone());
opts.image_clone = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--json" => {
opts.languages_json = true;
i += 1;
}
"--publish" => {
if i + 1 < args.len() {
opts.image_publish = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--source-type" => {
if i + 1 < args.len() {
opts.image_source_type = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--visibility" => {
if i + 2 < args.len() {
opts.image_visibility_id = Some(args[i + 1].clone());
opts.image_visibility_mode = Some(args[i + 2].clone());
i += 3;
} else if i + 1 < args.len() {
opts.image_visibility_id = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
"--spawn" => {
if i + 1 < args.len() {
opts.image_spawn = Some(args[i + 1].clone());
i += 2;
} else {
i += 1;
}
}
_ => {
// Positional argument or subcommand
if opts.command.is_none() && !arg.starts_with('-') {
match arg.as_str() {
"session" | "service" | "snapshot" | "image" | "key" | "env" | "languages" => {
if opts.command.is_some() {
opts.subcommand = Some(arg.clone());
} else {
opts.command = Some(arg.clone());
}
}
_ => {
opts.positional.push(arg.clone());
}
}
} else if !arg.starts_with('-') {
opts.positional.push(arg.clone());
}
i += 1;
}
}
}
opts
}
fn get_credentials(opts: &CliOptions) -> Result<Credentials> {
resolve_credentials(
opts.public_key.as_deref(),
opts.secret_key.as_deref(),
)
}
fn format_session_list(sessions: &[Session]) {
if sessions.is_empty() {
println!("No active sessions.");
return;
}
println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "STATUS", "CREATED");
for s in sessions {
println!(
"{:<40} {:<20} {:<10} {}",
s.session_id, s.container_name, s.status, s.created_at
);
}
}
fn format_service_list(services: &[Service]) {
if services.is_empty() {
println!("No active services.");
return;
}
println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "STATUS", "URL");
for s in services {
println!(
"{:<40} {:<20} {:<10} {}",
s.service_id, s.name, s.status, s.url
);
}
}
fn format_snapshot_list(snapshots: &[Snapshot]) {
if snapshots.is_empty() {
println!("No snapshots.");
return;
}
println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "TYPE", "CREATED");
for s in snapshots {
println!(
"{:<40} {:<20} {:<10} {}",
s.snapshot_id, s.name, s.source_type, s.created_at
);
}
}
fn cmd_execute(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
// Determine language and code
let (language, code) = if let Some(ref shell) = opts.shell {
// Inline code: -s LANG 'code'
let code = opts.positional.first().cloned().unwrap_or_default();
(shell.clone(), code)
} else if let Some(ref file) = opts.positional.first() {
// File execution
let lang = match detect_language(file) {
Some(l) => l.to_string(),
None => {
eprintln!("Error: Cannot detect language from file: {}", file);
return EXIT_INVALID_ARGS;
}
};
let code = match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: Cannot read file '{}': {}", file, e);
return EXIT_ERROR;
}
};
(lang, code)
} else {
eprintln!("Error: No source file or inline code provided");
print_help();
return EXIT_INVALID_ARGS;
};
// Execute the code
match execute_code(&language, &code, &creds) {
Ok(result) => {
print!("{}", result.output);
println!("---");
println!("Exit code: {}", result.exit_code);
println!("Execution time: {}ms", result.execution_time_ms);
if result.exit_code != 0 {
EXIT_ERROR
} else {
EXIT_SUCCESS
}
}
Err(e) => {
eprintln!("Error: {}", e);
match e {
UnsandboxError::Timeout(_) => EXIT_TIMEOUT,
UnsandboxError::ApiError { .. } => EXIT_API_ERROR,
UnsandboxError::NoCredentials => EXIT_AUTH_ERROR,
_ => EXIT_ERROR,
}
}
}
}
fn cmd_session(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
// Handle session subcommands
if opts.session_list {
match list_sessions(&creds) {
Ok(sessions) => {
format_session_list(&sessions);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_attach {
match get_session(id, &creds) {
Ok(session) => {
println!("Session: {}", session.session_id);
println!("Container: {}", session.container_name);
println!("Status: {}", session.status);
println!("Shell: {}", session.shell);
println!("\nNote: Interactive attach requires terminal support not available in this SDK.");
println!("Use 'un session --attach {}' with the C CLI for full functionality.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_kill {
match delete_session(id, &creds) {
Ok(()) => {
println!("Session {} terminated.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_freeze {
match freeze_session(id, &creds) {
Ok(session) => {
println!("Session {} frozen.", session.session_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_unfreeze {
match unfreeze_session(id, &creds) {
Ok(session) => {
println!("Session {} unfrozen.", session.session_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_boost {
match boost_session(id, &creds) {
Ok(session) => {
println!("Session {} boosted.", session.session_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_unboost {
match unboost_session(id, &creds) {
Ok(session) => {
println!("Session {} unboosted.", session.session_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.session_snapshot {
match session_snapshot(id, &creds, opts.snapshot_name.as_deref(), opts.snapshot_hot) {
Ok(snapshot) => {
println!("Snapshot created: {}", snapshot.snapshot_id);
if !snapshot.name.is_empty() {
println!("Name: {}", snapshot.name);
}
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Create new session
let shell = opts.shell.clone().unwrap_or_else(|| "bash".to_string());
let session_opts = SessionCreateOptions {
network_mode: opts.network.clone(),
shell: Some(shell.clone()),
vcpu: opts.vcpu,
tmux: if opts.session_tmux { Some(true) } else { None },
screen: if opts.session_screen { Some(true) } else { None },
};
match create_session(&shell, &creds, Some(session_opts)) {
Ok(session) => {
println!("Session created: {}", session.session_id);
println!("Container: {}", session.container_name);
println!("Status: {}", session.status);
println!("\nNote: Interactive session requires terminal support not available in this SDK.");
println!("Use the C CLI for interactive sessions.");
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
fn cmd_service(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
// Handle service env subcommand
if opts.command.as_deref() == Some("service") {
if let Some(ref subcmd) = opts.subcommand {
if subcmd == "env" {
return cmd_service_env(opts, &creds);
}
}
// Check if first positional is "env"
if opts.positional.first().map(|s| s.as_str()) == Some("env") {
return cmd_service_env(opts, &creds);
}
}
// Handle service subcommands
if opts.service_list {
match list_services(&creds) {
Ok(services) => {
format_service_list(&services);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_info {
match get_service(id, &creds) {
Ok(service) => {
println!("Service ID: {}", service.service_id);
println!("Name: {}", service.name);
println!("Status: {}", service.status);
println!("URL: {}", service.url);
println!("Ports: {:?}", service.ports);
println!("Domains: {:?}", service.domains);
println!("vCPU: {}", service.vcpu);
println!("Memory: {} MB", service.memory_mb);
println!("Locked: {}", service.locked);
println!("Created: {}", service.created_at);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_logs {
match get_service_logs(id, true, &creds) {
Ok(logs) => {
print!("{}", logs);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_tail {
match get_service_logs(id, false, &creds) {
Ok(logs) => {
print!("{}", logs);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_freeze {
match freeze_service(id, &creds) {
Ok(service) => {
println!("Service {} frozen.", service.service_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_unfreeze {
match unfreeze_service(id, &creds) {
Ok(service) => {
println!("Service {} unfrozen.", service.service_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_destroy {
match delete_service(id, &creds) {
Ok(()) => {
println!("Service {} destroyed.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_lock {
match lock_service(id, &creds) {
Ok(service) => {
println!("Service {} locked.", service.service_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_unlock {
match unlock_service(id, &creds) {
Ok(service) => {
println!("Service {} unlocked.", service.service_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_redeploy {
match redeploy_service(id, &creds) {
Ok(service) => {
println!("Service {} redeployed.", service.service_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_execute {
let cmd = opts.service_execute_cmd.clone().unwrap_or_default();
if cmd.is_empty() {
eprintln!("Error: --execute requires a command");
return EXIT_INVALID_ARGS;
}
match execute_in_service(id, &cmd, &creds) {
Ok(result) => {
print!("{}", result.output);
return if result.exit_code == 0 { EXIT_SUCCESS } else { EXIT_ERROR };
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.service_snapshot {
match service_snapshot(id, &creds, opts.snapshot_name.as_deref()) {
Ok(snapshot) => {
println!("Snapshot created: {}", snapshot.snapshot_id);
if !snapshot.name.is_empty() {
println!("Name: {}", snapshot.name);
}
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Create new service
if let Some(ref name) = opts.service_name {
let ports_str = opts.service_ports.clone().unwrap_or_default();
let ports: Vec<u16> = ports_str
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if ports.is_empty() {
eprintln!("Error: --ports is required for service creation");
return EXIT_INVALID_ARGS;
}
let bootstrap = if let Some(ref cmd) = opts.service_bootstrap {
cmd.clone()
} else if let Some(ref file) = opts.service_bootstrap_file {
match fs::read_to_string(file) {
Ok(content) => content,
Err(e) => {
eprintln!("Error: Cannot read bootstrap file '{}': {}", file, e);
return EXIT_ERROR;
}
}
} else {
String::new()
};
let service_opts = ServiceCreateOptions {
network_mode: opts.network.clone(),
vcpu: opts.vcpu,
domains: opts.service_domains.as_ref().map(|d| {
d.split(',').map(|s| s.trim().to_string()).collect()
}),
..Default::default()
};
match create_service(name, &ports, &bootstrap, &creds, Some(service_opts)) {
Ok(service) => {
println!("Service created: {}", service.service_id);
println!("Name: {}", service.name);
println!("URL: {}", service.url);
println!("Status: {}", service.status);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// No action specified, show list
match list_services(&creds) {
Ok(services) => {
format_service_list(&services);
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
fn cmd_service_env(opts: &CliOptions, creds: &Credentials) -> i32 {
// Parse: un service env <action> <service_id>
// positional[0] = "env", positional[1] = action, positional[2] = service_id
let action = opts.positional.get(1).map(|s| s.as_str()).unwrap_or("");
let service_id = opts.positional.get(2).cloned().unwrap_or_default();
if service_id.is_empty() && action != "status" {
eprintln!("Error: Service ID required");
return EXIT_INVALID_ARGS;
}
match action {
"status" => {
if service_id.is_empty() {
eprintln!("Error: Service ID required");
return EXIT_INVALID_ARGS;
}
match get_service_env(&service_id, creds) {
Ok(env) => {
if env.is_empty() {
println!("No environment variables set.");
} else {
println!("Environment variables ({} total):", env.len());
for (k, _) in &env {
println!(" {}", k);
}
}
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
"set" => {
// Read from --env-file or stdin
let content = if let Some(ref file) = opts.service_env_file {
match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: Cannot read env file '{}': {}", file, e);
return EXIT_ERROR;
}
}
} else {
eprintln!("Error: --env-file required for 'set' command");
return EXIT_INVALID_ARGS;
};
let mut env = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find('=') {
let key = line[..pos].to_string();
let val = line[pos + 1..].to_string();
env.insert(key, val);
}
}
match set_service_env(&service_id, &env, creds) {
Ok(()) => {
println!("Environment variables set ({} variables).", env.len());
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
"export" => {
match export_service_env(&service_id, creds) {
Ok(content) => {
print!("{}", content);
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
"delete" => {
// Delete all env vars
match get_service_env(&service_id, creds) {
Ok(env) => {
let keys: Vec<&str> = env.keys().map(|s| s.as_str()).collect();
if keys.is_empty() {
println!("No environment variables to delete.");
return EXIT_SUCCESS;
}
match delete_service_env(&service_id, &keys, creds) {
Ok(()) => {
println!("Environment vault deleted.");
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
_ => {
eprintln!("Error: Unknown env command '{}'. Use: status, set, export, delete", action);
EXIT_INVALID_ARGS
}
}
}
fn cmd_snapshot(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
if opts.snapshot_list {
match list_snapshots(&creds) {
Ok(snapshots) => {
format_snapshot_list(&snapshots);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.snapshot_info {
// Get snapshot details - reuse list and filter
match list_snapshots(&creds) {
Ok(snapshots) => {
if let Some(snapshot) = snapshots.iter().find(|s| s.snapshot_id == *id) {
println!("Snapshot ID: {}", snapshot.snapshot_id);
println!("Name: {}", snapshot.name);
println!("Source Type: {}", snapshot.source_type);
println!("Source ID: {}", snapshot.source_id);
println!("Hot: {}", snapshot.hot);
println!("Size: {} bytes", snapshot.size_bytes);
println!("Created: {}", snapshot.created_at);
return EXIT_SUCCESS;
} else {
eprintln!("Error: Snapshot not found: {}", id);
return EXIT_API_ERROR;
}
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.snapshot_delete {
match delete_snapshot(id, &creds) {
Ok(()) => {
println!("Snapshot {} deleted.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.snapshot_lock {
match lock_snapshot(id, &creds) {
Ok(snapshot) => {
println!("Snapshot {} locked.", snapshot.snapshot_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.snapshot_unlock {
match unlock_snapshot(id, &creds) {
Ok(snapshot) => {
println!("Snapshot {} unlocked.", snapshot.snapshot_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
if let Some(ref id) = opts.snapshot_clone {
let name = opts.clone_name.clone().unwrap_or_else(|| format!("{}-clone", id));
match clone_snapshot(id, &name, &creds) {
Ok(snapshot) => {
println!("Snapshot cloned: {}", snapshot.snapshot_id);
println!("Name: {}", snapshot.name);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Default: list snapshots
match list_snapshots(&creds) {
Ok(snapshots) => {
format_snapshot_list(&snapshots);
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
fn format_image_list(images: &[LxdImage]) {
if images.is_empty() {
println!("No images.");
return;
}
println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "VISIBILITY", "CREATED");
for img in images {
println!(
"{:<40} {:<20} {:<10} {}",
img.image_id, img.name, img.visibility, img.created_at
);
}
}
fn cmd_image(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
// Handle --list
if opts.image_list || opts.service_list {
match list_images(None, &creds) {
Ok(images) => {
format_image_list(&images);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --info
if let Some(ref id) = opts.image_info {
match get_image(id, &creds) {
Ok(img) => {
println!("Image ID: {}", img.image_id);
println!("Name: {}", img.name);
println!("Description: {}", img.description);
println!("Source Type: {}", img.source_type);
println!("Source ID: {}", img.source_id);
println!("Visibility: {}", img.visibility);
println!("Locked: {}", img.locked);
println!("Size: {} bytes", img.size_bytes);
println!("Created: {}", img.created_at);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --delete
if let Some(ref id) = opts.image_delete {
match delete_image(id, &creds) {
Ok(()) => {
println!("Image {} deleted.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --lock
if let Some(ref id) = opts.image_lock {
match lock_image(id, &creds) {
Ok(()) => {
println!("Image {} locked.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --unlock
if let Some(ref id) = opts.image_unlock {
match unlock_image(id, &creds) {
Ok(()) => {
println!("Image {} unlocked.", id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --publish
if let Some(ref source_id) = opts.image_publish {
let source_type = match &opts.image_source_type {
Some(t) => t.as_str(),
None => {
eprintln!("Error: --publish requires --source-type (service or snapshot)");
return EXIT_INVALID_ARGS;
}
};
let name = opts.image_name.clone().unwrap_or_else(|| "".to_string());
match image_publish(source_type, source_id, &name, None, &creds) {
Ok(img) => {
println!("Image published: {}", img.image_id);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --visibility
if let Some(ref id) = opts.image_visibility_id {
let mode = match &opts.image_visibility_mode {
Some(m) => m.as_str(),
None => {
eprintln!("Error: --visibility requires a mode (private, unlisted, or public)");
return EXIT_INVALID_ARGS;
}
};
match set_image_visibility(id, mode, &creds) {
Ok(()) => {
println!("Image {} visibility set to {}.", id, mode);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --spawn
if let Some(ref id) = opts.image_spawn {
let name = opts.image_name.clone().unwrap_or_else(|| "spawned-service".to_string());
let ports_str = opts.image_ports.clone().unwrap_or_else(|| "".to_string());
let ports: Vec<u16> = ports_str
.split(',')
.filter_map(|p| p.trim().parse().ok())
.collect();
match spawn_from_image(id, &name, &ports, None, &creds) {
Ok(result) => {
println!("Service spawned: {}", result.service_id);
println!("Name: {}", result.name);
println!("URL: {}", result.url);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Handle --clone
if let Some(ref id) = opts.image_clone {
let name = opts.image_name.clone().unwrap_or_else(|| format!("{}-clone", id));
match clone_image(id, &name, None, &creds) {
Ok(img) => {
println!("Image cloned: {}", img.image_id);
println!("Name: {}", img.name);
return EXIT_SUCCESS;
}
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_API_ERROR;
}
}
}
// Default: list images
match list_images(None, &creds) {
Ok(images) => {
format_image_list(&images);
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
fn cmd_key(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
match validate_keys(&creds) {
Ok(result) => {
if result.valid {
println!("API keys valid.");
println!("Account ID: {}", result.account_id);
if !result.email.is_empty() {
println!("Email: {}", result.email);
}
if !result.plan.is_empty() {
println!("Plan: {}", result.plan);
}
EXIT_SUCCESS
} else {
eprintln!("Error: Invalid API keys");
if !result.error.is_empty() {
eprintln!("Details: {}", result.error);
}
EXIT_AUTH_ERROR
}
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
fn cmd_languages(opts: &CliOptions) -> i32 {
let creds = match get_credentials(opts) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
return EXIT_AUTH_ERROR;
}
};
match get_languages(&creds) {
Ok(languages) => {
if opts.languages_json {
// Output as JSON array
let json = serde_json::to_string(&languages).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
} else {
// Output one language per line
for lang in &languages {
println!("{}", lang);
}
}
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
EXIT_API_ERROR
}
}
}
/// CLI entry point. Call this from main() to run the CLI.
///
/// # Examples
/// ```ignore
/// fn main() {
/// std::process::exit(un::cli_main());
/// }
/// ```
pub fn cli_main() -> i32 {
let args: Vec<String> = env::args().collect();
let opts = parse_args(&args);
if opts.help {
print_help();
return EXIT_SUCCESS;
}
// Dispatch based on command
match opts.command.as_deref() {
Some("session") => cmd_session(&opts),
Some("service") => cmd_service(&opts),
Some("snapshot") => cmd_snapshot(&opts),
Some("image") => cmd_image(&opts),
Some("key") => cmd_key(&opts),
Some("languages") => cmd_languages(&opts),
None => {
// Default: execute code
if opts.positional.is_empty() && opts.shell.is_none() {
print_help();
EXIT_SUCCESS
} else {
cmd_execute(&opts)
}
}
Some(cmd) => {
// Treat unknown command as file to execute
let mut new_opts = opts;
new_opts.positional.insert(0, cmd.to_string());
new_opts.command = None;
cmd_execute(&new_opts)
}
}
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_language() {
assert_eq!(detect_language("hello.py"), Some("python"));
assert_eq!(detect_language("script.js"), Some("javascript"));
assert_eq!(detect_language("main.go"), Some("go"));
assert_eq!(detect_language("test.rs"), Some("rust"));
assert_eq!(detect_language("app.ts"), Some("typescript"));
assert_eq!(detect_language("Makefile"), None);
assert_eq!(detect_language("unknown"), None);
assert_eq!(detect_language("file.unknown_ext"), None);
}
#[test]
fn test_sign_request() {
let signature = sign_request(
"test-secret",
1234567890,
"POST",
"/execute",
r#"{"language":"python","code":"print(42)"}"#,
);
// Signature should be 64 hex characters
assert_eq!(signature.len(), 64);
assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_credentials_new() {
let creds = Credentials::new("unsb-pk-test", "unsb-sk-test");
assert_eq!(creds.public_key, "unsb-pk-test");
assert_eq!(creds.secret_key, "unsb-sk-test");
}
#[test]
fn test_get_timestamp() {
let ts = get_timestamp();
// Should be a reasonable Unix timestamp (after 2024)
assert!(ts > 1700000000);
}
#[test]
fn test_parse_args_help() {
let args = vec!["un".to_string(), "--help".to_string()];
let opts = parse_args(&args);
assert!(opts.help);
}
#[test]
fn test_parse_args_execute() {
let args = vec!["un".to_string(), "script.py".to_string()];
let opts = parse_args(&args);
assert_eq!(opts.positional, vec!["script.py"]);
}
#[test]
fn test_parse_args_inline() {
let args = vec![
"un".to_string(),
"-s".to_string(),
"python".to_string(),
"print(1)".to_string(),
];
let opts = parse_args(&args);
assert_eq!(opts.shell, Some("python".to_string()));
assert_eq!(opts.positional, vec!["print(1)"]);
}
#[test]
fn test_parse_args_session() {
let args = vec!["un".to_string(), "session".to_string(), "--list".to_string()];
let opts = parse_args(&args);
assert_eq!(opts.command, Some("session".to_string()));
assert!(opts.session_list);
}
#[test]
fn test_parse_args_service() {
let args = vec![
"un".to_string(),
"service".to_string(),
"--name".to_string(),
"myapp".to_string(),
"--ports".to_string(),
"80".to_string(),
];
let opts = parse_args(&args);
assert_eq!(opts.command, Some("service".to_string()));
assert_eq!(opts.service_name, Some("myapp".to_string()));
assert_eq!(opts.service_ports, Some("80".to_string()));
}
}
Documentation clarifications
Dependencies
C Binary (un1) — requires libcurl and libwebsockets:
sudo apt install build-essential libcurl4-openssl-dev libwebsockets-dev
wget unsandbox.com/downloads/un.c && gcc -O2 -o un un.c -lcurl -lwebsockets
SDK Implementations — most use stdlib only (Ruby, JS, Go, etc). Some require minimal deps:
pip install requests # Python
Execute Code
Run a Script
./un hello.py
./un app.js
./un main.rs
With Environment Variables
./un -e DEBUG=1 -e NAME=World script.py
With Input Files (teleport files into sandbox)
./un -f data.csv -f config.json process.py
Get Compiled Binary (teleport artifacts out)
./un -a -o ./bin main.c
Interactive Sessions
Start a Shell Session
# Default bash shell
./un session
# Choose your shell
./un session --shell zsh
./un session --shell fish
# Jump into a REPL
./un session --shell python3
./un session --shell node
./un session --shell julia
Session with Network Access
./un session -n semitrusted
Session Auditing (full terminal recording)
# Record everything (including vim, interactive programs)
./un session --audit -o ./logs
# Replay session later
zcat session.log*.gz | less -R
Collect Artifacts from Session
# Files in /tmp/artifacts/ are collected on exit
./un session -a -o ./outputs
Session Persistence (tmux/screen)
# Default: session terminates on disconnect (clean exit)
./un session
# With tmux: session persists, can reconnect later
./un session --tmux
# Press Ctrl+b then d to detach
# With screen: alternative multiplexer
./un session --screen
# Press Ctrl+a then d to detach
List Active Sessions
./un session --list
# Output:
# Active sessions: 2
#
# SESSION ID CONTAINER SHELL TTL STATUS
# abc123... unsb-vm-12345 python3 45m30s active
# def456... unsb-vm-67890 bash 1h2m active
Reconnect to Existing Session
# Reconnect by container name (requires --tmux or --screen)
./un session --attach unsb-vm-12345
# Use exit to terminate session, or detach to keep it running
Terminate a Session
./un session --kill unsb-vm-12345
Available Shells & REPLs
Shells: bash, dash, sh, zsh, fish, ksh, tcsh, csh, elvish, xonsh, ash
REPLs: python3, bpython, ipython # Python
node # JavaScript
ruby, irb # Ruby
lua # Lua
php # PHP
perl # Perl
guile, scheme # Scheme
ghci # Haskell
erl, iex # Erlang/Elixir
sbcl, clisp # Common Lisp
r # R
julia # Julia
clojure # Clojure
API Key Management
Check Key Status
# Check if your API key is valid
./un key
# Output:
# Valid: key expires in 30 days
Extend Expired Key
# Open the portal to extend an expired key
./un key --extend
# This opens the unsandbox.com portal where you can
# add more credits to extend your key's expiration
Authentication
Credentials are loaded in priority order (highest first):
# 1. CLI flags (highest priority)
./un -p unsb-pk-xxxx -k unsb-sk-xxxxx script.py
# 2. Environment variables
export UNSANDBOX_PUBLIC_KEY=unsb-pk-xxxx-xxxx-xxxx-xxxx
export UNSANDBOX_SECRET_KEY=unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx
./un script.py
# 3. Config file (lowest priority)
# ~/.unsandbox/accounts.csv format: public_key,secret_key
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
./un script.py
Requests are signed with HMAC-SHA256. The bearer token contains only the public key; the secret key computes the signature (never transmitted).
Resource Scaling
Set vCPU Count
# Default: 1 vCPU, 2GB RAM
./un script.py
# Scale up: 4 vCPUs, 8GB RAM
./un -v 4 script.py
# Maximum: 8 vCPUs, 16GB RAM
./un --vcpu 8 heavy_compute.py
Live Session Boosting
# Boost a running session to 2 vCPU, 4GB RAM
./un session --boost sandbox-abc
# Boost to specific vCPU count (4 vCPU, 8GB RAM)
./un session --boost sandbox-abc --boost-vcpu 4
# Return to base resources (1 vCPU, 2GB RAM)
./un session --unboost sandbox-abc
Session Freeze/Unfreeze
Freeze and Unfreeze Sessions
# Freeze a session (stop billing, preserve state)
./un session --freeze sandbox-abc
# Unfreeze a frozen session
./un session --unfreeze sandbox-abc
# Note: Requires --tmux or --screen for persistence
Persistent Services
Create a Service
# Web server with ports
./un service --name web --ports 80,443 --bootstrap "python -m http.server 80"
# With custom domains
./un service --name blog --ports 8000 --domains blog.example.com
# Game server with SRV records
./un service --name mc --type minecraft --bootstrap ./setup.sh
# Deploy app tarball with bootstrap script
./un service --name app --ports 8000 -f app.tar.gz --bootstrap-file ./setup.sh
# setup.sh: cd /tmp && tar xzf app.tar.gz && ./app/start.sh
Manage Services
# List all services
./un service --list
# Get service details
./un service --info abc123
# View bootstrap logs
./un service --logs abc123
./un service --tail abc123 # last 9000 lines
# Execute command in running service
./un service --execute abc123 'journalctl -u myapp -n 50'
# Dump bootstrap script (for migrations)
./un service --dump-bootstrap abc123
./un service --dump-bootstrap abc123 backup.sh
# Freeze/unfreeze service
./un service --freeze abc123
./un service --unfreeze abc123
# Service settings (auto-wake, freeze page display)
./un service --auto-unfreeze abc123 # enable auto-wake on HTTP
./un service --no-auto-unfreeze abc123 # disable auto-wake
./un service --show-freeze-page abc123 # show HTML payment page (default)
./un service --no-show-freeze-page abc123 # return JSON error instead
# Redeploy with new bootstrap
./un service --redeploy abc123 --bootstrap ./new-setup.sh
# Destroy service
./un service --destroy abc123
Snapshots
List Snapshots
./un snapshot --list
# Output:
# Snapshots: 3
#
# SNAPSHOT ID NAME SOURCE SIZE CREATED
# unsb-snapshot-a1b2-c3d4-e5f6-g7h8 before-upgrade session 512 MB 2h ago
# unsb-snapshot-i9j0-k1l2-m3n4-o5p6 stable-v1.0 service 1.2 GB 1d ago
Create Session Snapshot
# Snapshot with name
./un session --snapshot unsb-vm-12345 --name "before upgrade"
# Quick snapshot (auto-generated name)
./un session --snapshot unsb-vm-12345
Create Service Snapshot
# Standard snapshot (pauses container briefly)
./un service --snapshot unsb-service-abc123 --name "stable v1.0"
# Hot snapshot (no pause, may be inconsistent)
./un service --snapshot unsb-service-abc123 --hot
Restore from Snapshot
# Restore session from snapshot
./un session --restore unsb-snapshot-a1b2-c3d4-e5f6-g7h8
# Restore service from snapshot
./un service --restore unsb-snapshot-i9j0-k1l2-m3n4-o5p6
Delete Snapshot
./un snapshot --delete unsb-snapshot-a1b2-c3d4-e5f6-g7h8
Images
Images are independent, transferable container images that survive container deletion. Unlike snapshots (which live with their container), images can be shared with other users, transferred between API keys, or made public in the marketplace.
List Images
# List all images (owned + shared + public)
./un image --list
# List only your images
./un image --list owned
# List images shared with you
./un image --list shared
# List public marketplace images
./un image --list public
# Get image details
./un image --info unsb-image-xxxx-xxxx-xxxx-xxxx
Publish Images
# Publish from a stopped or frozen service
./un image --publish-service unsb-service-abc123 \
--name "My App v1.0" --description "Production snapshot"
# Publish from a snapshot
./un image --publish-snapshot unsb-snapshot-xxxx-xxxx-xxxx-xxxx \
--name "Stable Release"
# Note: Cannot publish from running containers - stop or freeze first
Create Services from Images
# Spawn a new service from an image
./un image --spawn unsb-image-xxxx-xxxx-xxxx-xxxx \
--name new-service --ports 80,443
# Clone an image (creates a copy you own)
./un image --clone unsb-image-xxxx-xxxx-xxxx-xxxx
Image Protection
# Lock image to prevent accidental deletion
./un image --lock unsb-image-xxxx-xxxx-xxxx-xxxx
# Unlock image to allow deletion
./un image --unlock unsb-image-xxxx-xxxx-xxxx-xxxx
# Delete image (must be unlocked)
./un image --delete unsb-image-xxxx-xxxx-xxxx-xxxx
Visibility & Sharing
# Set visibility level
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx private # owner only (default)
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx unlisted # can be shared
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx public # marketplace
# Share with specific user
./un image --grant unsb-image-xxxx-xxxx-xxxx-xxxx \
--key unsb-pk-friend-friend-friend-friend
# Revoke access
./un image --revoke unsb-image-xxxx-xxxx-xxxx-xxxx \
--key unsb-pk-friend-friend-friend-friend
# List who has access
./un image --trusted unsb-image-xxxx-xxxx-xxxx-xxxx
Transfer Ownership
# Transfer image to another API key
./un image --transfer unsb-image-xxxx-xxxx-xxxx-xxxx \
--to unsb-pk-newowner-newowner-newowner-newowner
Usage Reference
Usage: ./un [options] <source_file>
./un session [options]
./un service [options]
./un snapshot [options]
./un image [options]
./un key
Commands:
(default) Execute source file in sandbox
session Open interactive shell/REPL session
service Manage persistent services
snapshot Manage container snapshots
image Manage container images (publish, share, transfer)
key Check API key validity and expiration
Options:
-e KEY=VALUE Set environment variable (can use multiple times)
-f FILE Add input file (can use multiple times)
-a Return and save artifacts from /tmp/artifacts/
-o DIR Output directory for artifacts (default: current dir)
-p KEY Public key (or set UNSANDBOX_PUBLIC_KEY env var)
-k KEY Secret key (or set UNSANDBOX_SECRET_KEY env var)
-n MODE Network mode: zerotrust (default) or semitrusted
-v N, --vcpu N vCPU count 1-8, each vCPU gets 2GB RAM (default: 1)
-y Skip confirmation for large uploads (>1GB)
-h Show this help
Authentication (priority order):
1. -p and -k flags (public and secret key)
2. UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars
3. ~/.unsandbox/accounts.csv (format: public_key,secret_key per line)
Session options:
-s, --shell SHELL Shell/REPL to use (default: bash)
-l, --list List active sessions
--attach ID Reconnect to existing session (ID or container name)
--kill ID Terminate a session (ID or container name)
--freeze ID Freeze a session (requires --tmux/--screen)
--unfreeze ID Unfreeze a frozen session
--boost ID Boost session resources (2 vCPU, 4GB RAM)
--boost-vcpu N Specify vCPU count for boost (1-8)
--unboost ID Return to base resources
--audit Record full session for auditing
--tmux Enable session persistence with tmux (allows reconnect)
--screen Enable session persistence with screen (allows reconnect)
Service options:
--name NAME Service name (creates new service)
--ports PORTS Comma-separated ports (e.g., 80,443)
--domains DOMAINS Custom domains (e.g., example.com,www.example.com)
--type TYPE Service type: minecraft, mumble, teamspeak, source, tcp, udp
--bootstrap CMD Bootstrap command/file/URL to run on startup
-f FILE Upload file to /tmp/ (can use multiple times)
-l, --list List all services
--info ID Get service details
--tail ID Get last 9000 lines of bootstrap logs
--logs ID Get all bootstrap logs
--freeze ID Freeze a service
--unfreeze ID Unfreeze a service
--auto-unfreeze ID Enable auto-wake on HTTP request
--no-auto-unfreeze ID Disable auto-wake on HTTP request
--show-freeze-page ID Show HTML payment page when frozen (default)
--no-show-freeze-page ID Return JSON error when frozen
--destroy ID Destroy a service
--redeploy ID Re-run bootstrap script (requires --bootstrap)
--execute ID CMD Run a command in a running service
--dump-bootstrap ID [FILE] Dump bootstrap script (for migrations)
--snapshot ID Create snapshot of session or service
--snapshot-name User-friendly name for snapshot
--hot Create snapshot without pausing (may be inconsistent)
--restore ID Restore session/service from snapshot ID
Snapshot options:
-l, --list List all snapshots
--info ID Get snapshot details
--delete ID Delete a snapshot permanently
Image options:
-l, --list [owned|shared|public] List images (all, owned, shared, or public)
--info ID Get image details
--publish-service ID Publish image from stopped/frozen service
--publish-snapshot ID Publish image from snapshot
--name NAME Name for published image
--description DESC Description for published image
--delete ID Delete image (must be unlocked)
--clone ID Clone image (creates copy you own)
--spawn ID Create service from image (requires --name)
--lock ID Lock image to prevent deletion
--unlock ID Unlock image to allow deletion
--visibility ID LEVEL Set visibility (private|unlisted|public)
--grant ID --key KEY Grant access to another API key
--revoke ID --key KEY Revoke access from API key
--transfer ID --to KEY Transfer ownership to API key
--trusted ID List API keys with access
Key options:
(no options) Check API key validity
--extend Open portal to extend an expired key
Examples:
./un script.py # execute Python script
./un -e DEBUG=1 script.py # with environment variable
./un -f data.csv process.py # with input file
./un -a -o ./bin main.c # save compiled artifacts
./un -v 4 heavy.py # with 4 vCPUs, 8GB RAM
./un session # interactive bash session
./un session --tmux # bash with reconnect support
./un session --list # list active sessions
./un session --attach unsb-vm-12345 # reconnect to session
./un session --kill unsb-vm-12345 # terminate a session
./un session --freeze unsb-vm-12345 # freeze session
./un session --unfreeze unsb-vm-12345 # unfreeze session
./un session --boost unsb-vm-12345 # boost resources
./un session --unboost unsb-vm-12345 # return to base
./un session --shell python3 # Python REPL
./un session --shell node # Node.js REPL
./un session -n semitrusted # session with network access
./un session --audit -o ./logs # record session for auditing
./un service --name web --ports 80 # create web service
./un service --list # list all services
./un service --logs abc123 # view bootstrap logs
./un key # check API key
./un key --extend # extend expired key
./un snapshot --list # list all snapshots
./un session --snapshot unsb-vm-123 # snapshot a session
./un service --snapshot abc123 # snapshot a service
./un session --restore unsb-snapshot-xxxx # restore from snapshot
./un image --list # list all images
./un image --list owned # list your images
./un image --publish-service abc # publish image from service
./un image --spawn img123 --name x # create service from image
./un image --grant img --key pk # share image with user
CLI Inception
The UN CLI has been implemented in 42 programming languages, demonstrating that the unsandbox API can be accessed from virtually any environment.
License
PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
This is free public domain software for the public good of a permacomputer hosted
at permacomputer.com - an always-on computer by the people, for the people. One
that is durable, easy to repair, and distributed like tap water for machine
learning intelligence.
The permacomputer is community-owned infrastructure optimized around four values:
TRUTH - First principles, math & science, open source code freely distributed
FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control
HARMONY - Minimal waste, self-renewing systems with diverse thriving connections
LOVE - Be yourself without hurting others, cooperation through natural law
This software contributes to that vision by enabling code execution across all 42
programming languages through a unified interface, accessible to everyone. Code is
seeds to sprout on any abandoned technology.
Learn more: https://www.permacomputer.com
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.
NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
That said, our permacomputer's digital membrane stratum continuously runs unit,
integration, and functional tests on all its own software - with our permacomputer
monitoring itself, repairing itself, with minimal human guidance in the loop.
Our agents do their best.
Copyright 2025 TimeHexOn & foxhop & russell@unturf
https://www.timehexon.com
https://www.foxhop.net
https://www.unturf.com/software