From 9ab564da607eb008f518459e86235b74764bba45 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 15 Jan 2026 17:38:19 -0500 Subject: [PATCH] ci: Disable GitHub Actions workflow temporarily --- .github/workflows/{ci.yml => ci.yml.disabled} | 0 clients/c/src/un.c | 6989 +++++++++++++++-- clients/go/async/src/un_async.go | 682 ++ clients/go/sync/src/un.go | 360 + clients/java/async/src/UnsandboxAsync.java | 688 ++ clients/java/sync/src/Un.java | 757 ++ clients/javascript/async/src/un_async.js | 584 +- clients/javascript/sync/src/un.js | 556 +- clients/php/async/src/UnsandboxAsync.php | 591 ++ clients/php/sync/src/un.php | 659 ++ clients/python/async/src/un_async.py | 948 +++ clients/python/sync/src/un.py | 945 +++ clients/ruby/async/src/un_async.rb | 674 +- clients/ruby/sync/src/un.rb | 624 +- clients/rust/async/src/lib.rs | 835 ++ clients/rust/sync/src/lib.rs | 833 ++ 16 files changed, 15954 insertions(+), 771 deletions(-) rename .github/workflows/{ci.yml => ci.yml.disabled} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml.disabled similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/ci.yml.disabled diff --git a/clients/c/src/un.c b/clients/c/src/un.c index 7c0a721..efa610c 100644 --- a/clients/c/src/un.c +++ b/clients/c/src/un.c @@ -1,823 +1,6336 @@ /* - * PUBLIC DOMAIN - NO LICENSE, NO WARRANTY + * un - unsandbox.com CLI * - * unsandbox.com C SDK Implementation + * Authentication priority (highest to lowest, per POSIX convention): + * 1. CLI flags: -p (public key) + -k (secret key) + * 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY + * 3. Config file: ~/.unsandbox/accounts.csv (format: public_key,secret_key per line) + * - Use --account N to select account by index (0-based, default: 0) + * - Or set UNSANDBOX_ACCOUNT=N environment variable + * + * Request authentication: + * Authorization: Bearer <- identifies account + * X-Timestamp: <- replay prevention + * X-Signature: HMAC-SHA256(secret_key, ts:method:path:body) <- proves secret + body integrity + * + * The secret key is NEVER transmitted. Server decrypts stored secret to verify HMAC. + * Timestamp must be within ±5 minutes of server time (prevents replay attacks). + * Body is included in signature to prevent tampering (empty string for GET/DELETE). */ -#include "un.h" #include #include #include #include #include -#include -#include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#define API_URL "https://api.unsandbox.com/execute" #define API_BASE "https://api.unsandbox.com" -#define LANGUAGES_CACHE_TTL 3600 -#define MAX_POLL_ATTEMPTS 100 +#define PORTAL_BASE "https://unsandbox.com" +#define MAX_FILE_SIZE (100 * 1024 * 1024) // 100MB max single file +#define MAX_INPUT_FILES 1000 +#define MAX_TOTAL_INPUT_SIZE (4096L * 1024 * 1024) // 4GB total across all input files +#define LARGE_UPLOAD_WARN_SIZE (1024L * 1024 * 1024) // Warn if total > 1GB +#define MAX_ENV_VARS 256 // LXC limit is typically higher +#define MAX_ENV_CONTENT_SIZE (64 * 1024) // 64KB max env vault size -static char g_last_error[1024] = {0}; +// ============================================================================ +// SHA-256 Implementation (for HMAC-SHA256) +// ============================================================================ -/* ============================================================================ - * Utility Macros and Helpers - * ============================================================================ */ +static const uint32_t sha256_k[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +}; -#define SET_ERROR(msg, ...) \ - do { \ - snprintf(g_last_error, sizeof(g_last_error), msg, ##__VA_ARGS__); \ - } while(0) +#define SHA256_ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n)))) +#define SHA256_CH(x, y, z) (((x) & (y)) ^ (~(x) & (z))) +#define SHA256_MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) +#define SHA256_EP0(x) (SHA256_ROTR(x, 2) ^ SHA256_ROTR(x, 13) ^ SHA256_ROTR(x, 22)) +#define SHA256_EP1(x) (SHA256_ROTR(x, 6) ^ SHA256_ROTR(x, 11) ^ SHA256_ROTR(x, 25)) +#define SHA256_SIG0(x) (SHA256_ROTR(x, 7) ^ SHA256_ROTR(x, 18) ^ ((x) >> 3)) +#define SHA256_SIG1(x) (SHA256_ROTR(x, 17) ^ SHA256_ROTR(x, 19) ^ ((x) >> 10)) typedef struct { + uint32_t state[8]; + uint64_t count; + unsigned char buffer[64]; +} UN_SHA256_CTX; + +static void sha256_init(UN_SHA256_CTX *ctx) { + ctx->state[0] = 0x6a09e667; + ctx->state[1] = 0xbb67ae85; + ctx->state[2] = 0x3c6ef372; + ctx->state[3] = 0xa54ff53a; + ctx->state[4] = 0x510e527f; + ctx->state[5] = 0x9b05688c; + ctx->state[6] = 0x1f83d9ab; + ctx->state[7] = 0x5be0cd19; + ctx->count = 0; +} + +static void sha256_transform(UN_SHA256_CTX *ctx, const unsigned char *data) { + uint32_t a, b, c, d, e, f, g, h, t1, t2, w[64]; + int i; + + for (i = 0; i < 16; i++) { + w[i] = ((uint32_t)data[i * 4] << 24) | ((uint32_t)data[i * 4 + 1] << 16) | + ((uint32_t)data[i * 4 + 2] << 8) | ((uint32_t)data[i * 4 + 3]); + } + for (i = 16; i < 64; i++) { + w[i] = SHA256_SIG1(w[i - 2]) + w[i - 7] + SHA256_SIG0(w[i - 15]) + w[i - 16]; + } + + a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3]; + e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7]; + + for (i = 0; i < 64; i++) { + t1 = h + SHA256_EP1(e) + SHA256_CH(e, f, g) + sha256_k[i] + w[i]; + t2 = SHA256_EP0(a) + SHA256_MAJ(a, b, c); + h = g; g = f; f = e; e = d + t1; + d = c; c = b; b = a; a = t1 + t2; + } + + ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d; + ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h; +} + +static void sha256_update(UN_SHA256_CTX *ctx, const unsigned char *data, size_t len) { + size_t i, index, part_len; + index = (size_t)(ctx->count & 0x3F); + ctx->count += len; + part_len = 64 - index; + if (len >= part_len) { + memcpy(&ctx->buffer[index], data, part_len); + sha256_transform(ctx, ctx->buffer); + for (i = part_len; i + 63 < len; i += 64) + sha256_transform(ctx, &data[i]); + index = 0; + } else { + i = 0; + } + memcpy(&ctx->buffer[index], &data[i], len - i); +} + +static void sha256_final(UN_SHA256_CTX *ctx, unsigned char hash[32]) { + unsigned char pad[64]; + unsigned char count_bits[8]; + size_t index, pad_len; + uint64_t bits = ctx->count * 8; + int i; + + for (i = 0; i < 8; i++) { + count_bits[i] = (unsigned char)(bits >> (56 - i * 8)); + } + + index = (size_t)(ctx->count & 0x3F); + pad_len = (index < 56) ? (56 - index) : (120 - index); + memset(pad, 0, pad_len); + pad[0] = 0x80; + sha256_update(ctx, pad, pad_len); + sha256_update(ctx, count_bits, 8); + + for (i = 0; i < 8; i++) { + hash[i * 4] = (unsigned char)(ctx->state[i] >> 24); + hash[i * 4 + 1] = (unsigned char)(ctx->state[i] >> 16); + hash[i * 4 + 2] = (unsigned char)(ctx->state[i] >> 8); + hash[i * 4 + 3] = (unsigned char)(ctx->state[i]); + } +} + +// Compute raw SHA-256 hash (32 bytes) +static void sha256_raw(const unsigned char *data, size_t len, unsigned char hash[32]) { + UN_SHA256_CTX ctx; + sha256_init(&ctx); + sha256_update(&ctx, data, len); + sha256_final(&ctx, hash); +} + +// ============================================================================ +// HMAC-SHA256 Implementation +// ============================================================================ + +#define HMAC_SHA256_BLOCK_SIZE 64 +#define HMAC_SHA256_HASH_SIZE 32 + +// Compute HMAC-SHA256 and return as lowercase hex string (64 chars + null) +static char* hmac_sha256_hex(const char *key, size_t key_len, const char *data, size_t data_len) { + unsigned char k_ipad[HMAC_SHA256_BLOCK_SIZE]; + unsigned char k_opad[HMAC_SHA256_BLOCK_SIZE]; + unsigned char tk[HMAC_SHA256_HASH_SIZE]; + unsigned char inner_hash[HMAC_SHA256_HASH_SIZE]; + unsigned char final_hash[HMAC_SHA256_HASH_SIZE]; + size_t i; + + // If key is longer than block size, hash it first + if (key_len > HMAC_SHA256_BLOCK_SIZE) { + sha256_raw((const unsigned char *)key, key_len, tk); + key = (const char *)tk; + key_len = HMAC_SHA256_HASH_SIZE; + } + + // XOR key with ipad and opad values + memset(k_ipad, 0x36, HMAC_SHA256_BLOCK_SIZE); + memset(k_opad, 0x5c, HMAC_SHA256_BLOCK_SIZE); + for (i = 0; i < key_len; i++) { + k_ipad[i] ^= (unsigned char)key[i]; + k_opad[i] ^= (unsigned char)key[i]; + } + + // Inner hash: SHA256(k_ipad || data) + UN_SHA256_CTX ctx; + sha256_init(&ctx); + sha256_update(&ctx, k_ipad, HMAC_SHA256_BLOCK_SIZE); + sha256_update(&ctx, (const unsigned char *)data, data_len); + sha256_final(&ctx, inner_hash); + + // Outer hash: SHA256(k_opad || inner_hash) + sha256_init(&ctx); + sha256_update(&ctx, k_opad, HMAC_SHA256_BLOCK_SIZE); + sha256_update(&ctx, inner_hash, HMAC_SHA256_HASH_SIZE); + sha256_final(&ctx, final_hash); + + // Convert to hex + char *hex = malloc(65); + if (!hex) return NULL; + for (i = 0; i < HMAC_SHA256_HASH_SIZE; i++) { + sprintf(hex + i * 2, "%02x", final_hash[i]); + } + hex[64] = '\0'; + return hex; +} + +// Sign a request: HMAC-SHA256(secret_key, timestamp:method:path:body) +// Returns signature as hex string (caller must free) +// body can be NULL for bodyless requests (GET, DELETE) +static char* sign_request(const char *secret_key, long timestamp, const char *method, const char *path, const char *body) { + // Build message: "timestamp:method:path:body" + // Body is included raw to prevent tampering (empty string if NULL) + char ts_str[32]; + snprintf(ts_str, sizeof(ts_str), "%ld", timestamp); + + const char *body_str = body ? body : ""; + size_t msg_len = strlen(ts_str) + 1 + strlen(method) + 1 + strlen(path) + 1 + strlen(body_str); + char *message = malloc(msg_len + 1); + if (!message) return NULL; + + snprintf(message, msg_len + 1, "%s:%s:%s:%s", ts_str, method, path, body_str); + + char *signature = hmac_sha256_hex(secret_key, strlen(secret_key), message, strlen(message)); + free(message); + return signature; +} + +// ============================================================================ +// Account Credentials Management (~/.unsandbox/accounts.csv) +// ============================================================================ + +typedef struct { + char *public_key; // unsb-pk-xxxx-xxxx-xxxx-xxxx - used as bearer token to identify account + char *secret_key; // unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx - used only for HMAC signing, never transmitted +} UnsandboxCredentials; + +// Get path to ~/.unsandbox/accounts.csv +static char* get_accounts_csv_path(void) { + const char *home = getenv("HOME"); + if (!home) { + struct passwd *pw = getpwuid(getuid()); + if (pw) home = pw->pw_dir; + } + if (!home) return NULL; + + char *path = malloc(strlen(home) + 32); + if (!path) return NULL; + sprintf(path, "%s/.unsandbox/accounts.csv", home); + return path; +} + +// Ensure ~/.unsandbox directory exists +static void ensure_unsandbox_dir(void) { + const char *home = getenv("HOME"); + if (!home) { + struct passwd *pw = getpwuid(getuid()); + if (pw) home = pw->pw_dir; + } + if (!home) return; + + char dir[512]; + snprintf(dir, sizeof(dir), "%s/.unsandbox", home); + mkdir(dir, 0700); +} + +// Load account from ~/.unsandbox/accounts.csv by index (0-based) +// Format: public_key,secret_key (one per line) +// index -1 means use first valid account +static UnsandboxCredentials* load_credentials_from_csv(int account_index) { + char *path = get_accounts_csv_path(); + if (!path) return NULL; + + FILE *f = fopen(path, "r"); + free(path); + if (!f) return NULL; + + char line[1024]; + UnsandboxCredentials *creds = NULL; + int current_index = 0; + + while (fgets(line, sizeof(line), f)) { + // Skip empty lines and comments + if (line[0] == '\n' || line[0] == '#') continue; + + // Remove newline + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0'; + + // Parse CSV: public_key,secret_key + char *comma = strchr(line, ','); + if (!comma) continue; + + *comma = '\0'; + char *pk = line; + char *sk = comma + 1; + + // Validate key prefixes + if (strncmp(pk, "unsb-pk-", 8) != 0) continue; + if (strncmp(sk, "unsb-sk-", 8) != 0) continue; + + // Check if this is the account we want + if (account_index >= 0 && current_index != account_index) { + current_index++; + continue; + } + + creds = malloc(sizeof(UnsandboxCredentials)); + if (!creds) break; + + creds->public_key = strdup(pk); + creds->secret_key = strdup(sk); + + if (!creds->public_key || !creds->secret_key) { + free(creds->public_key); + free(creds->secret_key); + free(creds); + creds = NULL; + } + break; + } + + fclose(f); + return creds; +} + +// Count total accounts in CSV +static int count_accounts_in_csv(void) { + char *path = get_accounts_csv_path(); + if (!path) return 0; + + FILE *f = fopen(path, "r"); + free(path); + if (!f) return 0; + + char line[1024]; + int count = 0; + + while (fgets(line, sizeof(line), f)) { + if (line[0] == '\n' || line[0] == '#') continue; + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0'; + char *comma = strchr(line, ','); + if (!comma) continue; + *comma = '\0'; + if (strncmp(line, "unsb-pk-", 8) == 0 && strncmp(comma + 1, "unsb-sk-", 8) == 0) { + count++; + } + } + + fclose(f); + return count; +} + +// Get credentials using POSIX priority: CLI flags > env vars > CSV file +// cli_pk/cli_sk: from -p/-k flags (can be NULL) +// account_index: from --account flag (-1 means use env var or default to 0) +static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli_sk, int account_index) { + // Priority 1: CLI flags (-p and -k) - highest priority per POSIX convention + if (cli_pk && cli_sk && strlen(cli_pk) > 0 && strlen(cli_sk) > 0) { + UnsandboxCredentials *creds = malloc(sizeof(UnsandboxCredentials)); + if (!creds) return NULL; + + creds->public_key = strdup(cli_pk); + creds->secret_key = strdup(cli_sk); + + if (!creds->public_key || !creds->secret_key) { + free(creds->public_key); + free(creds->secret_key); + free(creds); + return NULL; + } + return creds; + } + + // Priority 2: Environment variables (keys) + const char *env_pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char *env_sk = getenv("UNSANDBOX_SECRET_KEY"); + + if (env_pk && env_sk && strlen(env_pk) > 0 && strlen(env_sk) > 0) { + UnsandboxCredentials *creds = malloc(sizeof(UnsandboxCredentials)); + if (!creds) return NULL; + + creds->public_key = strdup(env_pk); + creds->secret_key = strdup(env_sk); + + if (!creds->public_key || !creds->secret_key) { + free(creds->public_key); + free(creds->secret_key); + free(creds); + return NULL; + } + return creds; + } + + // Priority 3: Config file (~/.unsandbox/accounts.csv) + // Use account_index from --account flag, or UNSANDBOX_ACCOUNT env var, or default to 0 + int csv_index = account_index; + if (csv_index < 0) { + const char *env_account = getenv("UNSANDBOX_ACCOUNT"); + if (env_account && strlen(env_account) > 0) { + csv_index = atoi(env_account); + } else { + csv_index = 0; + } + } + return load_credentials_from_csv(csv_index); +} + +static void free_credentials(UnsandboxCredentials *creds) { + if (!creds) return; + free(creds->public_key); + free(creds->secret_key); + free(creds); +} + +// ============================================================================ +// End Credentials Management +// ============================================================================ + +// ============================================================================ +// HMAC Auth Headers Helper +// ============================================================================ + +// Add HMAC authentication headers to a curl_slist +// Returns a new slist with auth headers appended (caller must free with curl_slist_free_all) +// method: "GET", "POST", etc. +// path: e.g., "/execute", "/services", "/sessions" +// +// Adds these headers: +// Authorization: Bearer (identifies account) +// X-Timestamp: (replay prevention) +// X-Signature: (proves secret + body integrity) +// body can be NULL for bodyless requests (GET, DELETE) +static struct curl_slist* add_hmac_auth_headers(struct curl_slist *headers, + const UnsandboxCredentials *creds, + const char *method, + const char *path, + const char *body) { + if (!creds || !creds->public_key || !creds->secret_key) return headers; + + long timestamp = (long)time(NULL); + char *signature = sign_request(creds->secret_key, timestamp, method, path, body); + if (!signature) return headers; + + char auth_header[256]; + char ts_header[64]; + char sig_header[128]; + + // Public key identifies the account (server looks up by key) + snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", creds->public_key); + // Timestamp prevents replay attacks (server checks ±5 minutes) + snprintf(ts_header, sizeof(ts_header), "X-Timestamp: %ld", timestamp); + // Signature proves possession of secret key and body integrity + snprintf(sig_header, sizeof(sig_header), "X-Signature: %s", signature); + + headers = curl_slist_append(headers, auth_header); + headers = curl_slist_append(headers, ts_header); + headers = curl_slist_append(headers, sig_header); + + free(signature); + return headers; +} + +// ============================================================================ +// End HMAC Auth Headers Helper +// ============================================================================ + +// Polling delays (milliseconds) - matches opencompletion.com cadence +// Cumulative: 300ms, 750ms, 1450ms, 2350ms, 3000ms, 4600ms, 6600ms+ +static const int POLL_DELAYS[] = {300, 450, 700, 900, 650, 1600, 2000}; +#define POLL_DELAYS_COUNT 7 + +// Response buffer structure +struct ResponseBuffer { char *data; size_t size; - size_t capacity; -} buffer_t; +}; -static void buffer_init(buffer_t *buf) { - buf->data = NULL; - buf->size = 0; - buf->capacity = 0; -} +// Input file structure +struct InputFile { + char *filename; + char *content_base64; +}; -static void buffer_append(buffer_t *buf, const char *data, size_t len) { - if (buf->size + len >= buf->capacity) { - buf->capacity = (buf->size + len) * 2 + 1; - buf->data = realloc(buf->data, buf->capacity); - } - memcpy(&buf->data[buf->size], data, len); - buf->size += len; -} +// Environment variable structure +struct EnvVar { + char *key; + char *value; +}; -static void buffer_free(buffer_t *buf) { - if (buf->data) free(buf->data); - buffer_init(buf); -} - -static size_t http_write_callback(void *data, size_t size, size_t nmemb, void *userp) { +// Write callback for libcurl +static size_t write_callback(void *contents, size_t size, size_t nmemb, void *userp) { size_t realsize = size * nmemb; - buffer_t *buf = (buffer_t *)userp; - buffer_append(buf, (const char *)data, realsize); + struct ResponseBuffer *mem = (struct ResponseBuffer *)userp; + + char *ptr = realloc(mem->data, mem->size + realsize + 1); + if (!ptr) { + fprintf(stderr, "Error: out of memory\n"); + return 0; + } + + mem->data = ptr; + memcpy(&(mem->data[mem->size]), contents, realsize); + mem->size += realsize; + mem->data[mem->size] = 0; + return realsize; } -/* ============================================================================ - * HMAC-SHA256 - * ============================================================================ */ +// Base64 encoding table +static const char base64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -char *hmac_sha256(const char *key, const char *message) { - if (!key || !message) return NULL; +// Base64 encode +char* base64_encode(const unsigned char *data, size_t input_length, size_t *output_length) { + *output_length = 4 * ((input_length + 2) / 3); + char *encoded = malloc(*output_length + 1); + if (!encoded) return NULL; - unsigned char digest[SHA256_DIGEST_LENGTH]; - unsigned int digest_len = SHA256_DIGEST_LENGTH; + size_t i, j; + for (i = 0, j = 0; i < input_length;) { + uint32_t octet_a = i < input_length ? data[i++] : 0; + uint32_t octet_b = i < input_length ? data[i++] : 0; + uint32_t octet_c = i < input_length ? data[i++] : 0; + uint32_t triple = (octet_a << 16) + (octet_b << 8) + octet_c; - HMAC(EVP_sha256(), - (unsigned char *)key, strlen(key), - (unsigned char *)message, strlen(message), - digest, &digest_len); - - char *result = malloc(SHA256_DIGEST_LENGTH * 2 + 1); - for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) { - sprintf(&result[i * 2], "%02x", digest[i]); + encoded[j++] = base64_table[(triple >> 18) & 0x3F]; + encoded[j++] = base64_table[(triple >> 12) & 0x3F]; + encoded[j++] = base64_table[(triple >> 6) & 0x3F]; + encoded[j++] = base64_table[triple & 0x3F]; } - result[SHA256_DIGEST_LENGTH * 2] = '\0'; - return result; + + // Add padding + int mod = input_length % 3; + if (mod > 0) { + encoded[*output_length - 1] = '='; + if (mod == 1) encoded[*output_length - 2] = '='; + } + + encoded[*output_length] = '\0'; + return encoded; } -/* ============================================================================ - * Language Detection - * ============================================================================ */ +// Base64 decode +unsigned char* base64_decode(const char *data, size_t input_length, size_t *output_length) { + if (input_length % 4 != 0) return NULL; -static const char *language_extensions[][2] = { - {"py", "python"}, - {"js", "javascript"}, - {"ts", "typescript"}, - {"rb", "ruby"}, - {"php", "php"}, - {"pl", "perl"}, - {"sh", "bash"}, - {"r", "r"}, - {"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"}, - {NULL, NULL} -}; + *output_length = input_length / 4 * 3; + if (data[input_length - 1] == '=') (*output_length)--; + if (data[input_length - 2] == '=') (*output_length)--; -const char *unsandbox_detect_language(const char *filename) { - if (!filename) return NULL; + unsigned char *decoded = malloc(*output_length + 1); + if (!decoded) return NULL; + int decoding_table[256]; + for (int i = 0; i < 256; i++) decoding_table[i] = -1; + for (int i = 0; i < 64; i++) decoding_table[(unsigned char)base64_table[i]] = i; + + size_t i, j; + for (i = 0, j = 0; i < input_length;) { + uint32_t sextet_a = data[i] == '=' ? 0 : decoding_table[(unsigned char)data[i]]; i++; + uint32_t sextet_b = data[i] == '=' ? 0 : decoding_table[(unsigned char)data[i]]; i++; + uint32_t sextet_c = data[i] == '=' ? 0 : decoding_table[(unsigned char)data[i]]; i++; + uint32_t sextet_d = data[i] == '=' ? 0 : decoding_table[(unsigned char)data[i]]; i++; + + uint32_t triple = (sextet_a << 18) + (sextet_b << 12) + (sextet_c << 6) + sextet_d; + + if (j < *output_length) decoded[j++] = (triple >> 16) & 0xFF; + if (j < *output_length) decoded[j++] = (triple >> 8) & 0xFF; + if (j < *output_length) decoded[j++] = triple & 0xFF; + } + + decoded[*output_length] = '\0'; + return decoded; +} + +// Detect language from shebang line +const char* detect_language_from_shebang(const char *code) { + if (code[0] != '#' || code[1] != '!') return NULL; + + if (strstr(code, "/python") || strstr(code, "/python3") || strstr(code, "/python2")) return "python"; + if (strstr(code, "/node") || strstr(code, "/nodejs")) return "javascript"; + if (strstr(code, "/ruby")) return "ruby"; + if (strstr(code, "/perl")) return "perl"; + if (strstr(code, "/php")) return "php"; + if (strstr(code, "/bash") || strstr(code, "/sh")) return "bash"; + if (strstr(code, "/lua")) return "lua"; + if (strstr(code, "/tclsh") || strstr(code, "/wish")) return "tcl"; + if (strstr(code, "/raku") || strstr(code, "/perl6")) return "raku"; + if (strstr(code, "/julia")) return "julia"; + if (strstr(code, "/Rscript")) return "r"; + if (strstr(code, "/groovy")) return "groovy"; + if (strstr(code, "/scala")) return "scala"; + if (strstr(code, "/swift")) return "swift"; + if (strstr(code, "/racket")) return "racket"; + if (strstr(code, "/scheme") || strstr(code, "/guile")) return "scheme"; + if (strstr(code, "/clisp") || strstr(code, "/sbcl")) return "commonlisp"; + if (strstr(code, "/ocaml")) return "ocaml"; + if (strstr(code, "/elixir")) return "elixir"; + + return NULL; +} + +// Detect language from file extension +const char* detect_language_from_extension(const char *filename) { const char *ext = strrchr(filename, '.'); if (!ext) return NULL; ext++; - for (int i = 0; language_extensions[i][0]; i++) { - if (strcmp(ext, language_extensions[i][0]) == 0) { - return language_extensions[i][1]; - } - } + if (strcmp(ext, "py") == 0) return "python"; + if (strcmp(ext, "js") == 0) return "javascript"; + if (strcmp(ext, "ts") == 0) return "typescript"; + if (strcmp(ext, "rb") == 0) return "ruby"; + if (strcmp(ext, "php") == 0) return "php"; + if (strcmp(ext, "pl") == 0) return "perl"; + if (strcmp(ext, "sh") == 0) return "bash"; + if (strcmp(ext, "r") == 0 || strcmp(ext, "R") == 0) return "r"; + if (strcmp(ext, "lua") == 0) return "lua"; + if (strcmp(ext, "go") == 0) return "go"; + if (strcmp(ext, "rs") == 0) return "rust"; + if (strcmp(ext, "c") == 0) return "c"; + if (strcmp(ext, "cpp") == 0 || strcmp(ext, "cc") == 0 || strcmp(ext, "cxx") == 0) return "cpp"; + if (strcmp(ext, "java") == 0) return "java"; + if (strcmp(ext, "kt") == 0) return "kotlin"; + if (strcmp(ext, "m") == 0) return "objc"; + if (strcmp(ext, "cs") == 0) return "csharp"; + if (strcmp(ext, "fs") == 0) return "fsharp"; + if (strcmp(ext, "hs") == 0) return "haskell"; + if (strcmp(ext, "ml") == 0) return "ocaml"; + if (strcmp(ext, "clj") == 0) return "clojure"; + if (strcmp(ext, "scm") == 0 || strcmp(ext, "ss") == 0) return "scheme"; + if (strcmp(ext, "erl") == 0) return "erlang"; + if (strcmp(ext, "ex") == 0 || strcmp(ext, "exs") == 0) return "elixir"; + if (strcmp(ext, "jl") == 0) return "julia"; + if (strcmp(ext, "d") == 0) return "d"; + if (strcmp(ext, "nim") == 0) return "nim"; + if (strcmp(ext, "zig") == 0) return "zig"; + if (strcmp(ext, "v") == 0) return "v"; + if (strcmp(ext, "cr") == 0) return "crystal"; + if (strcmp(ext, "dart") == 0) return "dart"; + if (strcmp(ext, "groovy") == 0) return "groovy"; + if (strcmp(ext, "f90") == 0 || strcmp(ext, "f95") == 0) return "fortran"; + if (strcmp(ext, "lisp") == 0 || strcmp(ext, "lsp") == 0) return "commonlisp"; + if (strcmp(ext, "cob") == 0) return "cobol"; + if (strcmp(ext, "tcl") == 0) return "tcl"; + if (strcmp(ext, "raku") == 0) return "raku"; + if (strcmp(ext, "pro") == 0 || strcmp(ext, "p") == 0) return "prolog"; + if (strcmp(ext, "4th") == 0 || strcmp(ext, "forth") == 0 || strcmp(ext, "fth") == 0) return "forth"; return NULL; } -/* ============================================================================ - * Credential Resolution - * ============================================================================ */ +// Read file contents +char* read_file(const char *filename, size_t *size) { + FILE *f = fopen(filename, "rb"); + if (!f) { + fprintf(stderr, "Error: cannot open file '%s'\n", filename); + return NULL; + } -static char *strdup_safe(const char *str) { - if (!str) return NULL; - char *dup = malloc(strlen(str) + 1); - strcpy(dup, str); - return dup; + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + fseek(f, 0, SEEK_SET); + + if (fsize > MAX_FILE_SIZE) { + fprintf(stderr, "Error: file too large (max %d bytes)\n", MAX_FILE_SIZE); + fclose(f); + return NULL; + } + + char *content = malloc(fsize + 1); + if (!content) { + fprintf(stderr, "Error: out of memory\n"); + fclose(f); + return NULL; + } + + size_t read_size = fread(content, 1, fsize, f); + content[read_size] = 0; + *size = read_size; + + fclose(f); + return content; } -static int load_credentials_from_csv(const char *path, int account_index, char **pk, char **sk) { - FILE *fp = fopen(path, "r"); - if (!fp) return -1; +// Escape JSON string +char* escape_json_string(const char *str) { + size_t len = strlen(str); + char *escaped = malloc(len * 6 + 1); // Worst case: \uXXXX for each char + if (!escaped) return NULL; - char line[2048]; - int current_index = 0; - - while (fgets(line, sizeof(line), fp)) { - char *p = line; - while (*p && isspace(*p)) p++; - - if (!*p || *p == '#') continue; - - char *newline = strchr(line, '\n'); - if (newline) *newline = '\0'; - - if (current_index == account_index) { - char *comma = strchr(line, ','); - if (comma) { - *comma = '\0'; - *pk = strdup_safe(line); - *sk = strdup_safe(comma + 1); - fclose(fp); - return 0; - } - } - current_index++; - } - - fclose(fp); - return -1; -} - -int unsandbox_resolve_credentials( - char **public_key_out, - char **secret_key_out, - const char *public_key_hint, - const char *secret_key_hint -) { - if (!public_key_out || !secret_key_out) return -1; - - *public_key_out = NULL; - *secret_key_out = NULL; - - if (public_key_hint && secret_key_hint) { - *public_key_out = strdup_safe(public_key_hint); - *secret_key_out = strdup_safe(secret_key_hint); - return 0; - } - - const char *env_pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char *env_sk = getenv("UNSANDBOX_SECRET_KEY"); - if (env_pk && env_sk) { - *public_key_out = strdup_safe(env_pk); - *secret_key_out = strdup_safe(env_sk); - return 0; - } - - int account_index = 0; - const char *account_env = getenv("UNSANDBOX_ACCOUNT"); - if (account_env) { - account_index = atoi(account_env); - } - - char home_csv[1024]; - const char *home = getenv("HOME"); - if (home) { - snprintf(home_csv, sizeof(home_csv), "%s/.unsandbox/accounts.csv", home); - if (load_credentials_from_csv(home_csv, account_index, public_key_out, secret_key_out) == 0) { - return 0; + char *out = escaped; + for (size_t i = 0; i < len; i++) { + switch (str[i]) { + case '"': *out++ = '\\'; *out++ = '"'; break; + case '\\': *out++ = '\\'; *out++ = '\\'; break; + case '\b': *out++ = '\\'; *out++ = 'b'; break; + case '\f': *out++ = '\\'; *out++ = 'f'; break; + case '\n': *out++ = '\\'; *out++ = 'n'; break; + case '\r': *out++ = '\\'; *out++ = 'r'; break; + case '\t': *out++ = '\\'; *out++ = 't'; break; + default: + if ((unsigned char)str[i] < 32) { + sprintf(out, "\\u%04x", (unsigned char)str[i]); + out += 6; + } else { + *out++ = str[i]; + } + break; } } - - if (load_credentials_from_csv("./accounts.csv", account_index, public_key_out, secret_key_out) == 0) { - return 0; - } - - SET_ERROR("No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY."); - return -1; + *out = 0; + return escaped; } -/* ============================================================================ - * HTTP Request Helpers - * ============================================================================ */ - -typedef struct { - const char *method; - const char *path; - const char *body; - const char *public_key; - const char *secret_key; -} request_args_t; - -static int make_request(const request_args_t *args, buffer_t *response) { - CURL *curl = curl_easy_init(); - if (!curl) { - SET_ERROR("Failed to initialize CURL"); - return -1; - } - - char url[2048]; - snprintf(url, sizeof(url), "%s%s", API_BASE, args->path); - - time_t now = time(NULL); - char timestamp_str[32]; - snprintf(timestamp_str, sizeof(timestamp_str), "%ld", now); - - char message[4096]; - snprintf(message, sizeof(message), "%s:%s:%s:%s", - timestamp_str, - args->method, - args->path, - args->body ? args->body : ""); - - char *signature = hmac_sha256(args->secret_key, message); - if (!signature) { - curl_easy_cleanup(curl); - SET_ERROR("Failed to generate signature"); - return -1; - } - - struct curl_slist *headers = NULL; - headers = curl_slist_append(headers, "Content-Type: application/json"); - headers = curl_slist_append(headers, "Accept: application/json"); - - char auth_header[512]; - snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", args->public_key); - headers = curl_slist_append(headers, auth_header); - - char timestamp_header[64]; - snprintf(timestamp_header, sizeof(timestamp_header), "X-Timestamp: %s", timestamp_str); - headers = curl_slist_append(headers, timestamp_header); - - char signature_header[256]; - snprintf(signature_header, sizeof(signature_header), "X-Signature: %s", signature); - headers = curl_slist_append(headers, signature_header); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, http_write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, response); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); - - if (strcmp(args->method, "POST") == 0) { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "POST"); - if (args->body) { - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, args->body); - } - } else if (strcmp(args->method, "DELETE") == 0) { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); - } else if (strcmp(args->method, "GET") == 0) { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "GET"); - } - - CURLcode res = curl_easy_perform(curl); - - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - free(signature); - - if (res != CURLE_OK) { - SET_ERROR("CURL error: %s", curl_easy_strerror(res)); - return -1; - } - - if (response_code >= 400) { - SET_ERROR("HTTP %ld: %.*s", response_code, (int)response->size, response->data); - return -1; - } - - return 0; -} - -/* ============================================================================ - * JSON Parsing (Simple) - * ============================================================================ */ - -static char *json_get_string(const char *json, const char *key) { - char search[512]; +// Extract JSON string value (simple parser) +char* extract_json_string(const char *json, const char *key) { + char search[256]; snprintf(search, sizeof(search), "\"%s\":\"", key); - const char *start = strstr(json, search); if (!start) return NULL; start += strlen(search); + const char *end = start; - const char *end = strchr(start, '"'); - if (!end) return NULL; + while (*end && !(*end == '"' && *(end - 1) != '\\')) { + end++; + } size_t len = end - start; char *result = malloc(len + 1); - memcpy(result, start, len); - result[len] = '\0'; + if (!result) return NULL; - for (char *p = result; *p; p++) { - if (*p == '\\' && *(p + 1) == '"') { - memmove(p, p + 1, strlen(p)); - } - } - - return result; -} - -static long json_get_long(const char *json, const char *key) { - char search[512]; - snprintf(search, sizeof(search), "\"%s\":", key); - - const char *start = strstr(json, search); - if (!start) return 0; - - start += strlen(search); - - while (*start && isspace(*start)) start++; - - return strtol(start, NULL, 10); -} - -static int json_get_bool(const char *json, const char *key) { - char search[512]; - snprintf(search, sizeof(search), "\"%s\":", key); - - const char *start = strstr(json, search); - if (!start) return 0; - - start += strlen(search); - - while (*start && isspace(*start)) start++; - - return strncmp(start, "true", 4) == 0; -} - -/* ============================================================================ - * Execute Functions - * ============================================================================ */ - -unsandbox_result_t *unsandbox_execute( - const char *language, - const char *code, - const char *public_key, - const char *secret_key -) { - if (!language || !code) { - SET_ERROR("Language and code are required"); - return NULL; - } - - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return NULL; - } - - char body[65536]; - snprintf(body, sizeof(body), "{\"language\":\"%s\",\"code\":%.*s}", - language, - (int)(strlen(code) < 65000 ? strlen(code) : 65000), code); - - buffer_t response; - buffer_init(&response); - - request_args_t req = { - .method = "POST", - .path = "/execute", - .body = body, - .public_key = pk, - .secret_key = sk - }; - - int result_code = make_request(&req, &response); - free(pk); - free(sk); - - if (result_code != 0) { - buffer_free(&response); - return NULL; - } - - unsandbox_result_t *result = calloc(1, sizeof(unsandbox_result_t)); - result->stdout = json_get_string(response.data, "stdout"); - result->stderr = json_get_string(response.data, "stderr"); - result->exit_code = (int)json_get_long(response.data, "exit_code"); - result->language = json_get_string(response.data, "language"); - result->success = 1; - - buffer_free(&response); - return result; -} - -char *unsandbox_execute_async( - const char *language, - const char *code, - const char *public_key, - const char *secret_key -) { - if (!language || !code) { - SET_ERROR("Language and code are required"); - return NULL; - } - - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return NULL; - } - - char body[65536]; - snprintf(body, sizeof(body), "{\"language\":\"%s\",\"code\":%.*s}", - language, - (int)(strlen(code) < 65000 ? strlen(code) : 65000), code); - - buffer_t response; - buffer_init(&response); - - request_args_t req = { - .method = "POST", - .path = "/execute_async", - .body = body, - .public_key = pk, - .secret_key = sk - }; - - int result_code = make_request(&req, &response); - free(pk); - free(sk); - - if (result_code != 0) { - buffer_free(&response); - return NULL; - } - - char *job_id = json_get_string(response.data, "job_id"); - buffer_free(&response); - return job_id; -} - -unsandbox_result_t *unsandbox_wait_job( - const char *job_id, - const char *public_key, - const char *secret_key -) { - if (!job_id) { - SET_ERROR("Job ID is required"); - return NULL; - } - - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return NULL; - } - - int poll_delays_ms[] = {300, 450, 700, 900, 650, 1600, 2000}; - int poll_count = 0; - - while (poll_count < MAX_POLL_ATTEMPTS) { - buffer_t response; - buffer_init(&response); - - char path[256]; - snprintf(path, sizeof(path), "/jobs/%s", job_id); - - request_args_t req = { - .method = "GET", - .path = path, - .body = "", - .public_key = pk, - .secret_key = sk - }; - - if (make_request(&req, &response) != 0) { - buffer_free(&response); - free(pk); - free(sk); - return NULL; - } - - const char *status = json_get_string(response.data, "status"); - if (status && strcmp(status, "completed") == 0) { - unsandbox_result_t *result = calloc(1, sizeof(unsandbox_result_t)); - result->stdout = json_get_string(response.data, "stdout"); - result->stderr = json_get_string(response.data, "stderr"); - result->exit_code = (int)json_get_long(response.data, "exit_code"); - result->language = json_get_string(response.data, "language"); - result->success = 1; - buffer_free(&response); - free(pk); - free(sk); - free((char *)status); - return result; - } - - buffer_free(&response); - free((char *)status); - - if (poll_count < sizeof(poll_delays_ms) / sizeof(poll_delays_ms[0])) { - usleep(poll_delays_ms[poll_count] * 1000); + // Unescape while copying + char *out = result; + for (const char *p = start; p < end; p++) { + if (*p == '\\' && p + 1 < end) { + p++; + switch (*p) { + case 'n': *out++ = '\n'; break; + case 't': *out++ = '\t'; break; + case 'r': *out++ = '\r'; break; + case '\\': *out++ = '\\'; break; + case '"': *out++ = '"'; break; + default: *out++ = *p; break; + } } else { - usleep(2000 * 1000); + *out++ = *p; } + } + *out = 0; + return result; +} - poll_count++; +// Extract JSON number value (returns -1 if not found or null) +long long extract_json_number(const char *json, const char *key) { + char search[256]; + snprintf(search, sizeof(search), "\"%s\":", key); + const char *start = strstr(json, search); + if (!start) return -1; + + start += strlen(search); + // Skip whitespace + while (*start == ' ' || *start == '\t') start++; + + // Check for null + if (strncmp(start, "null", 4) == 0) return -1; + + return atoll(start); +} + +// Format bytes to human readable (e.g., 1234567 -> "1.2M") +void format_bytes(long long bytes, char *buf, size_t bufsize) { + if (bytes < 0) { + snprintf(buf, bufsize, "-"); + } else if (bytes < 1024) { + snprintf(buf, bufsize, "%lldB", bytes); + } else if (bytes < 1024 * 1024) { + snprintf(buf, bufsize, "%.1fK", bytes / 1024.0); + } else if (bytes < 1024LL * 1024 * 1024) { + snprintf(buf, bufsize, "%.1fM", bytes / (1024.0 * 1024)); + } else { + snprintf(buf, bufsize, "%.1fG", bytes / (1024.0 * 1024 * 1024)); + } +} + +// Get basename without extension +char* get_basename_no_ext(const char *path) { + const char *base = strrchr(path, '/'); + base = base ? base + 1 : path; + + char *result = strdup(base); + char *dot = strrchr(result, '.'); + if (dot) *dot = '\0'; + return result; +} + +// Parse and handle response +void parse_and_print_response(const char *json_response, int save_artifacts, const char *artifact_dir, const char *source_file) { + // Print stdout in blue + char *out = extract_json_string(json_response, "stdout"); + if (out && strlen(out) > 0) { + printf("\033[34m%s\033[0m", out); + free(out); } - free(pk); - free(sk); - SET_ERROR("Job polling timeout"); + // Print stderr in red + char *err = extract_json_string(json_response, "stderr"); + if (err && strlen(err) > 0) { + fprintf(stderr, "\033[31m%s\033[0m", err); + free(err); + } + + // Print API error in bold red + char *error = extract_json_string(json_response, "error"); + if (error && strlen(error) > 0) { + fprintf(stderr, "\033[1;31mError: %s\033[0m\n", error); + free(error); + } + + // Handle artifacts - API returns "artifacts":[{"filename":"...", "content_base64":"..."}] + if (save_artifacts) { + const char *artifacts_start = strstr(json_response, "\"artifacts\":["); + if (artifacts_start) { + const char *pos = artifacts_start + 13; // Skip "artifacts":[ + + // Process each artifact in array + while ((pos = strchr(pos, '{')) != NULL) { + char *artifact_data = extract_json_string(pos, "content_base64"); + char *artifact_filename = extract_json_string(pos, "filename"); + + if (artifact_data) { + size_t decoded_len; + unsigned char *decoded = base64_decode(artifact_data, strlen(artifact_data), &decoded_len); + + if (decoded) { + char output_path[512]; + const char *fname = artifact_filename ? artifact_filename : "a.out"; + + // For executables, use source filename instead of API filename + char *source_basename = NULL; + int is_elf = decoded_len >= 4 && decoded[0] == 0x7F && + decoded[1] == 'E' && decoded[2] == 'L' && decoded[3] == 'F'; + int is_pe = decoded_len >= 2 && decoded[0] == 'M' && decoded[1] == 'Z'; + int is_macho = decoded_len >= 4 && + ((decoded[0] == 0xCF && decoded[1] == 0xFA) || + (decoded[0] == 0xFE && decoded[1] == 0xED)); + + if ((is_elf || is_pe || is_macho) && source_file) { + source_basename = get_basename_no_ext(source_file); + fname = source_basename; + } + + if (artifact_dir) { + snprintf(output_path, sizeof(output_path), "%s/%s", artifact_dir, fname); + } else { + snprintf(output_path, sizeof(output_path), "%s", fname); + } + + FILE *f = fopen(output_path, "wb"); + if (f) { + fwrite(decoded, 1, decoded_len, f); + fclose(f); + + // Check magic bytes to determine if executable + int is_executable = 0; + int is_data = 0; + if (decoded_len >= 4) { + // Executable formats + if (decoded[0] == 0x7F && decoded[1] == 'E' && + decoded[2] == 'L' && decoded[3] == 'F') { + is_executable = 1; // ELF + } else if (decoded[0] == 'M' && decoded[1] == 'Z') { + is_executable = 1; // PE/Windows + } else if ((decoded[0] == 0xCF && decoded[1] == 0xFA) || + (decoded[0] == 0xFE && decoded[1] == 0xED)) { + is_executable = 1; // Mach-O + } else if (decoded[0] == '#' && decoded[1] == '!') { + is_executable = 1; // Shebang script + } + // Data formats - don't make executable + else if (decoded[0] == 0x89 && decoded[1] == 'P' && + decoded[2] == 'N' && decoded[3] == 'G') { + is_data = 1; // PNG + } else if (decoded[0] == 0xFF && decoded[1] == 0xD8) { + is_data = 1; // JPEG + } else if (decoded[0] == 'G' && decoded[1] == 'I' && + decoded[2] == 'F' && decoded[3] == '8') { + is_data = 1; // GIF + } else if (decoded[0] == '%' && decoded[1] == 'P' && + decoded[2] == 'D' && decoded[3] == 'F') { + is_data = 1; // PDF + } else if (decoded[0] == 'P' && decoded[1] == 'K' && + decoded[2] == 0x03 && decoded[3] == 0x04) { + is_data = 1; // ZIP + } else if (decoded[0] == 0x1F && decoded[1] == 0x8B) { + is_data = 1; // GZIP + } else if (decoded[0] == '{' || decoded[0] == '[') { + is_data = 1; // JSON + } else if (decoded[0] == '<') { + is_data = 1; // XML/HTML + } + } + + // Fall back to extension if magic bytes inconclusive + if (!is_executable && !is_data) { + const char *ext = strrchr(fname, '.'); + if (ext) { + is_data = ( + // Images + strcmp(ext, ".png") == 0 || strcmp(ext, ".jpg") == 0 || + strcmp(ext, ".jpeg") == 0 || strcmp(ext, ".gif") == 0 || + strcmp(ext, ".svg") == 0 || strcmp(ext, ".webp") == 0 || + strcmp(ext, ".bmp") == 0 || strcmp(ext, ".ico") == 0 || + strcmp(ext, ".tiff") == 0 || strcmp(ext, ".tif") == 0 || + strcmp(ext, ".psd") == 0 || strcmp(ext, ".ai") == 0 || + strcmp(ext, ".eps") == 0 || strcmp(ext, ".raw") == 0 || + // Documents + strcmp(ext, ".pdf") == 0 || strcmp(ext, ".doc") == 0 || + strcmp(ext, ".docx") == 0 || strcmp(ext, ".xls") == 0 || + strcmp(ext, ".xlsx") == 0 || strcmp(ext, ".ppt") == 0 || + strcmp(ext, ".pptx") == 0 || strcmp(ext, ".odt") == 0 || + strcmp(ext, ".ods") == 0 || strcmp(ext, ".odp") == 0 || + strcmp(ext, ".rtf") == 0 || strcmp(ext, ".tex") == 0 || + // Text/Config + strcmp(ext, ".txt") == 0 || strcmp(ext, ".md") == 0 || + strcmp(ext, ".rst") == 0 || strcmp(ext, ".log") == 0 || + strcmp(ext, ".json") == 0 || strcmp(ext, ".xml") == 0 || + strcmp(ext, ".yaml") == 0 || strcmp(ext, ".yml") == 0 || + strcmp(ext, ".toml") == 0 || strcmp(ext, ".ini") == 0 || + strcmp(ext, ".conf") == 0 || strcmp(ext, ".cfg") == 0 || + strcmp(ext, ".config") == 0 || strcmp(ext, ".env") == 0 || + strcmp(ext, ".properties") == 0 || strcmp(ext, ".plist") == 0 || + // Data + strcmp(ext, ".csv") == 0 || strcmp(ext, ".tsv") == 0 || + strcmp(ext, ".sql") == 0 || strcmp(ext, ".db") == 0 || + strcmp(ext, ".sqlite") == 0 || strcmp(ext, ".parquet") == 0 || + strcmp(ext, ".avro") == 0 || strcmp(ext, ".npy") == 0 || + strcmp(ext, ".npz") == 0 || strcmp(ext, ".pkl") == 0 || + strcmp(ext, ".pickle") == 0 || strcmp(ext, ".h5") == 0 || + strcmp(ext, ".hdf5") == 0 || + // Web + strcmp(ext, ".html") == 0 || strcmp(ext, ".htm") == 0 || + strcmp(ext, ".css") == 0 || strcmp(ext, ".scss") == 0 || + strcmp(ext, ".sass") == 0 || strcmp(ext, ".less") == 0 || + strcmp(ext, ".woff") == 0 || strcmp(ext, ".woff2") == 0 || + strcmp(ext, ".ttf") == 0 || strcmp(ext, ".otf") == 0 || + strcmp(ext, ".eot") == 0 || + // Archives + strcmp(ext, ".zip") == 0 || strcmp(ext, ".tar") == 0 || + strcmp(ext, ".gz") == 0 || strcmp(ext, ".tgz") == 0 || + strcmp(ext, ".bz2") == 0 || strcmp(ext, ".xz") == 0 || + strcmp(ext, ".7z") == 0 || strcmp(ext, ".rar") == 0 || + strcmp(ext, ".zst") == 0 || + // Audio + strcmp(ext, ".mp3") == 0 || strcmp(ext, ".wav") == 0 || + strcmp(ext, ".flac") == 0 || strcmp(ext, ".aac") == 0 || + strcmp(ext, ".ogg") == 0 || strcmp(ext, ".m4a") == 0 || + strcmp(ext, ".wma") == 0 || strcmp(ext, ".aiff") == 0 || + // Video + strcmp(ext, ".mp4") == 0 || strcmp(ext, ".mkv") == 0 || + strcmp(ext, ".avi") == 0 || strcmp(ext, ".mov") == 0 || + strcmp(ext, ".wmv") == 0 || strcmp(ext, ".flv") == 0 || + strcmp(ext, ".webm") == 0 || strcmp(ext, ".m4v") == 0 || + // 3D/CAD + strcmp(ext, ".obj") == 0 || strcmp(ext, ".stl") == 0 || + strcmp(ext, ".fbx") == 0 || strcmp(ext, ".gltf") == 0 || + strcmp(ext, ".glb") == 0 || + // Misc + strcmp(ext, ".lock") == 0 || strcmp(ext, ".sum") == 0 || + strcmp(ext, ".map") == 0 || strcmp(ext, ".wasm") == 0 + ); + } + } + + if (is_executable || !is_data) { + chmod(output_path, 0755); + } + fprintf(stderr, "\033[32mArtifact saved: %s (%zu bytes)\033[0m\n", output_path, decoded_len); + } + if (source_basename) free(source_basename); + free(decoded); + } + free(artifact_data); + } + if (artifact_filename) free(artifact_filename); + + // Move to next object + pos++; + const char *next_obj = strchr(pos, '{'); + const char *end_arr = strchr(pos, ']'); + if (!next_obj || (end_arr && end_arr < next_obj)) break; + pos = next_obj; + } + } + } +} + +// Get basename from path +const char* get_basename(const char *path) { + const char *base = strrchr(path, '/'); + return base ? base + 1 : path; +} + +// Poll job status with exponential backoff +// Returns the final response JSON (caller must free), or NULL on error +static char* poll_job_status(const UnsandboxCredentials *creds, const char *job_id) { + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + char url[512]; + char path[256]; + snprintf(path, sizeof(path), "/jobs/%s", job_id); + snprintf(url, sizeof(url), "%s%s", API_BASE, path); + + int poll_count = 0; + char *final_response = NULL; + + while (1) { + // Sleep before polling (except first iteration handled by caller) + int delay_idx = poll_count < POLL_DELAYS_COUNT ? poll_count : POLL_DELAYS_COUNT - 1; + usleep(POLL_DELAYS[delay_idx] * 1000); + poll_count++; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + // Regenerate auth headers each poll to keep timestamp fresh + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + + if (res != CURLE_OK) { + fprintf(stderr, "Error polling job: %s\n", curl_easy_strerror(res)); + free(response.data); + break; + } + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code == 404) { + fprintf(stderr, "Error: job not found\n"); + free(response.data); + break; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld while polling job\n", http_code); + free(response.data); + break; + } + + // Check status field + char *status = extract_json_string(response.data, "status"); + if (!status) { + // No status field - might be final result format + final_response = response.data; + break; + } + + int is_terminal = (strcmp(status, "completed") == 0 || + strcmp(status, "failed") == 0 || + strcmp(status, "timeout") == 0 || + strcmp(status, "cancelled") == 0); + free(status); + + if (is_terminal) { + final_response = response.data; + break; + } + + // Still running - continue polling + free(response.data); + } + + curl_easy_cleanup(curl); + return final_response; +} + +// ============================================================================ +// Interactive Shell Support +// ============================================================================ + +static struct termios orig_termios; +static int shell_running = 0; +static struct lws *shell_wsi = NULL; + +// Terminal size +static void get_terminal_size(int *cols, int *rows) { + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) { + *cols = ws.ws_col; + *rows = ws.ws_row; + } else { + *cols = 80; + *rows = 24; + } +} + +// Raw terminal mode +static void enable_raw_mode(void) { + tcgetattr(STDIN_FILENO, &orig_termios); + struct termios raw = orig_termios; + raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN); + raw.c_iflag &= ~(IXON | ICRNL | BRKINT | INPCK | ISTRIP); + raw.c_oflag &= ~(OPOST); + raw.c_cflag |= (CS8); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 1; + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +} + +static void disable_raw_mode(void) { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios); +} + +// Signal handler for terminal resize +static void handle_sigwinch(int sig) { + (void)sig; + // Flag set - resize sent in main loop +} + +// Signal handler for interrupt +static void handle_sigint(int sig) { + (void)sig; + shell_running = 0; +} + +// WebSocket shell state +struct shell_state { + char *session_id; + int connected; + unsigned char *send_buf; + size_t send_len; + int need_resize; + int detached; // 1 if session was detached (can reconnect), 0 if ended + int need_initial_enter; // 1 to send Enter after connect (tmux repaint fix) +}; + +// WebSocket callback for shell +static int shell_ws_callback(struct lws *wsi, enum lws_callback_reasons reason, + void *user, void *in, size_t len) { + struct shell_state *state = (struct shell_state *)user; + + switch (reason) { + case LWS_CALLBACK_CLIENT_ESTABLISHED: + state->connected = 1; + state->need_resize = 1; // Send initial resize + lws_callback_on_writable(wsi); + break; + + case LWS_CALLBACK_CLIENT_RECEIVE: + if (in && len > 0) { + // Check if it's a binary frame (raw stdout) or text frame (JSON control) + if (lws_frame_is_binary(wsi)) { + // Binary frame = raw shell output, write directly + write(STDOUT_FILENO, in, len); + } else { + // Text frame = JSON control message (exit, error, detached, etc.) + char *json = (char *)in; + if (strstr(json, "\"type\":\"exit\"")) { + shell_running = 0; + state->detached = 0; // Session ended + } else if (strstr(json, "\"type\":\"detached\"")) { + shell_running = 0; + state->detached = 1; // Detached, can reconnect + } + } + } + break; + + case LWS_CALLBACK_CLIENT_WRITEABLE: + if (!state->connected) break; + + // Send resize if needed + if (state->need_resize) { + int cols, rows; + get_terminal_size(&cols, &rows); + char msg[128]; + int mlen = snprintf(msg, sizeof(msg), + "{\"type\":\"resize\",\"cols\":%d,\"rows\":%d}", cols, rows); + unsigned char buf[LWS_PRE + 128]; + memcpy(&buf[LWS_PRE], msg, mlen); + lws_write(wsi, &buf[LWS_PRE], mlen, LWS_WRITE_TEXT); + state->need_resize = 0; + lws_callback_on_writable(wsi); + break; + } + + // Send initial Enter to force tmux repaint (fixes blank screen on connect) + if (state->need_initial_enter) { + unsigned char buf[LWS_PRE + 1]; + buf[LWS_PRE] = '\n'; + lws_write(wsi, &buf[LWS_PRE], 1, LWS_WRITE_BINARY); + state->need_initial_enter = 0; + break; + } + + // Send stdin data as binary frame (fast path, no JSON encoding) + if (state->send_buf && state->send_len > 0) { + unsigned char buf[LWS_PRE + 256]; + memcpy(&buf[LWS_PRE], state->send_buf, state->send_len); + lws_write(wsi, &buf[LWS_PRE], state->send_len, LWS_WRITE_BINARY); + + free(state->send_buf); + state->send_buf = NULL; + state->send_len = 0; + } + break; + + case LWS_CALLBACK_CLIENT_CONNECTION_ERROR: + fprintf(stderr, "\r\nConnection error: %s\r\n", in ? (char *)in : "unknown"); + shell_running = 0; + break; + + case LWS_CALLBACK_CLIENT_CLOSED: + shell_running = 0; + break; + + default: + break; + } + return 0; +} + +static const struct lws_protocols shell_protocols[] = { + {"unsandbox-shell", shell_ws_callback, sizeof(struct shell_state), 4096, 0, NULL, 0}, + {NULL, NULL, 0, 0, 0, NULL, 0} +}; + +// Session info returned from create_session +struct SessionInfo { + char *session_id; + char *container_name; +}; + +// Find session ID by container name (for reconnect by container name) +static char* find_session_by_container(const UnsandboxCredentials *creds, const char *container_name) { + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/sessions", API_BASE); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", "/sessions", NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || !response.data) { + free(response.data); + return NULL; + } + + // Search for matching container name in sessions array + const char *sessions_start = strstr(response.data, "\"sessions\":["); + if (sessions_start) { + const char *pos = sessions_start + 12; + + while ((pos = strchr(pos, '{')) != NULL) { + char *container = extract_json_string(pos, "container_name"); + char *session_id = extract_json_string(pos, "id"); + + if (container && strcmp(container, container_name) == 0) { + free(container); + free(response.data); + return session_id; // Found it! + } + + if (container) free(container); + if (session_id) free(session_id); + + pos++; + const char *next_obj = strchr(pos, '{'); + const char *end_arr = strchr(pos, ']'); + if (!next_obj || (end_arr && end_arr < next_obj)) break; + pos = next_obj; + } + } + + free(response.data); return NULL; } -unsandbox_job_t *unsandbox_get_job( - const char *job_id, - const char *public_key, - const char *secret_key -) { - if (!job_id) { - SET_ERROR("Job ID is required"); - return NULL; +// Kill a session by ID or container name +static int kill_session(const UnsandboxCredentials *creds, const char *session_id_or_container) { + char *session_id = NULL; + + // Check if it looks like a container name or session ID + // Container names: unsb-vm-*, exec-*, sandbox-* (legacy) + if (strncmp(session_id_or_container, "unsb-vm-", 8) == 0 || + strncmp(session_id_or_container, "exec-", 5) == 0 || + strncmp(session_id_or_container, "sandbox-", 8) == 0) { + // It's a container name - look up the session ID + fprintf(stderr, "Looking up session for %s...", session_id_or_container); + fflush(stderr); + session_id = find_session_by_container(creds, session_id_or_container); + if (!session_id) { + fprintf(stderr, " not found\nError: No active session for container '%s'\n", session_id_or_container); + return 1; + } + fprintf(stderr, " found\n"); + } else { + session_id = strdup(session_id_or_container); } - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return NULL; - } + fprintf(stderr, "Terminating session %s...", session_id); + fflush(stderr); - buffer_t response; - buffer_init(&response); - - char path[256]; - snprintf(path, sizeof(path), "/jobs/%s", job_id); - - request_args_t req = { - .method = "GET", - .path = path, - .body = "", - .public_key = pk, - .secret_key = sk - }; - - if (make_request(&req, &response) != 0) { - buffer_free(&response); - free(pk); - free(sk); - return NULL; - } - - unsandbox_job_t *job = calloc(1, sizeof(unsandbox_job_t)); - job->id = json_get_string(response.data, "id"); - job->status = json_get_string(response.data, "status"); - job->language = json_get_string(response.data, "language"); - job->created_at = json_get_long(response.data, "created_at"); - job->completed_at = json_get_long(response.data, "completed_at"); - job->error_message = json_get_string(response.data, "error_message"); - - buffer_free(&response); - free(pk); - free(sk); - return job; -} - -int unsandbox_cancel_job( - const char *job_id, - const char *public_key, - const char *secret_key -) { - if (!job_id) { - SET_ERROR("Job ID is required"); - return -1; - } - - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return -1; - } - - buffer_t response; - buffer_init(&response); - - char path[256]; - snprintf(path, sizeof(path), "/jobs/%s", job_id); - - request_args_t req = { - .method = "DELETE", - .path = path, - .body = "", - .public_key = pk, - .secret_key = sk - }; - - int result = make_request(&req, &response); - buffer_free(&response); - free(pk); - free(sk); - return result; -} - -unsandbox_job_list_t *unsandbox_list_jobs( - const char *public_key, - const char *secret_key -) { - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return NULL; - } - - buffer_t response; - buffer_init(&response); - - request_args_t req = { - .method = "GET", - .path = "/jobs", - .body = "", - .public_key = pk, - .secret_key = sk - }; - - if (make_request(&req, &response) != 0) { - buffer_free(&response); - free(pk); - free(sk); - return NULL; - } - - unsandbox_job_list_t *list = calloc(1, sizeof(unsandbox_job_list_t)); - list->jobs = calloc(100, sizeof(unsandbox_job_t)); - list->count = 0; - - buffer_free(&response); - free(pk); - free(sk); - return list; -} - -unsandbox_languages_t *unsandbox_get_languages( - const char *public_key, - const char *secret_key -) { - char *pk = NULL, *sk = NULL; - if (unsandbox_resolve_credentials(&pk, &sk, public_key, secret_key) != 0) { - return NULL; - } - - buffer_t response; - buffer_init(&response); - - request_args_t req = { - .method = "GET", - .path = "/languages", - .body = "", - .public_key = pk, - .secret_key = sk - }; - - if (make_request(&req, &response) != 0) { - buffer_free(&response); - free(pk); - free(sk); - return NULL; - } - - unsandbox_languages_t *langs = calloc(1, sizeof(unsandbox_languages_t)); - langs->languages = calloc(100, sizeof(char *)); - langs->count = 0; - - buffer_free(&response); - free(pk); - free(sk); - return langs; -} - -/* ============================================================================ - * Memory Management - * ============================================================================ */ - -void unsandbox_free_result(unsandbox_result_t *result) { - if (!result) return; - if (result->stdout) free(result->stdout); - if (result->stderr) free(result->stderr); - if (result->language) free(result->language); - if (result->error_message) free(result->error_message); - free(result); -} - -void unsandbox_free_job(unsandbox_job_t *job) { - if (!job) return; - if (job->id) free(job->id); - if (job->status) free(job->status); - if (job->language) free(job->language); - if (job->error_message) free(job->error_message); - free(job); -} - -void unsandbox_free_job_list(unsandbox_job_list_t *jobs) { - if (!jobs) return; - for (size_t i = 0; i < jobs->count; i++) { - unsandbox_free_job(&jobs->jobs[i]); - } - if (jobs->jobs) free(jobs->jobs); - free(jobs); -} - -void unsandbox_free_languages(unsandbox_languages_t *langs) { - if (!langs) return; - for (size_t i = 0; i < langs->count; i++) { - if (langs->languages[i]) free(langs->languages[i]); - } - if (langs->languages) free(langs->languages); - free(langs); -} - -void unsandbox_free_quota(unsandbox_quota_t *quota) { - if (quota) free(quota); -} - -/* ============================================================================ - * Utility Functions - * ============================================================================ */ - -const char *unsandbox_last_error(void) { - return g_last_error[0] ? g_last_error : NULL; -} - -int unsandbox_health_check(void) { CURL *curl = curl_easy_init(); - if (!curl) return -1; + if (!curl) { + free(session_id); + return 1; + } - buffer_t response; - buffer_init(&response); + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; - curl_easy_setopt(curl, CURLOPT_URL, API_BASE "/health"); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, http_write_callback); + char url[512]; + char path[256]; + snprintf(url, sizeof(url), "%s/sessions/%s", API_BASE, session_id); + snprintf(path, sizeof(path), "/sessions/%s", session_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); CURLcode res = curl_easy_perform(curl); - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); curl_easy_cleanup(curl); - buffer_free(&response); - if (res != CURLE_OK) return -1; - if (response_code != 200) return 0; + if (res != CURLE_OK) { + fprintf(stderr, " failed\nError: %s\n", curl_easy_strerror(res)); + free(response.data); + free(session_id); + return 1; + } + + if (http_code == 200) { + fprintf(stderr, " done\n"); + fprintf(stderr, "\033[32mSession terminated successfully\033[0m\n"); + } else if (http_code == 404) { + fprintf(stderr, " not found\nError: Session not found or already terminated\n"); + free(response.data); + free(session_id); + return 1; + } else { + fprintf(stderr, " failed\nError: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + free(session_id); + return 1; + } + + free(response.data); + free(session_id); + return 0; +} + +// Freeze a session +static int freeze_session(const UnsandboxCredentials *creds, const char *session_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + char path[256]; + snprintf(url, sizeof(url), "%s/sessions/%s/freeze", API_BASE, session_id); + snprintf(path, sizeof(path), "/sessions/%s/freeze", session_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Session not found\n"); + free(response.data); + return 1; + } + + if (http_code == 400) { + // Parse error message from response + fprintf(stderr, "Error: %s\n", response.data); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + fprintf(stderr, "\033[32mSession frozen successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Unfreeze a session +static int unfreeze_session(const UnsandboxCredentials *creds, const char *session_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + char path[256]; + snprintf(url, sizeof(url), "%s/sessions/%s/unfreeze", API_BASE, session_id); + snprintf(path, sizeof(path), "/sessions/%s/unfreeze", session_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Session not found\n"); + free(response.data); + return 1; + } + + if (http_code == 429) { + // Concurrency limit reached + fprintf(stderr, "Error: Concurrency limit reached - cannot unfreeze session\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mSession woken successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Boost a session's resources (increase vCPU, memory is derived: vcpu * 2048MB) +static int boost_session(const UnsandboxCredentials *creds, const char *session_id, int vcpu) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + char path[256]; + snprintf(url, sizeof(url), "%s/sessions/%s/boost", API_BASE, session_id); + snprintf(path, sizeof(path), "/sessions/%s/boost", session_id); + + char post_data[256]; + snprintf(post_data, sizeof(post_data), "{\"vcpu\":%d}", vcpu); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, post_data); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Session not found\n"); + free(response.data); + return 1; + } + + if (http_code == 429) { + fprintf(stderr, "Error: Not enough concurrency slots to boost (boost consumes additional slots)\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + int memory_mb = vcpu * 2048; + printf("\033[32mSession boosted to %d vCPU, %d MB RAM\033[0m\n", vcpu, memory_mb); + free(response.data); + return 0; +} + +// Remove boost from a session (return to base resources) +static int unboost_session(const UnsandboxCredentials *creds, const char *session_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + char path[256]; + snprintf(url, sizeof(url), "%s/sessions/%s/unboost", API_BASE, session_id); + snprintf(path, sizeof(path), "/sessions/%s/unboost", session_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Session not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mSession unboosted, returning to base resources\033[0m\n"); + free(response.data); + return 0; +} + +// List active sessions +static int list_sessions(const UnsandboxCredentials *creds) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/sessions", API_BASE); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", "/sessions", NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Parse and display sessions + // Format: {"sessions":[...], "count": N} + if (!response.data || !strstr(response.data, "\"sessions\"")) { + fprintf(stderr, "Error: Invalid response\n"); + free(response.data); + return 1; + } + + // Extract count + const char *count_str = strstr(response.data, "\"count\":"); + int count = 0; + if (count_str) { + count = atoi(count_str + 8); + } + + if (count == 0) { + printf("No active sessions\n"); + free(response.data); + return 0; + } + + printf("Active sessions: %d\n\n", count); + printf("%-40s %-20s %-10s %-8s %-10s\n", "SESSION ID", "CONTAINER", "SHELL", "TTL", "STATUS"); + printf("%-40s %-20s %-10s %-8s %-10s\n", "----------------------------------------", + "--------------------", "----------", "--------", "----------"); + + // Parse sessions array - simple parser for [{...}, {...}] + const char *sessions_start = strstr(response.data, "\"sessions\":["); + if (sessions_start) { + const char *pos = sessions_start + 12; + + while ((pos = strchr(pos, '{')) != NULL) { + char *session_id = extract_json_string(pos, "id"); + char *container = extract_json_string(pos, "container_name"); + char *shell = extract_json_string(pos, "shell"); + char *status = extract_json_string(pos, "status"); + + // Extract remaining_ttl (numeric) + int remaining_ttl = 0; + const char *ttl_str = strstr(pos, "\"remaining_ttl\":"); + if (ttl_str) { + remaining_ttl = atoi(ttl_str + 16); + } + + // Format TTL as human-readable + char ttl_fmt[16]; + if (remaining_ttl >= 3600) { + snprintf(ttl_fmt, sizeof(ttl_fmt), "%dh%dm", remaining_ttl / 3600, (remaining_ttl % 3600) / 60); + } else if (remaining_ttl >= 60) { + snprintf(ttl_fmt, sizeof(ttl_fmt), "%dm%ds", remaining_ttl / 60, remaining_ttl % 60); + } else { + snprintf(ttl_fmt, sizeof(ttl_fmt), "%ds", remaining_ttl); + } + + printf("%-40s %-20s %-10s %-8s %-10s\n", + session_id ? session_id : "-", + container ? container : "-", + shell ? shell : "bash", + ttl_fmt, + status ? status : "-"); + + if (session_id) free(session_id); + if (container) free(container); + if (shell) free(shell); + if (status) free(status); + + // Move to next object + pos++; + const char *next_obj = strchr(pos, '{'); + const char *end_arr = strchr(pos, ']'); + if (!next_obj || (end_arr && end_arr < next_obj)) break; + pos = next_obj; + } + } + + free(response.data); + return 0; +} + +// Create a session via HTTP API +// multiplexer: NULL for no multiplexer (default), "screen", or "tmux" +// input_files: array of files to include (written to /tmp/ in container) +// input_file_count: number of input files +static struct SessionInfo create_session(const UnsandboxCredentials *creds, const char *network_mode, int audit, const char *shell, const char *multiplexer, int vcpu, struct InputFile *input_files, int input_file_count) { + struct SessionInfo info = {NULL, NULL}; + CURL *curl = curl_easy_init(); + if (!curl) return info; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + if (audit) { + snprintf(url, sizeof(url), "%s/sessions?audit=1", API_BASE); + } else { + snprintf(url, sizeof(url), "%s/sessions", API_BASE); + } + + // Calculate required payload size + size_t payload_size = 512; // Base size for JSON structure + for (int i = 0; i < input_file_count; i++) { + payload_size += strlen(input_files[i].content_base64) + 256; + } + + // Build payload with optional shell and multiplexer + char *payload = malloc(payload_size); + if (!payload) { + curl_easy_cleanup(curl); + free(response.data); + return info; + } + char *p = payload; + p += sprintf(p, "{\"network_mode\":\"%s\",\"ttl\":3600", + network_mode ? network_mode : "zerotrust"); + if (shell && strlen(shell) > 0) { + p += sprintf(p, ",\"shell\":\"%s\"", shell); + } + if (multiplexer && strlen(multiplexer) > 0) { + p += sprintf(p, ",\"multiplexer\":\"%s\"", multiplexer); + } + if (vcpu > 1) { + p += sprintf(p, ",\"vcpu\":%d", vcpu); + } + // Add input files + if (input_file_count > 0) { + p += sprintf(p, ",\"input_files\":["); + for (int i = 0; i < input_file_count; i++) { + if (i > 0) *p++ = ','; + char *esc_filename = escape_json_string(input_files[i].filename); + p += sprintf(p, "{\"filename\":\"%s\",\"content\":\"%s\"}", + esc_filename, input_files[i].content_base64); + free(esc_filename); + } + p += sprintf(p, "]"); + } + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", "/sessions", payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + // Get HTTP status code before cleanup + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, " curl error: %s\n", curl_easy_strerror(res)); + free(response.data); + return info; + } + + // Extract session_id and container_name from response + info.session_id = extract_json_string(response.data, "session_id"); + info.container_name = extract_json_string(response.data, "container_name"); + if (!info.session_id && response.data) { + // Parse error response for user-friendly message + char *error = extract_json_string(response.data, "error"); + char *message = extract_json_string(response.data, "message"); + + if (http_code == 429 && error) { + if (strcmp(error, "concurrency_limit_reached") == 0) { + // Parse active/limit for helpful context + const char *active_str = strstr(response.data, "\"active_executions\":"); + const char *limit_str = strstr(response.data, "\"concurrency_limit\":"); + int active = active_str ? atoi(active_str + 20) : 0; + int limit = limit_str ? atoi(limit_str + 20) : 1; + + fprintf(stderr, "\n\n"); + fprintf(stderr, " \033[1;33mSession limit reached\033[0m\n\n"); + fprintf(stderr, " You have %d of %d concurrent session%s in use.\n", + active, limit, limit == 1 ? "" : "s"); + fprintf(stderr, "\n"); + fprintf(stderr, " To continue, either:\n"); + fprintf(stderr, " 1. Wait for a running session to finish\n"); + fprintf(stderr, " 2. Run '\033[36mun session --list\033[0m' to see active sessions\n"); + fprintf(stderr, " 3. Run '\033[36mun session --attach \033[0m' to reconnect\n"); + fprintf(stderr, "\n"); + } else if (strcmp(error, "rate_limit_exceeded") == 0) { + fprintf(stderr, "\n\n"); + fprintf(stderr, " \033[1;33mRate limit exceeded\033[0m\n\n"); + if (message) { + fprintf(stderr, " %s\n", message); + } else { + fprintf(stderr, " Too many requests. Please wait a moment and try again.\n"); + } + fprintf(stderr, "\n"); + } else { + fprintf(stderr, "\n \033[1;31mError:\033[0m %s\n", message ? message : error); + } + } else if (http_code == 401) { + fprintf(stderr, "\n\n"); + fprintf(stderr, " \033[1;31mAuthentication failed\033[0m\n\n"); + // Check if it's a timestamp issue + if (message && (strstr(message, "timestamp") || strstr(message, "Timestamp"))) { + fprintf(stderr, " Request timestamp expired (must be within 5 minutes of server time).\n\n"); + fprintf(stderr, " \033[1;33mYour computer's clock may have drifted.\033[0m\n"); + fprintf(stderr, " Check your system time and sync with NTP if needed:\n"); + fprintf(stderr, " Linux: sudo ntpdate -s time.nist.gov\n"); + fprintf(stderr, " macOS: sudo sntp -sS time.apple.com\n"); + fprintf(stderr, " Windows: w32tm /resync\n"); + } else { + fprintf(stderr, " Your API key is invalid or expired.\n"); + fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY to valid keys.\n"); + } + fprintf(stderr, "\n"); + } else if (error || message) { + fprintf(stderr, "\n \033[1;31mError:\033[0m %s\n", message ? message : error); + } else { + fprintf(stderr, " HTTP %ld: %s\n", http_code, response.data); + } + + if (error) free(error); + if (message) free(message); + } + free(payload); + free(response.data); + return info; +} + +// Terminate a session and optionally save artifacts +// save_artifacts: 1 to save, 0 to discard +// artifact_dir: directory to save artifacts (NULL for current dir) +// container_name: used to postfix artifact filenames (e.g., bash_history-sandbox-abc123) +static void terminate_session(const UnsandboxCredentials *creds, const char *session_id, int save_artifacts, const char *artifact_dir, int audit_history, const char *container_name) { + CURL *curl = curl_easy_init(); + if (!curl) return; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + char path[256]; + snprintf(path, sizeof(path), "/sessions/%s", session_id); + if (audit_history) { + // Request audit - server will copy bash history to /tmp/artifacts before termination + snprintf(url, sizeof(url), "%s%s?audit=1", API_BASE, path); + } else { + snprintf(url, sizeof(url), "%s%s", API_BASE, path); + } + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res == CURLE_OK && response.data && save_artifacts) { + // Parse artifacts from response with container name for postfixing filenames + const char *artifacts_start = strstr(response.data, "\"artifacts\":["); + if (artifacts_start) { + const char *pos = artifacts_start + 13; // Skip "artifacts":[ + + // Process each artifact in array + while ((pos = strchr(pos, '{')) != NULL) { + char *artifact_data = extract_json_string(pos, "content_base64"); + char *artifact_filename = extract_json_string(pos, "filename"); + + if (artifact_data) { + size_t decoded_len; + unsigned char *decoded = base64_decode(artifact_data, strlen(artifact_data), &decoded_len); + + if (decoded) { + char output_path[512]; + const char *fname = artifact_filename ? artifact_filename : "artifact"; + + // Postfix filename with container name if available + // e.g., bash_history -> bash_history-sandbox-abc123 + char postfixed_name[256]; + if (container_name) { + // Find extension if any + const char *ext = strrchr(fname, '.'); + if (ext) { + // Has extension: file.ext -> file-container.ext + size_t base_len = ext - fname; + snprintf(postfixed_name, sizeof(postfixed_name), "%.*s-%s%s", + (int)base_len, fname, container_name, ext); + } else { + // No extension: file -> file-container + snprintf(postfixed_name, sizeof(postfixed_name), "%s-%s", fname, container_name); + } + fname = postfixed_name; + } + + if (artifact_dir) { + snprintf(output_path, sizeof(output_path), "%s/%s", artifact_dir, fname); + } else { + snprintf(output_path, sizeof(output_path), "%s", fname); + } + + FILE *f = fopen(output_path, "wb"); + if (f) { + fwrite(decoded, 1, decoded_len, f); + fclose(f); + fprintf(stderr, "\033[32mArtifact saved: %s (%zu bytes)\033[0m\n", output_path, decoded_len); + } + free(decoded); + } + free(artifact_data); + } + if (artifact_filename) free(artifact_filename); + + // Move to next object + pos++; + const char *next_obj = strchr(pos, '{'); + const char *end_arr = strchr(pos, ']'); + if (!next_obj || (end_arr && end_arr < next_obj)) break; + pos = next_obj; + } + } + } + + free(response.data); +} + +// Reconnect to existing session (shared WebSocket setup with shell_command) +static int reconnect_session(const UnsandboxCredentials *creds, const char *session_id_or_container, int save_artifacts, const char *artifact_dir, int audit_history); + +// Main shell command +// multiplexer: NULL for no multiplexer (default), "screen", or "tmux" +// input_files: array of files to include in session (written to /tmp/ in container) +// input_file_count: number of input files +static int shell_command(const UnsandboxCredentials *creds, const char *network_mode, int save_artifacts, const char *artifact_dir, int audit_history, const char *shell, const char *multiplexer, int vcpu, struct InputFile *input_files, int input_file_count) { + // Disable stdout buffering for real-time output + setvbuf(stdout, NULL, _IONBF, 0); + + // Create session (pass audit flag to enable script recording on server) + fprintf(stderr, "Connecting to unsandbox..."); + fflush(stderr); + struct SessionInfo session = create_session(creds, network_mode, audit_history, shell, multiplexer, vcpu, input_files, input_file_count); + if (!session.session_id) { + // Detailed error already printed by create_session + return 1; + } + char *session_id = session.session_id; + char *container_name = session.container_name; + fprintf(stderr, " done\n"); + + // Set up signal handlers + signal(SIGWINCH, handle_sigwinch); + signal(SIGINT, handle_sigint); + + // Enable raw terminal mode + enable_raw_mode(); + atexit(disable_raw_mode); + + // Disable libwebsockets logging (too noisy) + lws_set_log_level(0, NULL); + + // Create WebSocket context + struct lws_context_creation_info info; + memset(&info, 0, sizeof(info)); + info.port = CONTEXT_PORT_NO_LISTEN; + info.protocols = shell_protocols; + info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT; + + struct lws_context *context = lws_create_context(&info); + if (!context) { + fprintf(stderr, "\r\nError: Failed to create WebSocket context\r\n"); + free(session_id); + return 1; + } + + // Connect to WebSocket + struct lws_client_connect_info ccinfo; + memset(&ccinfo, 0, sizeof(ccinfo)); + ccinfo.context = context; + ccinfo.address = "api.unsandbox.com"; + ccinfo.port = 443; + + char path[256]; + snprintf(path, sizeof(path), "/sessions/%s/shell", session_id); + ccinfo.path = path; + + ccinfo.host = ccinfo.address; + ccinfo.origin = ccinfo.address; + ccinfo.protocol = shell_protocols[0].name; + // LCCSCF_IP_LOW_LATENCY sets TCP_NODELAY to disable Nagle's algorithm + ccinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_IP_LOW_LATENCY; + + shell_wsi = lws_client_connect_via_info(&ccinfo); + if (!shell_wsi) { + fprintf(stderr, "\r\nError: Failed to connect to WebSocket\r\n"); + lws_context_destroy(context); + free(session_id); + return 1; + } + + // Get user data from wsi + struct shell_state *state = (struct shell_state *)lws_wsi_user(shell_wsi); + state->session_id = session_id; + // Send initial Enter to force tmux/screen repaint (fixes blank screen on connect) + if (multiplexer && strlen(multiplexer) > 0) { + state->need_initial_enter = 1; + } + + shell_running = 1; + + // Main event loop - poll ONLY on stdin, service websocket separately + while (shell_running) { + // Poll stdin with very short timeout (1ms) + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + + if (poll(&pfd, 1, 1) > 0 && (pfd.revents & POLLIN)) { + char buf[256]; + ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n > 0 && state->connected) { + state->send_buf = malloc(n); + memcpy(state->send_buf, buf, n); + state->send_len = n; + lws_callback_on_writable(shell_wsi); + // Wake up lws_service if it's sleeping + lws_cancel_service(context); + } + } + + // Service websocket - use NEGATIVE timeout for true non-blocking (lws 3.2+) + lws_service(context, -1); + } + + // Cleanup + disable_raw_mode(); + int was_detached = state->detached; + lws_context_destroy(context); + + if (was_detached) { + fprintf(stderr, "\r\n\033[32mSession detached.\033[0m Reconnect with: un session --attach %s\r\n", container_name); + // Don't terminate - session is still running in multiplexer + } else { + fprintf(stderr, "\r\nSession ended.\r\n"); + // Terminate session and collect artifacts (postfix with container name) + terminate_session(creds, session_id, save_artifacts, artifact_dir, audit_history, container_name); + } + + // Hint for replaying audit logs (only when session ended, not detached) + if (audit_history && !was_detached) { + fprintf(stderr, "\033[33mTip: Replay session with: zcat session.log*.gz | less -R\033[0m\n"); + } + free(session_id); + if (container_name) free(container_name); + + return 0; +} + +// Reconnect to an existing session by ID or container name +static int reconnect_session(const UnsandboxCredentials *creds, const char *session_id_or_container, int save_artifacts, const char *artifact_dir, int audit_history) { + // Disable stdout buffering for real-time output + setvbuf(stdout, NULL, _IONBF, 0); + + char *session_id = NULL; + char *container_name = NULL; + + // Check if it looks like a container name or session ID + // Container names: unsb-vm-*, exec-*, sandbox-* (legacy) + if (strncmp(session_id_or_container, "unsb-vm-", 8) == 0 || + strncmp(session_id_or_container, "exec-", 5) == 0 || + strncmp(session_id_or_container, "sandbox-", 8) == 0) { + // It's a container name - look up the session ID + fprintf(stderr, "Looking up session for %s...", session_id_or_container); + fflush(stderr); + session_id = find_session_by_container(creds, session_id_or_container); + if (!session_id) { + fprintf(stderr, " not found\nError: No active session for container '%s'\n", session_id_or_container); + return 1; + } + container_name = strdup(session_id_or_container); + fprintf(stderr, " found\n"); + } else { + // Assume it's a session ID + session_id = strdup(session_id_or_container); + } + + fprintf(stderr, "Reconnecting to session %s...", session_id); + fflush(stderr); + + // Set up signal handlers + signal(SIGWINCH, handle_sigwinch); + signal(SIGINT, handle_sigint); + + // Enable raw terminal mode + enable_raw_mode(); + atexit(disable_raw_mode); + + // Disable libwebsockets logging + lws_set_log_level(0, NULL); + + // Create WebSocket context + struct lws_context_creation_info info; + memset(&info, 0, sizeof(info)); + info.port = CONTEXT_PORT_NO_LISTEN; + info.protocols = shell_protocols; + info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT; + + struct lws_context *context = lws_create_context(&info); + if (!context) { + fprintf(stderr, " failed\nError: Failed to create WebSocket context\n"); + free(session_id); + if (container_name) free(container_name); + return 1; + } + + // Connect to WebSocket + struct lws_client_connect_info ccinfo; + memset(&ccinfo, 0, sizeof(ccinfo)); + ccinfo.context = context; + ccinfo.address = "api.unsandbox.com"; + ccinfo.port = 443; + + char path[256]; + snprintf(path, sizeof(path), "/sessions/%s/shell", session_id); + ccinfo.path = path; + + ccinfo.host = ccinfo.address; + ccinfo.origin = ccinfo.address; + ccinfo.protocol = shell_protocols[0].name; + ccinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_IP_LOW_LATENCY; + + shell_wsi = lws_client_connect_via_info(&ccinfo); + if (!shell_wsi) { + fprintf(stderr, " failed\nError: Failed to connect (session may have expired)\n"); + lws_context_destroy(context); + free(session_id); + if (container_name) free(container_name); + return 1; + } + + fprintf(stderr, " done\n"); + + // Get user data from wsi + struct shell_state *state = (struct shell_state *)lws_wsi_user(shell_wsi); + state->session_id = session_id; + // Always send initial Enter on reconnect (fixes tmux/screen blank screen) + state->need_initial_enter = 1; + + shell_running = 1; + + // Main event loop + while (shell_running) { + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + + if (poll(&pfd, 1, 1) > 0 && (pfd.revents & POLLIN)) { + char buf[256]; + ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n > 0 && state->connected) { + state->send_buf = malloc(n); + memcpy(state->send_buf, buf, n); + state->send_len = n; + lws_callback_on_writable(shell_wsi); + lws_cancel_service(context); + } + } + + lws_service(context, -1); + } + + // Cleanup + disable_raw_mode(); + lws_context_destroy(context); + + fprintf(stderr, "\r\nSession ended.\r\n"); + + // Note: We don't terminate the session on reconnect disconnect + // The session stays alive for future reconnects + // Only collect artifacts if explicitly requested + if (save_artifacts || audit_history) { + terminate_session(creds, session_id, save_artifacts, artifact_dir, audit_history, container_name); + if (audit_history) { + fprintf(stderr, "\033[33mTip: Replay session with: zcat session.log*.gz | less -R\033[0m\n"); + } + } else { + fprintf(stderr, "\033[33mSession still active. Reconnect with: un session --attach %s\033[0m\n", + container_name ? container_name : session_id); + } + + free(session_id); + if (container_name) free(container_name); + + return 0; +} + +// ============================================================================ +// End Interactive Shell Support +// ============================================================================ + +// ============================================================================ +// Service Management Support +// ============================================================================ + +// Get bootstrap logs for a service +// mode: 0 = tail (last 9000 lines), 1 = all logs +static char* get_service_logs(const UnsandboxCredentials *creds, const char *service_id, int all_logs) { + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + char path[256]; + if (all_logs) { + snprintf(url, sizeof(url), "%s/services/%s/logs?all=true", API_BASE, service_id); + snprintf(path, sizeof(path), "/services/%s/logs?all=true", service_id); + } else { + snprintf(url, sizeof(url), "%s/services/%s/logs", API_BASE, service_id); + snprintf(path, sizeof(path), "/services/%s/logs", service_id); + } + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + // Extract log from response + char *log = extract_json_string(response.data, "log"); + free(response.data); + return log; +} + +// Create a service via HTTP API +// bootstrap_content: if provided, sent as bootstrap_content (file contents) +// bootstrap: if bootstrap_content is NULL and this starts with http, sent as bootstrap URL +// service_type: optional type for SRV-enabled services (minecraft, mumble, teamspeak, etc.) +// input_files: array of files to include (written to /tmp/ in container) +// input_file_count: number of input files +// golden_image: optional LXD image alias to use instead of default (for testing) +static char* create_service(const UnsandboxCredentials *creds, const char *name, const char *ports, const char *domains, const char *bootstrap, const char *bootstrap_content, const char *network_mode, int vcpu, const char *service_type, struct InputFile *input_files, int input_file_count, const char *golden_image) { + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/services", API_BASE); + + // Calculate required payload size (bootstrap_content can be large) + size_t payload_size = 1024; // Base size for JSON structure + if (bootstrap_content) { + payload_size += strlen(bootstrap_content) * 2 + 100; // Escaped content + field name + } else if (bootstrap) { + payload_size += strlen(bootstrap) * 2 + 100; + } + if (domains) { + payload_size += strlen(domains) * 2 + 100; // Escaped domains + JSON overhead + } + // Add space for input files + for (int i = 0; i < input_file_count; i++) { + payload_size += strlen(input_files[i].content_base64) + 256; + } + + // Build payload + char *payload = malloc(payload_size); + if (!payload) { + curl_easy_cleanup(curl); + return NULL; + } + char *p = payload; + p += sprintf(p, "{"); + + if (name && strlen(name) > 0) { + char *esc_name = escape_json_string(name); + p += sprintf(p, "\"name\":\"%s\"", esc_name); + free(esc_name); + } + + if (ports && strlen(ports) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + p += sprintf(p, "\"ports\":["); + // Parse comma-separated ports + char *ports_copy = strdup(ports); + char *port_token = strtok(ports_copy, ","); + int first = 1; + while (port_token) { + if (!first) p += sprintf(p, ","); + p += sprintf(p, "%d", atoi(port_token)); + first = 0; + port_token = strtok(NULL, ","); + } + free(ports_copy); + p += sprintf(p, "]"); + } + + // Add custom_domains as JSON array of strings + if (domains && strlen(domains) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + p += sprintf(p, "\"custom_domains\":["); + // Parse comma-separated domains + char *domains_copy = strdup(domains); + char *domain_token = strtok(domains_copy, ","); + int first = 1; + while (domain_token) { + if (!first) p += sprintf(p, ","); + // Trim whitespace from domain + while (*domain_token == ' ') domain_token++; + char *end = domain_token + strlen(domain_token) - 1; + while (end > domain_token && *end == ' ') *end-- = '\0'; + char *esc_domain = escape_json_string(domain_token); + p += sprintf(p, "\"%s\"", esc_domain); + free(esc_domain); + first = 0; + domain_token = strtok(NULL, ","); + } + free(domains_copy); + p += sprintf(p, "]"); + } + + // Prefer bootstrap_content (file contents), fall back to bootstrap (URL/command) + if (bootstrap_content && strlen(bootstrap_content) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + char *esc_content = escape_json_string(bootstrap_content); + p += sprintf(p, "\"bootstrap_content\":\"%s\"", esc_content); + free(esc_content); + } else if (bootstrap && strlen(bootstrap) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + char *esc_bootstrap = escape_json_string(bootstrap); + p += sprintf(p, "\"bootstrap\":\"%s\"", esc_bootstrap); + free(esc_bootstrap); + } + + if (network_mode && strlen(network_mode) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + p += sprintf(p, "\"network_mode\":\"%s\"", network_mode); + } + + if (vcpu > 1) { + if (p > payload + 1) p += sprintf(p, ","); + p += sprintf(p, "\"vcpu\":%d", vcpu); + } + + // Service type for SRV-enabled services (minecraft, mumble, etc.) + if (service_type && strlen(service_type) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + char *esc_type = escape_json_string(service_type); + p += sprintf(p, "\"service_type\":\"%s\"", esc_type); + free(esc_type); + } + + // Golden image override (for testing with different base images) + if (golden_image && strlen(golden_image) > 0) { + if (p > payload + 1) p += sprintf(p, ","); + char *esc_image = escape_json_string(golden_image); + p += sprintf(p, "\"golden_image\":\"%s\"", esc_image); + free(esc_image); + } + + // Add input files + if (input_file_count > 0) { + if (p > payload + 1) p += sprintf(p, ","); + p += sprintf(p, "\"input_files\":["); + for (int i = 0; i < input_file_count; i++) { + if (i > 0) *p++ = ','; + char *esc_filename = escape_json_string(input_files[i].filename); + p += sprintf(p, "{\"filename\":\"%s\",\"content\":\"%s\"}", + esc_filename, input_files[i].content_base64); + free(esc_filename); + } + p += sprintf(p, "]"); + } + + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", "/services", payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(payload); // Done with payload after curl_easy_perform + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return NULL; + } + + if (http_code != 200 && http_code != 201) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return NULL; + } + + // Extract service ID from response (try both "service_id" and "id" for compatibility) + char *service_id = extract_json_string(response.data, "service_id"); + if (!service_id) { + service_id = extract_json_string(response.data, "id"); + } + free(response.data); + return service_id; +} + +// List all services +static int list_services(const UnsandboxCredentials *creds) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/services", API_BASE); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", "/services", NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Parse and display services + if (!response.data || !strstr(response.data, "\"services\"")) { + fprintf(stderr, "Error: Invalid response\n"); + free(response.data); + return 1; + } + + // Extract count + const char *count_str = strstr(response.data, "\"count\":"); + int count = 0; + if (count_str) { + count = atoi(count_str + 8); + } + + if (count == 0) { + printf("No services found\n"); + free(response.data); + return 0; + } + + printf("Services: %d\n\n", count); + printf("%-40s %-20s %-10s %-8s %-15s\n", "SERVICE ID", "NAME", "STATUS", "DISK", "PORTS"); + printf("%-40s %-20s %-10s %-8s %-15s\n", "----------------------------------------", + "--------------------", "----------", "--------", "---------------"); + + // Parse services array + const char *services_start = strstr(response.data, "\"services\":["); + if (services_start) { + const char *pos = services_start + 12; + + while ((pos = strchr(pos, '{')) != NULL) { + char *service_id = extract_json_string(pos, "id"); + char *name = extract_json_string(pos, "name"); + char *status = extract_json_string(pos, "state"); + + // Extract disk_used (bytes as number) + long long disk_used = extract_json_number(pos, "disk_used"); + char disk_str[16]; + format_bytes(disk_used, disk_str, sizeof(disk_str)); + + // Extract ports array + char ports_str[128] = "-"; + const char *ports_start = strstr(pos, "\"ports\":["); + if (ports_start) { + const char *ports_end = strchr(ports_start + 9, ']'); + if (ports_end) { + size_t len = ports_end - (ports_start + 9); + if (len < sizeof(ports_str) - 1) { + strncpy(ports_str, ports_start + 9, len); + ports_str[len] = '\0'; + } + } + } + + printf("%-40s %-20s %-10s %-8s %-15s\n", + service_id ? service_id : "-", + name ? name : "-", + status ? status : "-", + disk_str, + ports_str); + + if (service_id) free(service_id); + if (name) free(name); + if (status) free(status); + + // Move to next object by skipping past current object's closing } + // Need to properly match braces to handle nested objects like port_mappings + int brace_depth = 1; + pos++; // move past opening { + while (*pos && brace_depth > 0) { + if (*pos == '{') brace_depth++; + else if (*pos == '}') brace_depth--; + else if (*pos == '"') { + // Skip strings (may contain { or }) + pos++; + while (*pos && !(*pos == '"' && *(pos-1) != '\\')) pos++; + } + pos++; + } + // pos now points just past the closing } of current object + const char *next_obj = strchr(pos, '{'); + const char *end_arr = strchr(pos, ']'); + if (!next_obj || (end_arr && end_arr < next_obj)) break; + pos = next_obj; + } + } + + free(response.data); + return 0; +} + +// Get service info +static int get_service_info(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s", service_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Extract and display service details + char *id = extract_json_string(response.data, "id"); + char *name = extract_json_string(response.data, "name"); + char *status = extract_json_string(response.data, "status"); + char *network_mode = extract_json_string(response.data, "network_mode"); + + printf("Service Information:\n"); + printf(" ID: %s\n", id ? id : "-"); + printf(" Name: %s\n", name ? name : "-"); + printf(" Status: %s\n", status ? status : "-"); + printf(" Network Mode: %s\n", network_mode ? network_mode : "-"); + + // Extract ports array + const char *ports_start = strstr(response.data, "\"ports\":["); + if (ports_start) { + printf(" Ports: "); + const char *pos = ports_start + 9; + const char *ports_end = strchr(pos, ']'); + if (ports_end) { + char ports_buf[256]; + size_t len = ports_end - pos; + if (len < sizeof(ports_buf)) { + strncpy(ports_buf, pos, len); + ports_buf[len] = '\0'; + printf("%s\n", ports_buf); + } + } + } + + if (id) free(id); + if (name) free(name); + if (status) free(status); + if (network_mode) free(network_mode); + free(response.data); + return 0; +} + +// Freeze a service +static int freeze_service(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/freeze", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/freeze", service_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mService frozen successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Unfreeze a service +static int unfreeze_service(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/unfreeze", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/unfreeze", service_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mService unfrozen successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Destroy a service +static int destroy_service(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s", service_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mService destroyed successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Lock a service to prevent deletion +static int lock_service(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/lock", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/lock", service_id); + + const char *body = "{}"; + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mService locked successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Unlock a service to allow deletion +static int unlock_service(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/unlock", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/unlock", service_id); + + const char *body = "{}"; + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mService unlocked successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Resize a service (change vCPU/memory live) +static int resize_service(const UnsandboxCredentials *creds, const char *service_id, int vcpu) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s", service_id); + + char body[64]; + snprintf(body, sizeof(body), "{\"vcpu\":%d}", vcpu); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "PATCH", path, body); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code == 429) { + fprintf(stderr, "Error: Cannot resize - would exceed tier concurrency limit\n"); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + if (http_code == 400) { + fprintf(stderr, "Error: Invalid vcpu value (must be 1-8)\n"); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Parse response to show details + printf("\033[32mService resized to %d vCPU, %dGB RAM\033[0m\n", vcpu, vcpu * 2); + if (response.data) { + printf("Details: %s\n", response.data); + } + free(response.data); + return 0; +} + +// ============================================================================ +// Environment Secrets Vault Functions +// ============================================================================ + +// Get environment vault status for a service +// Returns: 0 on success, 1 on error +static int service_env_status(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/env", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/env", service_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Parse response: {"has_vault": true, "updated_at": 123456, "count": 3} + // Check for has_vault field + const char *has_vault_str = strstr(response.data, "\"has_vault\":"); + int has_vault = 0; + if (has_vault_str) { + has_vault_str += 12; // Skip "has_vault": + while (*has_vault_str == ' ') has_vault_str++; + has_vault = (strncmp(has_vault_str, "true", 4) == 0); + } + + if (!has_vault) { + printf("Vault exists: no\n"); + printf("Variable count: 0\n"); + } else { + printf("Vault exists: yes\n"); + + // Extract count + long long count = extract_json_number(response.data, "count"); + if (count >= 0) { + printf("Variable count: %lld\n", count); + } + + // Extract updated_at + long long updated_at = extract_json_number(response.data, "updated_at"); + if (updated_at > 0) { + time_t ts = (time_t)updated_at; + struct tm *tm_info = localtime(&ts); + char time_buf[64]; + strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info); + printf("Last updated: %s\n", time_buf); + } + } + + free(response.data); + return 0; +} + +// Set environment vault for a service (PUT /services/:id/env) +// env_content: .env format string (KEY=VALUE\nKEY2=VALUE2\n...) +// Returns: 0 on success, 1 on error +static int service_env_set(const UnsandboxCredentials *creds, const char *service_id, const char *env_content) { + if (!env_content || strlen(env_content) == 0) { + fprintf(stderr, "Error: No environment content provided\n"); + return 1; + } + + if (strlen(env_content) > MAX_ENV_CONTENT_SIZE) { + fprintf(stderr, "Error: Environment content too large (max %d bytes)\n", MAX_ENV_CONTENT_SIZE); + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/env", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/env", service_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: text/plain"); + headers = add_hmac_auth_headers(headers, creds, "PUT", path, env_content); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, env_content); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Extract count from response + long long count = extract_json_number(response.data, "count"); + if (count >= 0) { + printf("\033[32mEnvironment vault updated: %lld variable%s\033[0m\n", + count, count == 1 ? "" : "s"); + } else { + printf("\033[32mEnvironment vault updated\033[0m\n"); + } + + // Print note about taking effect + char *message = extract_json_string(response.data, "message"); + if (message) { + printf("%s\n", message); + free(message); + } + + free(response.data); + return 0; +} + +// Export environment vault for a service (POST /services/:id/env/export) +// HMAC auth proves ownership - returns .env format string +// Returns: 0 on success, 1 on error +static int service_env_export(const UnsandboxCredentials *creds, const char *service_id) { + // HMAC auth proves ownership - no additional confirmation needed + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/env/export", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/env/export", service_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, ""); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found or no vault exists\n"); + free(response.data); + return 1; + } + + if (http_code == 401 || http_code == 403) { + fprintf(stderr, "Error: Not authorized\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Extract env content from response + char *env_content = extract_json_string(response.data, "env"); + if (env_content) { + printf("%s", env_content); + // Ensure trailing newline + if (strlen(env_content) > 0 && env_content[strlen(env_content) - 1] != '\n') { + printf("\n"); + } + free(env_content); + } + + free(response.data); + return 0; +} + +// Delete environment vault for a service (DELETE /services/:id/env) +// Returns: 0 on success, 1 on error +static int service_env_delete(const UnsandboxCredentials *creds, const char *service_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/env", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/env", service_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found or no vault exists\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mEnvironment vault deleted\033[0m\n"); + + // Print note about taking effect + char *message = extract_json_string(response.data, "message"); + if (message) { + printf("%s\n", message); + free(message); + } + + free(response.data); + return 0; +} + +// Read .env file contents +// Returns: allocated string with file contents, or NULL on error +static char* read_env_file(const char *filename) { + FILE *f = fopen(filename, "r"); + if (!f) { + fprintf(stderr, "Error: Cannot open env file '%s'\n", filename); + return NULL; + } + + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + fseek(f, 0, SEEK_SET); + + if (fsize > MAX_ENV_CONTENT_SIZE) { + fprintf(stderr, "Error: Env file too large (max %d bytes)\n", MAX_ENV_CONTENT_SIZE); + fclose(f); + return NULL; + } + + char *content = malloc(fsize + 1); + if (!content) { + fprintf(stderr, "Error: Out of memory\n"); + fclose(f); + return NULL; + } + + size_t read_size = fread(content, 1, fsize, f); + content[read_size] = '\0'; + fclose(f); + + return content; +} + +// Read env content from stdin until EOF +// Returns: allocated string with content, or NULL on error +static char* read_env_stdin(void) { + size_t capacity = 4096; + size_t size = 0; + char *content = malloc(capacity); + if (!content) return NULL; + + char buf[1024]; + while (fgets(buf, sizeof(buf), stdin)) { + size_t len = strlen(buf); + if (size + len + 1 > capacity) { + capacity *= 2; + if (capacity > MAX_ENV_CONTENT_SIZE) { + fprintf(stderr, "Error: Input too large (max %d bytes)\n", MAX_ENV_CONTENT_SIZE); + free(content); + return NULL; + } + char *new_content = realloc(content, capacity); + if (!new_content) { + free(content); + return NULL; + } + content = new_content; + } + memcpy(content + size, buf, len); + size += len; + } + content[size] = '\0'; + return content; +} + +// ============================================================================ +// End Environment Secrets Vault Functions +// ============================================================================ + +// Redeploy a service (re-run bootstrap script) +// Bootstrap scripts should be idempotent for proper upgrade behavior +static int redeploy_service(const UnsandboxCredentials *creds, const char *service_id, const char *bootstrap) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/redeploy", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/redeploy", service_id); + + // Check if bootstrap is a file or URL + char *bootstrap_content = NULL; + const char *bootstrap_url = NULL; + + if (bootstrap && strlen(bootstrap) > 0) { + if (strncmp(bootstrap, "http://", 7) == 0 || + strncmp(bootstrap, "https://", 8) == 0) { + bootstrap_url = bootstrap; + } else { + // Try to read as file + struct stat st; + if (stat(bootstrap, &st) == 0 && S_ISREG(st.st_mode)) { + size_t fsize; + bootstrap_content = read_file(bootstrap, &fsize); + if (!bootstrap_content) { + fprintf(stderr, "Error reading bootstrap file: %s\n", bootstrap); + curl_easy_cleanup(curl); + free(response.data); + return 1; + } + printf("Read bootstrap script (%zu bytes) from %s\n", fsize, bootstrap); + } else { + // Treat as inline command + bootstrap_url = bootstrap; + } + } + } + + // Calculate required payload size + size_t payload_size = 256; // Base size + if (bootstrap_content) { + payload_size += strlen(bootstrap_content) * 2 + 100; + } else if (bootstrap_url) { + payload_size += strlen(bootstrap_url) * 2 + 100; + } + + // Build JSON payload manually (matching create_service pattern) + char *payload = malloc(payload_size); + if (!payload) { + if (bootstrap_content) free(bootstrap_content); + curl_easy_cleanup(curl); + free(response.data); + return 1; + } + char *p = payload; + p += sprintf(p, "{"); + + if (bootstrap_content) { + char *esc_content = escape_json_string(bootstrap_content); + p += sprintf(p, "\"bootstrap_content\":\"%s\"", esc_content); + free(esc_content); + free(bootstrap_content); + } else if (bootstrap_url) { + char *esc_url = escape_json_string(bootstrap_url); + p += sprintf(p, "\"bootstrap\":\"%s\"", esc_url); + free(esc_url); + } + + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + printf("Redeploying service '%s'...\n", service_id); + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + free(payload); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code == 400) { + fprintf(stderr, "Error: No bootstrap script provided. Use --bootstrap option.\n"); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mRedeploy initiated successfully\033[0m\n"); + printf("Note: Bootstrap scripts should be idempotent for proper upgrade behavior.\n"); + printf("Use 'un service --logs %s' to check progress.\n", service_id); + free(response.data); + return 0; +} + +// Execute a command in a running service container +// Uses async job polling for long-running commands +static int execute_service(const UnsandboxCredentials *creds, const char *service_id, const char *command, int timeout_ms) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/execute", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/execute", service_id); + + // Build JSON payload + char *esc_command = escape_json_string(command); + if (!esc_command) { + curl_easy_cleanup(curl); + free(response.data); + return 1; + } + + char payload[8192]; + snprintf(payload, sizeof(payload), "{\"command\":\"%s\",\"timeout\":%d}", esc_command, timeout_ms); + free(esc_command); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code == 409) { + fprintf(stderr, "Error: Service is not running. Unfreeze it first with --unfreeze\n"); + free(response.data); + return 1; + } + + if (http_code != 202) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Extract job_id from response + char *job_id = extract_json_string(response.data, "job_id"); + free(response.data); + + if (!job_id) { + fprintf(stderr, "Error: No job_id in response\n"); + return 1; + } + + // Poll for job completion + char job_url[512]; + snprintf(job_url, sizeof(job_url), "%s/jobs/%s", API_BASE, job_id); + + char job_path[256]; + snprintf(job_path, sizeof(job_path), "/jobs/%s", job_id); + + int poll_count = 0; + int max_polls = (timeout_ms / 1000) + 10; // timeout + 10 extra seconds + + while (poll_count < max_polls) { + usleep(500000); // 500ms between polls + poll_count++; + + curl = curl_easy_init(); + if (!curl) { + free(job_id); + return 1; + } + + struct ResponseBuffer job_response = {0}; + job_response.data = malloc(1); + job_response.size = 0; + + headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", job_path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, job_url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &job_response); + + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || http_code != 200) { + free(job_response.data); + continue; + } + + // Check job status + char *status = extract_json_string(job_response.data, "status"); + if (status && strcmp(status, "completed") == 0) { + // Job completed - print result using same format as code execution + parse_and_print_response(job_response.data, 0, NULL, NULL); + free(status); + free(job_response.data); + free(job_id); + return 0; + } + + if (status && (strcmp(status, "failed") == 0 || strcmp(status, "cancelled") == 0)) { + char *error = extract_json_string(job_response.data, "error"); + fprintf(stderr, "Error: Job %s: %s\n", status, error ? error : "unknown"); + if (error) free(error); + free(status); + free(job_response.data); + free(job_id); + return 1; + } + + if (status) free(status); + free(job_response.data); + } + + fprintf(stderr, "Error: Command timed out after %d seconds\n", timeout_ms / 1000); + free(job_id); return 1; } -const char *unsandbox_version(void) { - return "1.0.0"; +// Execute a command in a service and capture output (returns malloc'd string or NULL) +static char* execute_service_capture(const UnsandboxCredentials *creds, const char *service_id, const char *command, int timeout_ms) { + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char path[512]; + char url[512]; + snprintf(path, sizeof(path), "/services/%s/execute", service_id); + snprintf(url, sizeof(url), "%s%s", API_BASE, path); + + char *esc_command = escape_json_string(command); + if (!esc_command) { + curl_easy_cleanup(curl); + free(response.data); + return NULL; + } + + char payload[8192]; + snprintf(payload, sizeof(payload), "{\"command\":\"%s\",\"timeout\":%d}", esc_command, timeout_ms); + free(esc_command); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || http_code != 202) { + if (http_code == 409) { + fprintf(stderr, "\033[34mError: Instance is not running\n\033[0m"); + } + free(response.data); + return NULL; + } + + char *job_id = extract_json_string(response.data, "job_id"); + free(response.data); + + if (!job_id) return NULL; + + char job_path[512]; + char job_url[512]; + snprintf(job_path, sizeof(job_path), "/jobs/%s", job_id); + snprintf(job_url, sizeof(job_url), "%s%s", API_BASE, job_path); + + int poll_count = 0; + int max_polls = (timeout_ms / 1000) + 10; + + while (poll_count < max_polls) { + usleep(500000); + poll_count++; + + curl = curl_easy_init(); + if (!curl) { + free(job_id); + return NULL; + } + + struct ResponseBuffer job_response = {0}; + job_response.data = malloc(1); + job_response.size = 0; + + // Regenerate auth headers each poll to keep timestamp fresh + headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", job_path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, job_url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &job_response); + + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || http_code != 200) { + free(job_response.data); + continue; + } + + char *status = extract_json_string(job_response.data, "status"); + if (status && strcmp(status, "completed") == 0) { + // Extract stdout from result + char *output = extract_json_string(job_response.data, "stdout"); + free(status); + free(job_response.data); + free(job_id); + return output; // Caller must free + } + + if (status && (strcmp(status, "failed") == 0 || strcmp(status, "cancelled") == 0)) { + free(status); + free(job_response.data); + free(job_id); + return NULL; + } + + if (status) free(status); + free(job_response.data); + } + + free(job_id); + return NULL; +} + +// ============================================================================ +// Snapshot Management Support +// ============================================================================ + +// List all snapshots +static int list_snapshots(const UnsandboxCredentials *creds) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/snapshots", API_BASE); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", "/snapshots", NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 403) { + fprintf(stderr, "Error: Snapshots not available for free tier\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Parse and display snapshots + if (!response.data || !strstr(response.data, "\"snapshots\"")) { + fprintf(stderr, "Error: Invalid response\n"); + free(response.data); + return 1; + } + + // Extract count + const char *count_str = strstr(response.data, "\"count\":"); + int count = 0; + if (count_str) { + count = atoi(count_str + 8); + } + + if (count == 0) { + printf("No snapshots found\n"); + free(response.data); + return 0; + } + + printf("Snapshots: %d\n\n", count); + printf("%-40s %-20s %-12s %-30s %-8s\n", "SNAPSHOT ID", "NAME", "SOURCE TYPE", "SOURCE ID", "SIZE"); + printf("%-40s %-20s %-12s %-30s %-8s\n", "----------------------------------------", + "--------------------", "------------", "------------------------------", "--------"); + + // Parse snapshots array + const char *snapshots_start = strstr(response.data, "\"snapshots\":["); + if (snapshots_start) { + const char *pos = snapshots_start + 13; + + while ((pos = strchr(pos, '{')) != NULL) { + char *snapshot_id = extract_json_string(pos, "id"); + char *name = extract_json_string(pos, "name"); + char *source_type = extract_json_string(pos, "source_type"); + char *source_id = extract_json_string(pos, "source_id"); + long long size_bytes = extract_json_number(pos, "size_bytes"); + + char size_str[16]; + format_bytes(size_bytes, size_str, sizeof(size_str)); + + printf("%-40s %-20s %-12s %-30s %-8s\n", + snapshot_id ? snapshot_id : "-", + name ? name : "-", + source_type ? source_type : "-", + source_id ? source_id : "-", + size_str); + + if (snapshot_id) free(snapshot_id); + if (name) free(name); + if (source_type) free(source_type); + if (source_id) free(source_id); + + // Move to next object + int brace_depth = 1; + pos++; + while (*pos && brace_depth > 0) { + if (*pos == '{') brace_depth++; + else if (*pos == '}') brace_depth--; + else if (*pos == '"') { + pos++; + while (*pos && !(*pos == '"' && *(pos-1) != '\\')) pos++; + } + pos++; + } + const char *next_obj = strchr(pos, '{'); + const char *end_arr = strchr(pos, ']'); + if (!next_obj || (end_arr && end_arr < next_obj)) break; + pos = next_obj; + } + } + + free(response.data); + return 0; +} + +// Get snapshot info +static int get_snapshot_info(const UnsandboxCredentials *creds, const char *snapshot_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/snapshots/%s", API_BASE, snapshot_id); + + char path[256]; + snprintf(path, sizeof(path), "/snapshots/%s", snapshot_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Snapshot not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + // Parse and display snapshot info + char *id = extract_json_string(response.data, "id"); + char *name = extract_json_string(response.data, "name"); + char *source_type = extract_json_string(response.data, "source_type"); + char *source_id = extract_json_string(response.data, "source_id"); + char *container_name = extract_json_string(response.data, "container_name"); + char *status = extract_json_string(response.data, "status"); + long long size_bytes = extract_json_number(response.data, "size_bytes"); + long long created_at = extract_json_number(response.data, "created_at"); + + char size_str[16]; + format_bytes(size_bytes, size_str, sizeof(size_str)); + + printf("\033[1mSnapshot Details\033[0m\n\n"); + printf("%-20s %s\n", "Snapshot ID:", id ? id : "-"); + printf("%-20s %s\n", "Name:", name ? name : "-"); + printf("%-20s %s\n", "Source Type:", source_type ? source_type : "-"); + printf("%-20s %s\n", "Source ID:", source_id ? source_id : "-"); + printf("%-20s %s\n", "Container:", container_name ? container_name : "-"); + printf("%-20s %s\n", "Size:", size_str); + printf("%-20s %s\n", "Status:", status ? status : "-"); + + if (created_at > 0) { + time_t t = (time_t)created_at; + struct tm *tm_info = localtime(&t); + char time_buf[64]; + strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info); + printf("%-20s %s\n", "Created:", time_buf); + } + + if (id) free(id); + if (name) free(name); + if (source_type) free(source_type); + if (source_id) free(source_id); + if (container_name) free(container_name); + if (status) free(status); + + free(response.data); + return 0; +} + +// Delete a snapshot +static int delete_snapshot(const UnsandboxCredentials *creds, const char *snapshot_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/snapshots/%s", API_BASE, snapshot_id); + + char path[256]; + snprintf(path, sizeof(path), "/snapshots/%s", snapshot_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Snapshot not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mSnapshot deleted successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Lock a snapshot to prevent deletion +static int lock_snapshot(const UnsandboxCredentials *creds, const char *snapshot_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/snapshots/%s/lock", API_BASE, snapshot_id); + + char path[256]; + snprintf(path, sizeof(path), "/snapshots/%s/lock", snapshot_id); + + const char *body = "{}"; + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Snapshot not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mSnapshot locked successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Unlock a snapshot to allow deletion +static int unlock_snapshot(const UnsandboxCredentials *creds, const char *snapshot_id) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/snapshots/%s/unlock", API_BASE, snapshot_id); + + char path[256]; + snprintf(path, sizeof(path), "/snapshots/%s/unlock", snapshot_id); + + const char *body = "{}"; + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, "Error: Snapshot not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + printf("\033[32mSnapshot unlocked successfully\033[0m\n"); + free(response.data); + return 0; +} + +// Create snapshot of a session +static int create_session_snapshot(const UnsandboxCredentials *creds, const char *session_id, const char *name, int hot) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/sessions/%s/snapshot", API_BASE, session_id); + + char path[256]; + snprintf(path, sizeof(path), "/sessions/%s/snapshot", session_id); + + char payload[1024]; + if (name && strlen(name) > 0) { + char *esc_name = escape_json_string(name); + snprintf(payload, sizeof(payload), "{\"name\":\"%s\",\"hot\":%s}", esc_name, hot ? "true" : "false"); + free(esc_name); + } else { + snprintf(payload, sizeof(payload), "{\"hot\":%s}", hot ? "true" : "false"); + } + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + fprintf(stderr, "Creating snapshot of session %s...", session_id); + fflush(stderr); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 403) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Snapshots not available for free tier\n"); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Session not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200 && http_code != 201) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + fprintf(stderr, " done\n"); + + char *snapshot_id = extract_json_string(response.data, "id"); + if (snapshot_id) { + printf("\033[32mSnapshot created successfully\033[0m\n"); + printf("Snapshot ID: %s\n", snapshot_id); + free(snapshot_id); + } + + free(response.data); + return 0; +} + +// Create snapshot of a service +static int create_service_snapshot(const UnsandboxCredentials *creds, const char *service_id, const char *name, int hot) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/services/%s/snapshot", API_BASE, service_id); + + char path[256]; + snprintf(path, sizeof(path), "/services/%s/snapshot", service_id); + + char payload[1024]; + if (name && strlen(name) > 0) { + char *esc_name = escape_json_string(name); + snprintf(payload, sizeof(payload), "{\"name\":\"%s\",\"hot\":%s}", esc_name, hot ? "true" : "false"); + free(esc_name); + } else { + snprintf(payload, sizeof(payload), "{\"hot\":%s}", hot ? "true" : "false"); + } + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + fprintf(stderr, "Creating snapshot of service %s...", service_id); + fflush(stderr); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 403) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Snapshots not available for free tier\n"); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Service not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200 && http_code != 201) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + fprintf(stderr, " done\n"); + + char *snapshot_id = extract_json_string(response.data, "id"); + if (snapshot_id) { + printf("\033[32mSnapshot created successfully\033[0m\n"); + printf("Snapshot ID: %s\n", snapshot_id); + free(snapshot_id); + } + + free(response.data); + return 0; +} + +// Restore session from snapshot +static int restore_from_snapshot(const UnsandboxCredentials *creds, const char *snapshot_id, const char *type) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/snapshots/%s/restore", API_BASE, snapshot_id); + + char path[256]; + snprintf(path, sizeof(path), "/snapshots/%s/restore", snapshot_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, ""); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, ""); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + fprintf(stderr, "Restoring %s from snapshot %s...", type, snapshot_id); + fflush(stderr); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Snapshot not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + fprintf(stderr, " done\n"); + printf("\033[32m%s restored from snapshot\033[0m\n", type); + + free(response.data); + return 0; +} + +// Clone from snapshot to create new session or service +static int clone_snapshot(const UnsandboxCredentials *creds, const char *snapshot_id, const char *clone_type, + const char *name, const char *shell, const char *ports) { + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[512]; + snprintf(url, sizeof(url), "%s/snapshots/%s/clone", API_BASE, snapshot_id); + + char path[256]; + snprintf(path, sizeof(path), "/snapshots/%s/clone", snapshot_id); + + // Build payload + char payload[2048]; + char *p = payload; + p += sprintf(p, "{\"type\":\"%s\"", clone_type); + + if (name && strlen(name) > 0) { + char *esc = escape_json_string(name); + p += sprintf(p, ",\"name\":\"%s\"", esc); + free(esc); + } + + if (shell && strlen(shell) > 0) { + char *esc = escape_json_string(shell); + p += sprintf(p, ",\"shell\":\"%s\"", esc); + free(esc); + } + + if (ports && strlen(ports) > 0) { + // Parse comma-separated ports into array + p += sprintf(p, ",\"ports\":["); + char *ports_copy = strdup(ports); + char *tok = strtok(ports_copy, ","); + int first = 1; + while (tok) { + if (!first) p += sprintf(p, ","); + p += sprintf(p, "%d", atoi(tok)); + first = 0; + tok = strtok(NULL, ","); + } + free(ports_copy); + p += sprintf(p, "]"); + } + + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + fprintf(stderr, "Cloning snapshot %s to create new %s...", snapshot_id, clone_type); + fflush(stderr); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code == 403) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Snapshots not available for free tier\n"); + free(response.data); + return 1; + } + + if (http_code == 404) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: Snapshot not found\n"); + free(response.data); + return 1; + } + + if (http_code != 200 && http_code != 201) { + fprintf(stderr, " failed\n"); + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) fprintf(stderr, "%s\n", response.data); + free(response.data); + return 1; + } + + fprintf(stderr, " done\n"); + + if (strcmp(clone_type, "session") == 0) { + char *session_id = extract_json_string(response.data, "session_id"); + if (session_id) { + printf("\033[32mSession created from snapshot\033[0m\n"); + printf("Session ID: %s\n", session_id); + free(session_id); + } + } else { + char *service_id = extract_json_string(response.data, "service_id"); + if (service_id) { + printf("\033[32mService created from snapshot\033[0m\n"); + printf("Service ID: %s\n", service_id); + free(service_id); + } + } + + free(response.data); + return 0; +} + +// ============================================================================ +// End Snapshot Management Support +// ============================================================================ + +// ============================================================================ +// End Service Management Support +// ============================================================================ + +// ============================================================================ +// Key Validation Support +// ============================================================================ + +static int validate_api_key(const UnsandboxCredentials *creds) { + if (!creds || !creds->public_key || !creds->secret_key) { + fprintf(stderr, "Error: Both public and secret keys required for validation\n"); + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/keys/validate", PORTAL_BASE); + + // Use HMAC authentication with empty body + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", "/keys/validate", ""); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, ""); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); + free(response.data); + return 1; + } + + if (http_code != 200) { + // Parse error response + if (response.data) { + char *error = extract_json_string(response.data, "error"); + char *reason = extract_json_string(response.data, "reason"); + if (error) { + printf("\033[31mInvalid\033[0m: %s\n", error); + free(error); + } else if (reason) { + if (strcmp(reason, "invalid_key") == 0) { + printf("\033[31mInvalid\033[0m: key not found\n"); + } else if (strcmp(reason, "expired") == 0) { + printf("\033[31mExpired\033[0m\n\n"); + + // Show key details if available + char *public_key = extract_json_string(response.data, "public_key"); + long long tier = extract_json_number(response.data, "tier"); + char *expired_at = extract_json_string(response.data, "expired_at_datetime"); + char *expired_ago = extract_json_string(response.data, "expired_ago"); + char *renew_url = extract_json_string(response.data, "renew_url"); + + if (public_key) { + printf("%-20s %s\n", "Public Key:", public_key); + free(public_key); + } + if (tier >= 0) { + printf("%-20s %lld\n", "Tier:", tier); + } + if (expired_at) { + printf("%-20s %s", "Expired:", expired_at); + if (expired_ago) { + printf(" (%s)", expired_ago); + } + printf("\n"); + free(expired_at); + } + if (expired_ago) free(expired_ago); + + printf("\n\033[33mTo renew:\033[0m Visit %s\n", + renew_url ? renew_url : "https://unsandbox.com/pricing"); + if (renew_url) free(renew_url); + } else if (strcmp(reason, "suspended") == 0) { + printf("\033[31mSuspended\033[0m: key has been suspended\n"); + } else { + printf("\033[31mInvalid\033[0m: %s\n", reason); + } + free(reason); + } else { + printf("\033[31mInvalid\033[0m: HTTP %ld\n", http_code); + } + } + free(response.data); + return 1; + } + + // Check for valid:false in 200 response + const char *valid_check = strstr(response.data, "\"valid\":false"); + if (valid_check) { + char *reason = extract_json_string(response.data, "reason"); + if (reason) { + if (strcmp(reason, "invalid_key") == 0) { + printf("\033[31mInvalid\033[0m: key not found\n"); + } else if (strcmp(reason, "expired") == 0) { + printf("\033[31mExpired\033[0m\n\n"); + + // Show key details if available + char *public_key = extract_json_string(response.data, "public_key"); + long long tier = extract_json_number(response.data, "tier"); + char *expired_at = extract_json_string(response.data, "expired_at_datetime"); + char *expired_ago = extract_json_string(response.data, "expired_ago"); + char *renew_url = extract_json_string(response.data, "renew_url"); + + if (public_key) { + printf("%-20s %s\n", "Public Key:", public_key); + free(public_key); + } + if (tier >= 0) { + printf("%-20s %lld\n", "Tier:", tier); + } + if (expired_at) { + printf("%-20s %s", "Expired:", expired_at); + if (expired_ago) { + printf(" (%s)", expired_ago); + } + printf("\n"); + free(expired_at); + } + if (expired_ago) free(expired_ago); + + printf("\n\033[33mTo renew:\033[0m Visit %s\n", + renew_url ? renew_url : "https://unsandbox.com/pricing"); + if (renew_url) free(renew_url); + } else if (strcmp(reason, "suspended") == 0) { + printf("\033[31mSuspended\033[0m: key has been suspended\n"); + } else { + printf("\033[31mInvalid\033[0m: %s\n", reason); + } + free(reason); + } else { + printf("\033[31mInvalid key\033[0m\n"); + } + free(response.data); + return 1; + } + + // Parse valid response + if (!response.data) { + fprintf(stderr, "Error: Empty response\n"); + return 1; + } + + // Check if valid + const char *valid_str = strstr(response.data, "\"valid\":"); + int valid = 0; + if (valid_str) { + valid = (strstr(valid_str, "true") == valid_str + 8); + } + + if (!valid) { + printf("\033[31mInvalid key\033[0m\n"); + free(response.data); + return 1; + } + + // Extract fields + long long tier = extract_json_number(response.data, "tier"); + char *status = extract_json_string(response.data, "status"); + char *valid_through = extract_json_string(response.data, "valid_through_datetime"); + char *valid_for = extract_json_string(response.data, "valid_for_human"); + char *public_key = extract_json_string(response.data, "public_key"); + long long rate_per_minute = extract_json_number(response.data, "rate_per_minute"); + long long burst = extract_json_number(response.data, "burst"); + long long concurrency = extract_json_number(response.data, "concurrency"); + + // Display key info + printf("\033[32mValid\033[0m\n\n"); + printf("%-20s %s\n", "Public Key:", public_key ? public_key : "N/A"); + printf("%-20s %lld\n", "Tier:", tier); + printf("%-20s %s\n", "Status:", status ? status : "N/A"); + printf("%-20s %s\n", "Expires:", valid_through ? valid_through : "N/A"); + printf("%-20s %s\n", "Time Remaining:", valid_for ? valid_for : "N/A"); + printf("%-20s %lld/min\n", "Rate Limit:", rate_per_minute); + printf("%-20s %lld\n", "Burst:", burst); + printf("%-20s %lld\n", "Concurrency:", concurrency); + + if (status) free(status); + if (valid_through) free(valid_through); + if (valid_for) free(valid_for); + if (public_key) free(public_key); + free(response.data); + + return 0; +} + +// ============================================================================ +// End Key Validation Support +// ============================================================================ + +void print_usage(const char *prog) { + fprintf(stderr, "Usage: %s [options] \n", prog); + fprintf(stderr, " %s session [options]\n", prog); + fprintf(stderr, " %s service [options]\n", prog); + fprintf(stderr, " %s snapshot [options]\n", prog); + fprintf(stderr, " %s key\n\n", prog); + fprintf(stderr, "Commands:\n"); + fprintf(stderr, " (default) Execute source file in sandbox\n"); + fprintf(stderr, " session Open interactive shell/REPL session\n"); + fprintf(stderr, " service Manage persistent services\n"); + fprintf(stderr, " snapshot Manage container snapshots\n"); + fprintf(stderr, " key Check API key validity and expiration\n"); + fprintf(stderr, "\nOptions:\n"); + fprintf(stderr, " -s, --shell LANG Specify language (default: bash if arg is not a file)\n"); + fprintf(stderr, " -e KEY=VALUE Set environment variable (can use multiple times)\n"); + fprintf(stderr, " -f FILE Add input file to /tmp/ (can use multiple times)\n"); + fprintf(stderr, " -F FILE Add input file with path preserved (can use multiple times)\n"); + fprintf(stderr, " -a Return and save artifacts (compiled binaries)\n"); + fprintf(stderr, " -o DIR Output directory for artifacts (default: current dir)\n"); + fprintf(stderr, " -p KEY Public key (or set UNSANDBOX_PUBLIC_KEY env var)\n"); + fprintf(stderr, " -k KEY Secret key (or set UNSANDBOX_SECRET_KEY env var)\n"); + fprintf(stderr, " -n MODE Network mode: zerotrust (default) or semitrusted\n"); + fprintf(stderr, " -v N, --vcpu N vCPU count 1-8, each vCPU gets 2GB RAM. Default: 1\n"); + fprintf(stderr, " -y Skip confirmation for large uploads (>1GB)\n"); + fprintf(stderr, " -h Show this help\n"); + fprintf(stderr, "\nSession options:\n"); + fprintf(stderr, " -s, --shell SHELL Shell/REPL to use (default: bash)\n"); + fprintf(stderr, " -l, --list List active sessions\n"); + fprintf(stderr, " --attach ID Reconnect to existing session (ID or container name)\n"); + fprintf(stderr, " --kill ID Terminate a session (ID or container name)\n"); + fprintf(stderr, " --audit Record session for auditing\n"); + fprintf(stderr, " --tmux Enable session persistence with tmux (allows reconnect)\n"); + fprintf(stderr, " --screen Enable session persistence with screen (allows reconnect)\n"); + fprintf(stderr, " --snapshot ID Create snapshot of session (paid tiers only)\n"); + fprintf(stderr, " --restore SNAPSHOT Restore session from snapshot\n"); + fprintf(stderr, " --snapshot-name N Name for the snapshot\n"); + fprintf(stderr, " --hot Take snapshot without freezing (live snapshot)\n"); + fprintf(stderr, "\nService options:\n"); + fprintf(stderr, " --name NAME Service name (creates new service)\n"); + fprintf(stderr, " --ports PORTS Comma-separated ports (e.g., 80,443)\n"); + fprintf(stderr, " --domains DOMAINS Comma-separated custom domains (e.g., example.com,www.example.com)\n"); + fprintf(stderr, " --type TYPE Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)\n"); + fprintf(stderr, " --golden-image IMG Use custom LXD image alias (for testing, e.g., jammy-golden-22.04)\n"); + fprintf(stderr, " --bootstrap CMD Bootstrap command or URI to run on startup\n"); + fprintf(stderr, " --bootstrap-file FILE Upload local file as bootstrap script content\n"); + fprintf(stderr, " -e, --env KEY=VAL Set environment variable (can repeat, stored encrypted)\n"); + fprintf(stderr, " --env-file FILE Load env vars from .env file (stored encrypted)\n"); + fprintf(stderr, " -f FILE Upload file to /tmp/ (can use multiple times)\n"); + fprintf(stderr, " -F FILE Upload file with path preserved (can use multiple times)\n"); + fprintf(stderr, " -l, --list List all services\n"); + fprintf(stderr, " --info ID Get service details\n"); + fprintf(stderr, " --tail ID Get last 9000 lines of bootstrap logs\n"); + fprintf(stderr, " --logs ID Get all bootstrap logs\n"); + fprintf(stderr, " --download-logs ID FILE Download all logs to file\n"); + fprintf(stderr, " --freeze ID Freeze a service\n"); + fprintf(stderr, " --unfreeze ID Unfreeze a service\n"); + fprintf(stderr, " --destroy ID Destroy a service\n"); + fprintf(stderr, " --lock ID Lock a service to prevent deletion\n"); + fprintf(stderr, " --unlock ID Unlock a service to allow deletion\n"); + fprintf(stderr, " --resize ID Resize service vCPU/memory (requires --vcpu)\n"); + fprintf(stderr, " --redeploy ID Re-run bootstrap script (requires --bootstrap)\n"); + fprintf(stderr, " --execute ID CMD Run a command in a running service\n"); + fprintf(stderr, " --dump-bootstrap ID [FILE] Dump bootstrap script (for migrations)\n"); + fprintf(stderr, " --snapshot ID Create snapshot of service (paid tiers only)\n"); + fprintf(stderr, " --restore SNAPSHOT Restore service from snapshot\n"); + fprintf(stderr, " --snapshot-name N Name for the snapshot\n"); + fprintf(stderr, " --hot Take snapshot without freezing (live snapshot)\n"); + fprintf(stderr, "\nService environment vault:\n"); + fprintf(stderr, " env status ID Show vault status (exists, count, updated)\n"); + fprintf(stderr, " env set ID Set vault from --env-file FILE or stdin\n"); + fprintf(stderr, " env export ID Export vault contents to stdout\n"); + fprintf(stderr, " env delete ID Delete vault\n"); + fprintf(stderr, "\nSnapshot options:\n"); + fprintf(stderr, " -l, --list List all snapshots\n"); + fprintf(stderr, " --info ID Get snapshot details\n"); + fprintf(stderr, " --delete ID Delete a snapshot\n"); + fprintf(stderr, " --lock ID Lock a snapshot to prevent deletion\n"); + fprintf(stderr, " --unlock ID Unlock a snapshot to allow deletion\n"); + fprintf(stderr, " --clone ID Clone snapshot to new session/service\n"); + fprintf(stderr, " --type TYPE Clone type: session or service (for --clone)\n"); + fprintf(stderr, " --name NAME Name for cloned service (for --clone)\n"); + fprintf(stderr, " --shell SHELL Shell for cloned session (for --clone)\n"); + fprintf(stderr, " --ports PORTS Ports for cloned service (for --clone)\n"); + fprintf(stderr, "\nAvailable shells/REPLs:\n"); + fprintf(stderr, " Shells: bash, dash, sh, zsh, fish, ksh, tcsh, csh, elvish, xonsh, ash\n"); + fprintf(stderr, " REPLs: python3, bpython, ipython, node, ruby, irb, lua, php, perl\n"); + fprintf(stderr, " guile, ghci, erl, iex, sbcl, clisp, r, julia, clojure\n"); + fprintf(stderr, "\nSession behavior:\n"); + fprintf(stderr, " Default: Session terminates immediately on disconnect (clean exit)\n"); + fprintf(stderr, " --tmux: Session persists on disconnect, reconnect with --attach\n"); + fprintf(stderr, " --screen: Session persists on disconnect, reconnect with --attach\n"); + fprintf(stderr, "\nExamples:\n"); + fprintf(stderr, " %s script.py # execute Python script\n", prog); + fprintf(stderr, " %s -s bash 'echo hello' # execute inline command\n", prog); + fprintf(stderr, " %s -e DEBUG=1 script.py # with environment variable\n", prog); + fprintf(stderr, " %s -f data.csv process.py # with input file\n", prog); + fprintf(stderr, " %s -a -o ./bin main.c # save compiled artifacts\n", prog); + fprintf(stderr, " %s session # interactive bash (terminates on disconnect)\n", prog); + fprintf(stderr, " %s session --tmux # bash with tmux (can reconnect)\n", prog); + fprintf(stderr, " %s session --screen # bash with screen (can reconnect)\n", prog); + fprintf(stderr, " %s session --list # list active sessions\n", prog); + fprintf(stderr, " %s session --kill sandbox-abc # terminate a session\n", prog); + fprintf(stderr, " %s session --freeze sandbox-abc # freeze session (requires --tmux/--screen)\n", prog); + fprintf(stderr, " %s session --unfreeze sandbox-abc # unfreeze a frozen session\n", prog); + fprintf(stderr, " %s session --boost sandbox-abc # boost to 2 vCPU, 4GB RAM\n", prog); + fprintf(stderr, " %s session --boost sandbox-abc --boost-vcpu 4 # 4 vCPU, 8GB RAM\n", prog); + fprintf(stderr, " %s session --unboost sandbox-abc # return to base resources\n", prog); + fprintf(stderr, " %s session --attach sandbox-abc # reconnect by container name\n", prog); + fprintf(stderr, " %s session --shell python3 # Python REPL\n", prog); + fprintf(stderr, " %s session --shell node --tmux # Node.js REPL with reconnect\n", prog); + fprintf(stderr, " %s session -n semitrusted # session with network access\n", prog); + fprintf(stderr, " %s session --audit -o ./logs # record session for auditing\n", prog); + fprintf(stderr, " %s session -f data.csv # session with input file in /tmp/\n", prog); + fprintf(stderr, " %s service --name web --ports 80,443 --bootstrap \"python3 -m http.server 80\"\n", prog); + fprintf(stderr, " %s service --name app --ports 8000 --bootstrap-file ./setup.sh\n", prog); + fprintf(stderr, " %s service --name app -f app.tar.gz --bootstrap-file ./setup.sh # deploy tarball\n", prog); + fprintf(stderr, " %s service --name blog --ports 8000 --domains blog.example.com,www.example.com\n", prog); + fprintf(stderr, " %s service --list # list all services\n", prog); + fprintf(stderr, " %s service --info abc123 # get service details\n", prog); + fprintf(stderr, " %s service --logs abc123 # get bootstrap logs\n", prog); + fprintf(stderr, " %s service --freeze abc123 # freeze a service\n", prog); + fprintf(stderr, " %s service --unfreeze abc123 # unfreeze a service\n", prog); + fprintf(stderr, " %s service --resize abc123 --vcpu 4 # scale to 4 vCPU, 8GB RAM\n", prog); + fprintf(stderr, " %s service --destroy abc123 # destroy a service\n", prog); + fprintf(stderr, " %s service --redeploy abc123 --bootstrap ./script.sh\n", prog); + fprintf(stderr, " %s service --execute maldoror 'journalctl -u myapp -n 50'\n", prog); + fprintf(stderr, " %s service --dump-bootstrap maldoror # print bootstrap to stdout\n", prog); + fprintf(stderr, " %s service --dump-bootstrap maldoror backup.sh # save to file\n", prog); + fprintf(stderr, " %s service --name app -e API_KEY=secret -e DEBUG=1 # with env vars\n", prog); + fprintf(stderr, " %s service --name app --env-file .env # with env file\n", prog); + fprintf(stderr, " %s service env status myapp # check vault status\n", prog); + fprintf(stderr, " %s service env set myapp -e KEY=val -e SECRET=xxx # set from flags\n", prog); + fprintf(stderr, " %s service env set myapp --env-file .env # set vault from file\n", prog); + fprintf(stderr, " %s service env set myapp < .env # set vault from stdin\n", prog); + fprintf(stderr, " %s service env export myapp # export vault contents\n", prog); + fprintf(stderr, " %s service env delete myapp # delete vault\n", prog); + fprintf(stderr, " %s service --snapshot abc123 # create snapshot of service\n", prog); + fprintf(stderr, " %s service --restore unsb-snapshot-xxxx # restore service\n", prog); + fprintf(stderr, " %s session --snapshot abc123 # create snapshot of session\n", prog); + fprintf(stderr, " %s session --restore unsb-snapshot-xxxx # restore session\n", prog); + fprintf(stderr, " %s snapshot --list # list all snapshots\n", prog); + fprintf(stderr, " %s snapshot --info unsb-snapshot-xxxx # get snapshot details\n", prog); + fprintf(stderr, " %s snapshot --delete unsb-snapshot-xxxx # delete a snapshot\n", prog); + fprintf(stderr, " %s snapshot --clone unsb-snapshot-xxxx --type service --name myapp\n", prog); + fprintf(stderr, " %s key # check API key validity\n", prog); + fprintf(stderr, " %s key --extend # open portal to extend key\n", prog); + fprintf(stderr, "\nAuthentication:\n"); + fprintf(stderr, " Credentials are loaded in order of priority:\n"); + fprintf(stderr, " 1. -p and -k flags (public and secret key)\n"); + fprintf(stderr, " 2. UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars\n"); + fprintf(stderr, " 3. ~/.unsandbox/accounts.csv (format: public_key,secret_key per line)\n"); + fprintf(stderr, " Use --account N to select account by index (0-based, default: 0)\n"); + fprintf(stderr, " Or set UNSANDBOX_ACCOUNT=N env var\n"); +} + +int main(int argc, char *argv[]) { + // Disable stdout buffering for real-time output + setvbuf(stdout, NULL, _IONBF, 0); + + const char *filename = NULL; + const char *cli_public_key = NULL; // -p flag + const char *cli_secret_key = NULL; // -k flag + int cli_account_index = -1; // --account flag (-1 = use env or default) + const char *artifact_dir = NULL; + const char *network_mode = NULL; + const char *shell = NULL; // -s/--shell for language + int vcpu = 0; // 0 = default (1), valid values: 1, 2, 4, 8 + int ttl = 0; // 0 = default (60s), valid values: 1-900 + int save_artifacts = 0; + int skip_confirm = 0; // -y flag to skip large upload confirmation + + struct InputFile input_files[MAX_INPUT_FILES]; + int input_file_count = 0; + long total_input_size = 0; // Track total input file size + + struct EnvVar env_vars[MAX_ENV_VARS]; + int env_var_count = 0; + + // Check for key command first + if (argc >= 2 && strcmp(argv[1], "key") == 0) { + int do_extend = 0; + + // Parse options + for (int i = 2; i < argc; i++) { + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + i++; + cli_public_key = argv[i]; + } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { + i++; + cli_secret_key = argv[i]; + } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { + i++; + cli_account_index = atoi(argv[i]); + } else if (strcmp(argv[i], "--extend") == 0) { + do_extend = 1; + } + } + + // Get credentials (priority: flags > env > file with --account) + UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); + + if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { + fprintf(stderr, "Error: API credentials required.\n"); + fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); + fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); + fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); + free_credentials(creds); + return 1; + } + + // Handle --extend: validate key to get public key, then open portal + if (do_extend) { + // If we have public_key from credentials, use it directly + if (creds->public_key) { + char extend_url[512]; + snprintf(extend_url, sizeof(extend_url), "%s/keys/extend?pk=%s", PORTAL_BASE, creds->public_key); + printf("Opening extension page in browser...\n"); + printf("If browser doesn't open, visit: %s\n", extend_url); + + // Try to open URL in browser + #ifdef __APPLE__ + char cmd[1024]; + snprintf(cmd, sizeof(cmd), "open '%s'", extend_url); + system(cmd); + #elif defined(__linux__) + char cmd[1024]; + snprintf(cmd, sizeof(cmd), "xdg-open '%s' 2>/dev/null || sensible-browser '%s' 2>/dev/null", extend_url, extend_url); + system(cmd); + #elif defined(_WIN32) + char cmd[1024]; + snprintf(cmd, sizeof(cmd), "start %s", extend_url); + system(cmd); + #endif + + free_credentials(creds); + return 0; + } + } + + // Default: validate key using HMAC authentication + curl_global_init(CURL_GLOBAL_DEFAULT); + int ret = validate_api_key(creds); + curl_global_cleanup(); + free_credentials(creds); + return ret; + } + + // Check for snapshot command + if (argc >= 2 && strcmp(argv[1], "snapshot") == 0) { + const char *snapshot_id = NULL; + const char *clone_type = NULL; + const char *clone_name = NULL; + const char *clone_shell = NULL; + const char *clone_ports = NULL; + int do_list = 0; + int do_info = 0; + int do_delete = 0; + int do_lock = 0; + int do_unlock = 0; + int do_clone = 0; + int show_help = 0; + + // Parse snapshot-specific args + for (int i = 2; i < argc; i++) { + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + i++; + cli_public_key = argv[i]; + } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { + i++; + cli_secret_key = argv[i]; + } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { + i++; + cli_account_index = atoi(argv[i]); + } else if (strcmp(argv[i], "-l") == 0 || strcmp(argv[i], "--list") == 0) { + do_list = 1; + } else if (strcmp(argv[i], "--info") == 0 && i + 1 < argc) { + do_info = 1; + i++; + snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--delete") == 0 && i + 1 < argc) { + do_delete = 1; + i++; + snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--lock") == 0 && i + 1 < argc) { + do_lock = 1; + i++; + snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--unlock") == 0 && i + 1 < argc) { + do_unlock = 1; + i++; + snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--clone") == 0 && i + 1 < argc) { + do_clone = 1; + i++; + snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--type") == 0 && i + 1 < argc) { + i++; + clone_type = argv[i]; + } else if (strcmp(argv[i], "--name") == 0 && i + 1 < argc) { + i++; + clone_name = argv[i]; + } else if (strcmp(argv[i], "--shell") == 0 && i + 1 < argc) { + i++; + clone_shell = argv[i]; + } else if (strcmp(argv[i], "--ports") == 0 && i + 1 < argc) { + i++; + clone_ports = argv[i]; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + show_help = 1; + } else if (argv[i][0] == '-') { + fprintf(stderr, "Unknown option: %s\n", argv[i]); + print_usage(argv[0]); + return 1; + } + } + + if (show_help) { + print_usage(argv[0]); + return 0; + } + + // Get credentials + UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); + if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { + fprintf(stderr, "Error: API credentials required.\n"); + fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); + fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); + fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); + free_credentials(creds); + return 1; + } + + curl_global_init(CURL_GLOBAL_DEFAULT); + int ret = 0; + + if (do_list) { + ret = list_snapshots(creds); + } else if (do_info) { + ret = get_snapshot_info(creds, snapshot_id); + } else if (do_delete) { + ret = delete_snapshot(creds, snapshot_id); + } else if (do_lock) { + ret = lock_snapshot(creds, snapshot_id); + } else if (do_unlock) { + ret = unlock_snapshot(creds, snapshot_id); + } else if (do_clone) { + if (!clone_type) { + fprintf(stderr, "Error: --type required for --clone (session or service)\n"); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + if (strcmp(clone_type, "session") != 0 && strcmp(clone_type, "service") != 0) { + fprintf(stderr, "Error: --type must be 'session' or 'service'\n"); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + ret = clone_snapshot(creds, snapshot_id, clone_type, clone_name, clone_shell, clone_ports); + } else { + fprintf(stderr, "Error: No snapshot action specified. Use --list, --info, --delete, --lock, --unlock, or --clone\n"); + print_usage(argv[0]); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + + curl_global_cleanup(); + free_credentials(creds); + return ret; + } + + // Check for service command first + if (argc >= 2 && strcmp(argv[1], "service") == 0) { + const char *service_name = NULL; + const char *service_ports = NULL; + const char *service_domains = NULL; + const char *service_type = NULL; + const char *golden_image = NULL; + const char *service_bootstrap = NULL; + const char *bootstrap_file = NULL; + const char *service_id = NULL; + struct InputFile service_input_files[MAX_INPUT_FILES]; + int service_input_file_count = 0; + int show_help = 0; + int do_list = 0; + int do_info = 0; + int do_tail = 0; + int do_logs = 0; + int do_download_logs = 0; + const char *download_logs_file = NULL; + int do_freeze = 0; + int do_unfreeze = 0; + int do_destroy = 0; + int do_lock = 0; + int do_unlock = 0; + int do_resize = 0; + int do_redeploy = 0; + int do_execute = 0; + const char *execute_command = NULL; + int do_dump_bootstrap = 0; + const char *dump_bootstrap_file = NULL; + int do_snapshot = 0; + int do_restore = 0; + const char *restore_snapshot_id = NULL; + const char *snapshot_name = NULL; + int hot_snapshot = 0; + + // Environment variables for service create + char *service_env_content = NULL; // Accumulated env vars (KEY=VALUE\n...) + size_t service_env_size = 0; + size_t service_env_capacity = 0; + const char *service_env_file = NULL; // --env-file path + + // Env subcommand: un service env status|set|export|delete + const char *env_subcommand = NULL; + const char *env_target_id = NULL; + + // Parse service-specific args (flags like session) + for (int i = 2; i < argc; i++) { + // Check for "env" subcommand: un service env + if (strcmp(argv[i], "env") == 0 && i + 2 < argc) { + env_subcommand = argv[i + 1]; // status, set, export, delete + env_target_id = argv[i + 2]; // service name/id + i += 2; + // Continue parsing for --env-file in case of "set" + continue; + } + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + i++; + cli_public_key = argv[i]; + } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { + i++; + cli_secret_key = argv[i]; + } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { + i++; + cli_account_index = atoi(argv[i]); + } else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) { + i++; + network_mode = argv[i]; + } else if (strcmp(argv[i], "--name") == 0 && i + 1 < argc) { + i++; + service_name = argv[i]; + } else if (strcmp(argv[i], "--ports") == 0 && i + 1 < argc) { + i++; + service_ports = argv[i]; + } else if (strcmp(argv[i], "--domains") == 0 && i + 1 < argc) { + i++; + service_domains = argv[i]; + } else if (strcmp(argv[i], "--type") == 0 && i + 1 < argc) { + i++; + service_type = argv[i]; + } else if (strcmp(argv[i], "--golden-image") == 0 && i + 1 < argc) { + i++; + golden_image = argv[i]; + } else if (strcmp(argv[i], "--bootstrap") == 0 && i + 1 < argc) { + i++; + service_bootstrap = argv[i]; + } else if (strcmp(argv[i], "--bootstrap-file") == 0 && i + 1 < argc) { + i++; + bootstrap_file = argv[i]; + } else if (strcmp(argv[i], "-f") == 0 && i + 1 < argc) { + i++; + if (service_input_file_count >= MAX_INPUT_FILES) { + fprintf(stderr, "Error: too many input files (max %d)\n", MAX_INPUT_FILES); + return 1; + } + // Read file and base64 encode + size_t fsize; + char *content = read_file(argv[i], &fsize); + if (!content) { + fprintf(stderr, "Error: cannot read file '%s'\n", argv[i]); + return 1; + } + size_t b64_len; + char *b64 = base64_encode((unsigned char *)content, fsize, &b64_len); + free(content); + if (!b64) { + fprintf(stderr, "Error: failed to encode file '%s'\n", argv[i]); + return 1; + } + service_input_files[service_input_file_count].filename = strdup(get_basename(argv[i])); + service_input_files[service_input_file_count].content_base64 = b64; + service_input_file_count++; + } else if (strcmp(argv[i], "-F") == 0 && i + 1 < argc) { + // -F preserves relative path (for directory structures) + i++; + if (service_input_file_count >= MAX_INPUT_FILES) { + fprintf(stderr, "Error: too many input files (max %d)\n", MAX_INPUT_FILES); + return 1; + } + size_t fsize; + char *content = read_file(argv[i], &fsize); + if (!content) { + fprintf(stderr, "Error: cannot read file '%s'\n", argv[i]); + return 1; + } + size_t b64_len; + char *b64 = base64_encode((unsigned char *)content, fsize, &b64_len); + free(content); + if (!b64) { + fprintf(stderr, "Error: failed to encode file '%s'\n", argv[i]); + return 1; + } + // Use full path instead of basename + service_input_files[service_input_file_count].filename = strdup(argv[i]); + service_input_files[service_input_file_count].content_base64 = b64; + service_input_file_count++; + } else if (strcmp(argv[i], "-l") == 0 || strcmp(argv[i], "--list") == 0) { + do_list = 1; + } else if (strcmp(argv[i], "--info") == 0 && i + 1 < argc) { + do_info = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--tail") == 0 && i + 1 < argc) { + do_tail = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--logs") == 0 && i + 1 < argc) { + do_logs = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--download-logs") == 0 && i + 2 < argc) { + do_download_logs = 1; + i++; + service_id = argv[i]; + i++; + download_logs_file = argv[i]; + } else if (strcmp(argv[i], "--freeze") == 0 && i + 1 < argc) { + do_freeze = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--unfreeze") == 0 && i + 1 < argc) { + do_unfreeze = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--destroy") == 0 && i + 1 < argc) { + do_destroy = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--lock") == 0 && i + 1 < argc) { + do_lock = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--unlock") == 0 && i + 1 < argc) { + do_unlock = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--resize") == 0 && i + 1 < argc) { + do_resize = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--redeploy") == 0 && i + 1 < argc) { + do_redeploy = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--execute") == 0 && i + 2 < argc) { + do_execute = 1; + i++; + service_id = argv[i]; + i++; + execute_command = argv[i]; + } else if (strcmp(argv[i], "--dump-bootstrap") == 0 && i + 1 < argc) { + do_dump_bootstrap = 1; + i++; + service_id = argv[i]; + // Optional file argument + if (i + 1 < argc && argv[i + 1][0] != '-') { + i++; + dump_bootstrap_file = argv[i]; + } + } else if ((strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--vcpu") == 0) && i + 1 < argc) { + i++; + vcpu = atoi(argv[i]); + if (vcpu < 1 || vcpu > 8) { + fprintf(stderr, "Error: -v/--vcpu must be 1-8\n"); + return 1; + } + } else if (strcmp(argv[i], "--snapshot") == 0 && i + 1 < argc) { + do_snapshot = 1; + i++; + service_id = argv[i]; + } else if (strcmp(argv[i], "--restore") == 0 && i + 1 < argc) { + do_restore = 1; + i++; + restore_snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--snapshot-name") == 0 && i + 1 < argc) { + i++; + snapshot_name = argv[i]; + } else if (strcmp(argv[i], "--hot") == 0) { + hot_snapshot = 1; + } else if ((strcmp(argv[i], "-e") == 0 || strcmp(argv[i], "--env") == 0) && i + 1 < argc) { + // -e KEY=VALUE or --env KEY=VALUE - accumulate env vars + i++; + const char *env_pair = argv[i]; + // Validate format: must contain '=' + if (!strchr(env_pair, '=')) { + fprintf(stderr, "Error: Invalid environment variable format '%s'. Use KEY=VALUE\n", env_pair); + return 1; + } + // Add to accumulated env content + size_t pair_len = strlen(env_pair); + size_t needed = service_env_size + pair_len + 2; // +1 for newline, +1 for null + if (needed > service_env_capacity) { + service_env_capacity = needed > 4096 ? needed * 2 : 4096; + char *new_content = realloc(service_env_content, service_env_capacity); + if (!new_content) { + fprintf(stderr, "Error: Out of memory\n"); + free(service_env_content); + return 1; + } + service_env_content = new_content; + } + memcpy(service_env_content + service_env_size, env_pair, pair_len); + service_env_size += pair_len; + service_env_content[service_env_size++] = '\n'; + service_env_content[service_env_size] = '\0'; + } else if (strcmp(argv[i], "--env-file") == 0 && i + 1 < argc) { + i++; + service_env_file = argv[i]; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + show_help = 1; + } + } + + // Show help if requested or no action specified + if (show_help) { + print_usage(argv[0]); + return 0; + } + + // Get credentials (priority: env > flags > file) + UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); + if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { + fprintf(stderr, "Error: API credentials required.\n"); + fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); + fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); + fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); + free_credentials(creds); + return 1; + } + + curl_global_init(CURL_GLOBAL_DEFAULT); + int ret = 0; + + // Handle env subcommand first + if (env_subcommand) { + if (strcmp(env_subcommand, "status") == 0) { + ret = service_env_status(creds, env_target_id); + } else if (strcmp(env_subcommand, "set") == 0) { + // Read env content from --env-file, -e flags, or stdin + char *env_content = NULL; + if (service_env_file) { + env_content = read_env_file(service_env_file); + } else if (service_env_content && service_env_size > 0) { + env_content = service_env_content; + service_env_content = NULL; // Transfer ownership + } else { + // Check if stdin is a TTY + if (isatty(STDIN_FILENO)) { + fprintf(stderr, "Reading environment variables from stdin (Ctrl+D to finish):\n"); + } + env_content = read_env_stdin(); + } + if (env_content) { + ret = service_env_set(creds, env_target_id, env_content); + free(env_content); + } else { + ret = 1; + } + } else if (strcmp(env_subcommand, "export") == 0) { + ret = service_env_export(creds, env_target_id); + } else if (strcmp(env_subcommand, "delete") == 0) { + ret = service_env_delete(creds, env_target_id); + } else { + fprintf(stderr, "Error: Unknown env subcommand '%s'. Use: status, set, export, delete\n", env_subcommand); + ret = 1; + } + free(service_env_content); + curl_global_cleanup(); + free_credentials(creds); + return ret; + } + + if (do_list) { + ret = list_services(creds); + } else if (do_info) { + ret = get_service_info(creds, service_id); + } else if (do_tail) { + // --tail: last 9000 lines (default) + char *log = get_service_logs(creds, service_id, 0); + if (log) { + printf("%s", log); + free(log); + ret = 0; + } else { + fprintf(stderr, "Error: Failed to fetch logs (service not found or no logs available)\n"); + ret = 1; + } + } else if (do_logs) { + // --logs: all logs + char *log = get_service_logs(creds, service_id, 1); + if (log) { + printf("%s", log); + free(log); + ret = 0; + } else { + fprintf(stderr, "Error: Failed to fetch logs (service not found or no logs available)\n"); + ret = 1; + } + } else if (do_download_logs) { + // --download-logs: all logs to file + char *log = get_service_logs(creds, service_id, 1); + if (log) { + FILE *f = fopen(download_logs_file, "w"); + if (f) { + fprintf(f, "%s", log); + fclose(f); + printf("Logs saved to %s\n", download_logs_file); + ret = 0; + } else { + fprintf(stderr, "Error: Could not open file %s for writing\n", download_logs_file); + ret = 1; + } + free(log); + } else { + fprintf(stderr, "Error: Failed to fetch logs (service not found or no logs available)\n"); + ret = 1; + } + } else if (do_freeze) { + ret = freeze_service(creds, service_id); + } else if (do_unfreeze) { + ret = unfreeze_service(creds, service_id); + } else if (do_destroy) { + ret = destroy_service(creds, service_id); + } else if (do_lock) { + ret = lock_service(creds, service_id); + } else if (do_unlock) { + ret = unlock_service(creds, service_id); + } else if (do_resize) { + if (vcpu < 1 || vcpu > 8) { + fprintf(stderr, "Error: --vcpu must be 1-8 for resize\n"); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + ret = resize_service(creds, service_id, vcpu); + } else if (do_redeploy) { + if (!service_bootstrap) { + fprintf(stderr, "Error: --bootstrap required for --redeploy\n"); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + ret = redeploy_service(creds, service_id, service_bootstrap); + } else if (do_execute) { + // Default timeout 30 seconds (30000ms) + ret = execute_service(creds, service_id, execute_command, 30000); + } else if (do_dump_bootstrap) { + // Dump bootstrap script from /tmp/bootstrap.sh inside the service + // This is useful for migrations - the bootstrap is stored at the same path on all instances + fprintf(stderr, "Fetching bootstrap script from %s...\n", service_id); + + // Use execute to cat the bootstrap file + char *bootstrap = execute_service_capture(creds, service_id, "cat /tmp/bootstrap.sh", 30000); + if (bootstrap) { + if (dump_bootstrap_file) { + // Write to file + FILE *f = fopen(dump_bootstrap_file, "w"); + if (f) { + fprintf(f, "%s", bootstrap); + fclose(f); + // Make executable + chmod(dump_bootstrap_file, 0755); + printf("Bootstrap saved to %s\n", dump_bootstrap_file); + ret = 0; + } else { + fprintf(stderr, "Error: Could not open file %s for writing\n", dump_bootstrap_file); + ret = 1; + } + } else { + // Print to stdout + printf("%s", bootstrap); + ret = 0; + } + free(bootstrap); + } else { + fprintf(stderr, "Error: Failed to fetch bootstrap (service not running or no bootstrap file)\n"); + ret = 1; + } + } else if (do_snapshot) { + ret = create_service_snapshot(creds, service_id, snapshot_name, hot_snapshot); + } else if (do_restore) { + ret = restore_from_snapshot(creds, restore_snapshot_id, "Service"); + } else if (service_name) { + // Create service (default action when --name is provided) + char *bootstrap_content = NULL; + + // If --bootstrap-file provided, read its contents + if (bootstrap_file && strlen(bootstrap_file) > 0) { + struct stat st; + if (stat(bootstrap_file, &st) != 0 || !S_ISREG(st.st_mode)) { + fprintf(stderr, "Error: Bootstrap file not found: '%s'\n", bootstrap_file); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + size_t fsize; + bootstrap_content = read_file(bootstrap_file, &fsize); + if (!bootstrap_content) { + fprintf(stderr, "Error: Failed to read bootstrap file '%s'\n", bootstrap_file); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + fprintf(stderr, "Read bootstrap script (%zu bytes) from %s\n", fsize, bootstrap_file); + } + + fprintf(stderr, "Creating service '%s'...", service_name); + if (service_input_file_count > 0) { + fprintf(stderr, " (%d files)...", service_input_file_count); + } + fflush(stderr); + char *created_id = create_service(creds, service_name, service_ports, service_domains, service_bootstrap, bootstrap_content, network_mode, vcpu, service_type, service_input_files, service_input_file_count, golden_image); + if (bootstrap_content) free(bootstrap_content); + // Free input file memory + for (int i = 0; i < service_input_file_count; i++) { + free(service_input_files[i].filename); + free(service_input_files[i].content_base64); + } + + if (created_id) { + fprintf(stderr, " done\n"); + printf("\033[32mService created successfully\033[0m\n"); + printf("Service ID: %s\n", created_id); + + // Set environment vault if env vars were provided + char *env_content_to_set = NULL; + if (service_env_file) { + env_content_to_set = read_env_file(service_env_file); + } else if (service_env_content && service_env_size > 0) { + env_content_to_set = service_env_content; + service_env_content = NULL; // Transfer ownership + } + if (env_content_to_set) { + fprintf(stderr, "Setting environment vault...\n"); + int env_ret = service_env_set(creds, created_id, env_content_to_set); + free(env_content_to_set); + if (env_ret != 0) { + fprintf(stderr, "\033[33mWarning: Failed to set environment vault\033[0m\n"); + } + } + + // Wait a moment then check bootstrap logs + if ((service_bootstrap && strlen(service_bootstrap) > 0) || + (bootstrap_file && strlen(bootstrap_file) > 0)) { + fprintf(stderr, "Checking bootstrap status...\n"); + sleep(2); // Give bootstrap time to start + char *log = get_service_logs(creds, created_id, 0); + if (log && strlen(log) > 0) { + printf("\n--- Bootstrap Log ---\n%s\n--- End Log ---\n", log); + free(log); + } + } + + free(created_id); + ret = 0; + } else { + fprintf(stderr, " failed\n"); + // Try to get logs if we can find the service by name + // The service might exist even if create returned error + fprintf(stderr, "Attempting to fetch bootstrap logs...\n"); + char *log = get_service_logs(creds, service_name, 0); + if (log && strlen(log) > 0) { + fprintf(stderr, "\n\033[31m--- Bootstrap Log ---\033[0m\n%s\n\033[31m--- End Log ---\033[0m\n", log); + free(log); + } + ret = 1; + } + // Clean up env content if not transferred + free(service_env_content); + } else { + // No action specified, show help + free(service_env_content); + fprintf(stderr, "Error: No service action specified. Use --list, --info, --name, etc.\n"); + print_usage(argv[0]); + curl_global_cleanup(); + free_credentials(creds); + return 1; + } + + curl_global_cleanup(); + free_credentials(creds); + return ret; + } + + // Check for session command + if (argc >= 2 && strcmp(argv[1], "session") == 0) { + int audit_history = 0; + int list_only = 0; + const char *shell = NULL; + const char *attach_to = NULL; + const char *kill_target = NULL; + const char *freeze_target = NULL; + const char *unfreeze_target = NULL; + const char *boost_target = NULL; + int boost_vcpu = 0; + const char *unboost_target = NULL; + const char *multiplexer = NULL; // NULL = no multiplexer, "tmux", or "screen" + struct InputFile session_input_files[MAX_INPUT_FILES]; + int session_input_file_count = 0; + const char *snapshot_target = NULL; + const char *restore_snapshot_id = NULL; + const char *snapshot_name = NULL; + int hot_snapshot = 0; + // Parse shell-specific args + for (int i = 2; i < argc; i++) { + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + i++; + cli_public_key = argv[i]; + } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { + i++; + cli_secret_key = argv[i]; + } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { + i++; + cli_account_index = atoi(argv[i]); + } else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) { + i++; + network_mode = argv[i]; + } else if ((strcmp(argv[i], "-s") == 0 || strcmp(argv[i], "--shell") == 0) && i + 1 < argc) { + i++; + shell = argv[i]; + } else if (strcmp(argv[i], "-a") == 0 || strcmp(argv[i], "--artifacts") == 0) { + save_artifacts = 1; + } else if (strcmp(argv[i], "--audit") == 0) { + audit_history = 1; + save_artifacts = 1; // --audit implies -a + } else if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) { + i++; + artifact_dir = argv[i]; + save_artifacts = 1; // -o implies -a + } else if (strcmp(argv[i], "-l") == 0 || strcmp(argv[i], "--list") == 0) { + list_only = 1; + } else if (strcmp(argv[i], "--attach") == 0 && i + 1 < argc) { + i++; + attach_to = argv[i]; + } else if (strcmp(argv[i], "--kill") == 0 && i + 1 < argc) { + i++; + kill_target = argv[i]; + } else if (strcmp(argv[i], "--freeze") == 0 && i + 1 < argc) { + i++; + freeze_target = argv[i]; + } else if (strcmp(argv[i], "--unfreeze") == 0 && i + 1 < argc) { + i++; + unfreeze_target = argv[i]; + } else if (strcmp(argv[i], "--boost") == 0 && i + 1 < argc) { + i++; + boost_target = argv[i]; + } else if (strcmp(argv[i], "--boost-vcpu") == 0 && i + 1 < argc) { + i++; + boost_vcpu = atoi(argv[i]); + } else if (strcmp(argv[i], "--unboost") == 0 && i + 1 < argc) { + i++; + unboost_target = argv[i]; + } else if (strcmp(argv[i], "--tmux") == 0) { + multiplexer = "tmux"; + } else if (strcmp(argv[i], "--screen") == 0) { + multiplexer = "screen"; + } else if (strcmp(argv[i], "-f") == 0 && i + 1 < argc) { + i++; + if (session_input_file_count >= MAX_INPUT_FILES) { + fprintf(stderr, "Error: too many input files (max %d)\n", MAX_INPUT_FILES); + return 1; + } + size_t fsize; + char *content = read_file(argv[i], &fsize); + if (!content) { + fprintf(stderr, "Error: cannot read file '%s'\n", argv[i]); + return 1; + } + size_t b64_len; + char *b64 = base64_encode((unsigned char *)content, fsize, &b64_len); + free(content); + if (!b64) { + fprintf(stderr, "Error: failed to encode file '%s'\n", argv[i]); + return 1; + } + session_input_files[session_input_file_count].filename = strdup(get_basename(argv[i])); + session_input_files[session_input_file_count].content_base64 = b64; + session_input_file_count++; + } else if (strcmp(argv[i], "-F") == 0 && i + 1 < argc) { + // -F preserves relative path (for directory structures) + i++; + if (session_input_file_count >= MAX_INPUT_FILES) { + fprintf(stderr, "Error: too many input files (max %d)\n", MAX_INPUT_FILES); + return 1; + } + size_t fsize; + char *content = read_file(argv[i], &fsize); + if (!content) { + fprintf(stderr, "Error: cannot read file '%s'\n", argv[i]); + return 1; + } + size_t b64_len; + char *b64 = base64_encode((unsigned char *)content, fsize, &b64_len); + free(content); + if (!b64) { + fprintf(stderr, "Error: failed to encode file '%s'\n", argv[i]); + return 1; + } + // Use full path instead of basename + session_input_files[session_input_file_count].filename = strdup(argv[i]); + session_input_files[session_input_file_count].content_base64 = b64; + session_input_file_count++; + } else if ((strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--vcpu") == 0) && i + 1 < argc) { + i++; + vcpu = atoi(argv[i]); + if (vcpu < 1 || vcpu > 8) { + fprintf(stderr, "Error: -v/--vcpu must be 1-8\n"); + return 1; + } + } else if (strcmp(argv[i], "--snapshot") == 0 && i + 1 < argc) { + i++; + snapshot_target = argv[i]; + } else if (strcmp(argv[i], "--restore") == 0 && i + 1 < argc) { + i++; + restore_snapshot_id = argv[i]; + } else if (strcmp(argv[i], "--snapshot-name") == 0 && i + 1 < argc) { + i++; + snapshot_name = argv[i]; + } else if (strcmp(argv[i], "--hot") == 0) { + hot_snapshot = 1; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + print_usage(argv[0]); + return 0; + } else if (argv[i][0] == '-') { + fprintf(stderr, "Unknown option: %s\n", argv[i]); + print_usage(argv[0]); + return 1; + } + } + + // Get credentials (priority: env > flags > file) + UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); + if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { + fprintf(stderr, "Error: API credentials required.\n"); + fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); + fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); + fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); + free_credentials(creds); + return 1; + } + + curl_global_init(CURL_GLOBAL_DEFAULT); + int ret; + if (list_only) { + ret = list_sessions(creds); + } else if (kill_target) { + ret = kill_session(creds, kill_target); + } else if (freeze_target) { + ret = freeze_session(creds, freeze_target); + } else if (unfreeze_target) { + ret = unfreeze_session(creds, unfreeze_target); + } else if (boost_target) { + if (boost_vcpu == 0) boost_vcpu = 2; // Default boost: 2 vCPU + ret = boost_session(creds, boost_target, boost_vcpu); + } else if (unboost_target) { + ret = unboost_session(creds, unboost_target); + } else if (snapshot_target) { + ret = create_session_snapshot(creds, snapshot_target, snapshot_name, hot_snapshot); + } else if (restore_snapshot_id) { + ret = restore_from_snapshot(creds, restore_snapshot_id, "Session"); + } else if (attach_to) { + ret = reconnect_session(creds, attach_to, save_artifacts, artifact_dir, audit_history); + } else { + ret = shell_command(creds, network_mode, save_artifacts, artifact_dir, audit_history, shell, multiplexer, vcpu, session_input_files, session_input_file_count); + } + curl_global_cleanup(); + free_credentials(creds); + return ret; + } + + // Parse arguments for execute command (default) + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + print_usage(argv[0]); + return 0; + } else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) { + i++; + network_mode = argv[i]; + } else if (strcmp(argv[i], "-e") == 0 && i + 1 < argc) { + i++; + char *eq = strchr(argv[i], '='); + if (!eq) { + fprintf(stderr, "Error: -e requires KEY=VALUE format\n"); + return 1; + } + if (env_var_count >= MAX_ENV_VARS) { + fprintf(stderr, "Error: too many env vars (max %d)\n", MAX_ENV_VARS); + return 1; + } + env_vars[env_var_count].key = strndup(argv[i], eq - argv[i]); + env_vars[env_var_count].value = strdup(eq + 1); + env_var_count++; + } else if (strcmp(argv[i], "-f") == 0 && i + 1 < argc) { + i++; + if (input_file_count >= MAX_INPUT_FILES) { + fprintf(stderr, "Error: too many input files (max %d)\n", MAX_INPUT_FILES); + return 1; + } + size_t fsize; + char *content = read_file(argv[i], &fsize); + if (!content) return 1; + + // Check total size limit + total_input_size += fsize; + if (total_input_size > MAX_TOTAL_INPUT_SIZE) { + fprintf(stderr, "Error: total input file size exceeds limit (max 4GB)\n"); + free(content); + return 1; + } + + size_t b64_len; + char *b64 = base64_encode((unsigned char*)content, fsize, &b64_len); + free(content); + if (!b64) { + fprintf(stderr, "Error: failed to encode file\n"); + return 1; + } + + input_files[input_file_count].filename = strdup(get_basename(argv[i])); + input_files[input_file_count].content_base64 = b64; + input_file_count++; + } else if (strcmp(argv[i], "-F") == 0 && i + 1 < argc) { + // -F preserves relative path (for directory structures) + i++; + if (input_file_count >= MAX_INPUT_FILES) { + fprintf(stderr, "Error: too many input files (max %d)\n", MAX_INPUT_FILES); + return 1; + } + size_t fsize; + char *content = read_file(argv[i], &fsize); + if (!content) return 1; + + // Check total size limit + total_input_size += fsize; + if (total_input_size > MAX_TOTAL_INPUT_SIZE) { + fprintf(stderr, "Error: total input file size exceeds limit (max 4GB)\n"); + free(content); + return 1; + } + + size_t b64_len; + char *b64 = base64_encode((unsigned char*)content, fsize, &b64_len); + free(content); + if (!b64) { + fprintf(stderr, "Error: failed to encode file\n"); + return 1; + } + + // Use full path instead of basename + input_files[input_file_count].filename = strdup(argv[i]); + input_files[input_file_count].content_base64 = b64; + input_file_count++; + } else if (strcmp(argv[i], "-a") == 0 || strcmp(argv[i], "--artifacts") == 0) { + save_artifacts = 1; + } else if (strcmp(argv[i], "-y") == 0 || strcmp(argv[i], "--yes") == 0) { + skip_confirm = 1; + } else if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) { + i++; + artifact_dir = argv[i]; + } else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + i++; + cli_public_key = argv[i]; + } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { + i++; + cli_secret_key = argv[i]; + } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { + i++; + cli_account_index = atoi(argv[i]); + } else if ((strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--vcpu") == 0) && i + 1 < argc) { + i++; + vcpu = atoi(argv[i]); + if (vcpu < 1 || vcpu > 8) { + fprintf(stderr, "Error: -v/--vcpu must be 1-8\n"); + return 1; + } + } else if ((strcmp(argv[i], "-t") == 0 || strcmp(argv[i], "--ttl") == 0) && i + 1 < argc) { + i++; + ttl = atoi(argv[i]); + if (ttl < 1 || ttl > 900) { + fprintf(stderr, "Error: -t/--ttl must be 1-900 seconds\n"); + return 1; + } + } else if ((strcmp(argv[i], "-s") == 0 || strcmp(argv[i], "--shell") == 0) && i + 1 < argc) { + i++; + shell = argv[i]; + } else if (argv[i][0] != '-') { + filename = argv[i]; + } else { + fprintf(stderr, "Unknown option: %s\n", argv[i]); + print_usage(argv[0]); + return 1; + } + } + + if (!filename) { + print_usage(argv[0]); + return 1; + } + + // Warn about large uploads and confirm + if (total_input_size > LARGE_UPLOAD_WARN_SIZE && !skip_confirm) { + fprintf(stderr, "Warning: uploading %.1f GB of input files. This may take a while (base64 encoded).\n", + (double)total_input_size / (1024.0 * 1024.0 * 1024.0)); + fprintf(stderr, "Continue? [y/N] "); + int c = getchar(); + if (c != 'y' && c != 'Y') { + fprintf(stderr, "Aborted. Use -y to skip this confirmation.\n"); + return 1; + } + // Consume rest of line + while (c != '\n' && c != EOF) c = getchar(); + } + + // Get credentials (priority: env > flags > file) + UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); + if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { + fprintf(stderr, "Error: API credentials required.\n"); + fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); + fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); + fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); + free_credentials(creds); + return 1; + } + + // Get code: if -s is given OR file doesn't exist, treat as inline code + size_t code_size; + char *code; + int inline_mode = 0; + if (shell) { + // Explicit -s flag: treat as inline code + inline_mode = 1; + } else if (access(filename, F_OK) != 0) { + // File doesn't exist: assume bash inline code + shell = "bash"; + inline_mode = 1; + } + + if (inline_mode) { + // Inline code mode: argument is the code itself + code = strdup(filename); + code_size = strlen(code); + } else { + // File mode: read code from file + code = read_file(filename, &code_size); + if (!code) return 1; + } + + // Detect language (use -s/--shell if provided, otherwise auto-detect) + const char *language = shell; + if (!language) { + language = detect_language_from_extension(filename); + } + if (!language) { + language = detect_language_from_shebang(code); + } + if (!language) { + fprintf(stderr, "Error: cannot detect language from file extension or shebang\n"); + fprintf(stderr, " Use -s/--shell to specify the language (e.g., -s bash, -s python)\n"); + free(code); + return 1; + } + + // Escape code for JSON + char *escaped_code = escape_json_string(code); + free(code); + if (!escaped_code) { + fprintf(stderr, "Error: failed to escape code\n"); + return 1; + } + + // Build JSON payload + size_t payload_size = strlen(escaped_code) + 4096; + for (int i = 0; i < input_file_count; i++) { + payload_size += strlen(input_files[i].content_base64) + 256; + } + for (int i = 0; i < env_var_count; i++) { + payload_size += strlen(env_vars[i].key) + strlen(env_vars[i].value) + 32; + } + + char *json_payload = malloc(payload_size); + if (!json_payload) { + fprintf(stderr, "Error: out of memory\n"); + free(escaped_code); + return 1; + } + + char *p = json_payload; + p += sprintf(p, "{\"language\":\"%s\",\"code\":\"%s\"", language, escaped_code); + free(escaped_code); + + // Add input files + if (input_file_count > 0) { + p += sprintf(p, ",\"input_files\":["); + for (int i = 0; i < input_file_count; i++) { + if (i > 0) *p++ = ','; + char *esc_filename = escape_json_string(input_files[i].filename); + p += sprintf(p, "{\"filename\":\"%s\",\"content\":\"%s\"}", + esc_filename, input_files[i].content_base64); + free(esc_filename); + free(input_files[i].filename); + free(input_files[i].content_base64); + } + p += sprintf(p, "]"); + } + + // Add env vars + if (env_var_count > 0) { + p += sprintf(p, ",\"env\":{"); + for (int i = 0; i < env_var_count; i++) { + if (i > 0) *p++ = ','; + char *esc_key = escape_json_string(env_vars[i].key); + char *esc_val = escape_json_string(env_vars[i].value); + p += sprintf(p, "\"%s\":\"%s\"", esc_key, esc_val); + free(esc_key); + free(esc_val); + free(env_vars[i].key); + free(env_vars[i].value); + } + p += sprintf(p, "}"); + } + + // Add artifact flag + if (save_artifacts) { + p += sprintf(p, ",\"return_artifact\":true"); + } + + // Add vcpu if specified (> 1) + if (vcpu > 1) { + p += sprintf(p, ",\"vcpu\":%d", vcpu); + } + + // Add network_mode if specified + if (network_mode && strlen(network_mode) > 0) { + p += sprintf(p, ",\"network_mode\":\"%s\"", network_mode); + } + + // Add TTL if specified + if (ttl > 0) { + p += sprintf(p, ",\"ttl\":%d", ttl); + } + + p += sprintf(p, "}"); + + // Initialize libcurl + curl_global_init(CURL_GLOBAL_DEFAULT); + CURL *curl = curl_easy_init(); + if (!curl) { + fprintf(stderr, "Error: failed to initialize curl\n"); + free(json_payload); + curl_global_cleanup(); + return 1; + } + + // Set up response buffer + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + // Set up request headers with HMAC authentication + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", "/execute", json_payload); + + // Configure curl + curl_easy_setopt(curl, CURLOPT_URL, API_URL); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&response); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "un-cli/2.0"); + + // Set timeout based on TTL (or default to 120 seconds) + long curl_timeout = (ttl > 0) ? (ttl + 30) : 120; // Add 30s buffer + curl_easy_setopt(curl, CURLOPT_TIMEOUT, curl_timeout); + + // Perform request + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + fprintf(stderr, "Error: request failed: %s\n", curl_easy_strerror(res)); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + free(json_payload); + free(response.data); + curl_global_cleanup(); + return 1; + } + + // Check HTTP status code + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld\n", http_code); + if (response.data) { + fprintf(stderr, "%s\n", response.data); + } + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + free(json_payload); + free(response.data); + curl_global_cleanup(); + return 1; + } + + // Check if response contains job_id (async execution) + char *job_id = NULL; + char *status = NULL; + char *final_data = response.data; + + if (response.data) { + job_id = extract_json_string(response.data, "job_id"); + status = extract_json_string(response.data, "status"); + } + + // If we got a job_id and status isn't terminal, we need to poll + if (job_id && status) { + int need_poll = (strcmp(status, "pending") == 0 || + strcmp(status, "running") == 0); + + if (need_poll) { + // Free initial response, poll for final result + free(response.data); + final_data = poll_job_status(creds, job_id); + } + } + + // Parse and print final response + if (final_data) { + parse_and_print_response(final_data, save_artifacts, artifact_dir, filename); + if (final_data != response.data) { + free(final_data); + } + } + + // Cleanup + if (job_id) free(job_id); + if (status) free(status); + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + free(json_payload); + if (final_data == response.data) { + free(response.data); + } + curl_global_cleanup(); + free_credentials(creds); + + return 0; } diff --git a/clients/go/async/src/un_async.go b/clients/go/async/src/un_async.go index c6149e0..6aa000f 100644 --- a/clients/go/async/src/un_async.go +++ b/clients/go/async/src/un_async.go @@ -878,3 +878,685 @@ func DeleteSnapshot(creds *Credentials, snapshotID string) <-chan DeleteResult { return resultChan } + +// ============================================================================ +// Session Operations +// ============================================================================ + +// SessionOptions contains optional parameters for session creation. +type SessionOptions struct { + NetworkMode string // "zerotrust" (default) or "semitrusted" + Shell string // Shell to use (e.g., "bash", "python3") + TTL int // Time-to-live in seconds (default: 3600) + VCPU int // Number of virtual CPUs (default: 1) + Multiplexer string // Multiplexer to use (e.g., "tmux") +} + +// SessionListResult contains the result of listing sessions. +type SessionListResult struct { + Sessions []map[string]interface{} + Err error +} + +// SessionResult contains the result of a session operation. +type SessionResult struct { + Data map[string]interface{} + Err error +} + +// ListSessions lists all active sessions for the authenticated account. +// Returns a channel that receives exactly one SessionListResult then closes. +func ListSessions(creds *Credentials) <-chan SessionListResult { + resultChan := make(chan SessionListResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("GET", "/sessions", creds, nil) + if err != nil { + resultChan <- SessionListResult{Err: err} + return + } + + var sessions []map[string]interface{} + if sessionsInterface, ok := response["sessions"].([]interface{}); ok { + sessions = make([]map[string]interface{}, len(sessionsInterface)) + for i, session := range sessionsInterface { + if m, ok := session.(map[string]interface{}); ok { + sessions[i] = m + } + } + } + + resultChan <- SessionListResult{Sessions: sessions} + }() + + return resultChan +} + +// GetSession gets details of a specific session. +// Returns a channel that receives exactly one SessionResult then closes. +func GetSession(creds *Credentials, sessionID string) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("GET", fmt.Sprintf("/sessions/%s", sessionID), creds, nil) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// CreateSession creates a new interactive session. +// Returns a channel that receives exactly one SessionResult then closes. +// +// Args: +// +// creds: API credentials +// opts: Optional session configuration (can be nil for defaults) +func CreateSession(creds *Credentials, opts *SessionOptions) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + data := make(map[string]interface{}) + + if opts != nil { + if opts.NetworkMode != "" { + data["network_mode"] = opts.NetworkMode + } + if opts.Shell != "" { + data["shell"] = opts.Shell + } + if opts.TTL > 0 { + data["ttl"] = opts.TTL + } + if opts.VCPU > 0 { + data["vcpu"] = opts.VCPU + } + if opts.Multiplexer != "" { + data["multiplexer"] = opts.Multiplexer + } + } + + response, err := makeRequest("POST", "/sessions", creds, data) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// DeleteSession terminates a session. +// Returns a channel that receives exactly one SessionResult then closes. +func DeleteSession(creds *Credentials, sessionID string) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("DELETE", fmt.Sprintf("/sessions/%s", sessionID), creds, nil) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// FreezeSession freezes a session (pauses execution, preserves state). +// Returns a channel that receives exactly one SessionResult then closes. +func FreezeSession(creds *Credentials, sessionID string) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/sessions/%s/freeze", sessionID), creds, map[string]interface{}{}) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// UnfreezeSession unfreezes a previously frozen session. +// Returns a channel that receives exactly one SessionResult then closes. +func UnfreezeSession(creds *Credentials, sessionID string) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/sessions/%s/unfreeze", sessionID), creds, map[string]interface{}{}) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// BoostSession increases the vCPU allocation for a session. +// Returns a channel that receives exactly one SessionResult then closes. +// +// Args: +// +// creds: API credentials +// sessionID: Session ID to boost +// vcpu: Number of vCPUs (2, 4, 8, etc.) +func BoostSession(creds *Credentials, sessionID string, vcpu int) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + data := map[string]interface{}{ + "vcpu": vcpu, + } + response, err := makeRequest("POST", fmt.Sprintf("/sessions/%s/boost", sessionID), creds, data) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// UnboostSession resets the vCPU allocation for a session to default. +// Returns a channel that receives exactly one SessionResult then closes. +func UnboostSession(creds *Credentials, sessionID string) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/sessions/%s/unboost", sessionID), creds, map[string]interface{}{}) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// ShellSession executes a command in a session's shell. +// Returns a channel that receives exactly one SessionResult then closes. +// +// Note: For interactive shell access, use the WebSocket-based shell endpoint. +// This function is for executing single commands. +func ShellSession(creds *Credentials, sessionID, command string) <-chan SessionResult { + resultChan := make(chan SessionResult, 1) + + go func() { + defer close(resultChan) + + data := map[string]interface{}{ + "command": command, + } + response, err := makeRequest("POST", fmt.Sprintf("/sessions/%s/shell", sessionID), creds, data) + resultChan <- SessionResult{Data: response, Err: err} + }() + + return resultChan +} + +// ============================================================================ +// Service Operations +// ============================================================================ + +// ServiceOptions contains optional parameters for service creation. +type ServiceOptions struct { + NetworkMode string // "zerotrust" (default) or "semitrusted" + Shell string // Shell to use for bootstrap + VCPU int // Number of virtual CPUs +} + +// ServiceUpdateOptions contains optional parameters for service updates. +type ServiceUpdateOptions struct { + VCPU int // Number of virtual CPUs +} + +// ServiceListResult contains the result of listing services. +type ServiceListResult struct { + Services []map[string]interface{} + Err error +} + +// ServiceResult contains the result of a service operation. +type ServiceResult struct { + Data map[string]interface{} + Err error +} + +// ListServices lists all services for the authenticated account. +// Returns a channel that receives exactly one ServiceListResult then closes. +func ListServices(creds *Credentials) <-chan ServiceListResult { + resultChan := make(chan ServiceListResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("GET", "/services", creds, nil) + if err != nil { + resultChan <- ServiceListResult{Err: err} + return + } + + var services []map[string]interface{} + if servicesInterface, ok := response["services"].([]interface{}); ok { + services = make([]map[string]interface{}, len(servicesInterface)) + for i, service := range servicesInterface { + if m, ok := service.(map[string]interface{}); ok { + services[i] = m + } + } + } + + resultChan <- ServiceListResult{Services: services} + }() + + return resultChan +} + +// CreateService creates a new persistent service. +// Returns a channel that receives exactly one ServiceResult then closes. +// +// Args: +// +// creds: API credentials +// name: Service name +// ports: Array of port numbers to expose +// bootstrap: Bootstrap script to run on service start +// opts: Optional service configuration (can be nil for defaults) +func CreateService(creds *Credentials, name string, ports []int, bootstrap string, opts *ServiceOptions) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + data := map[string]interface{}{ + "name": name, + "ports": ports, + "bootstrap": bootstrap, + } + + if opts != nil { + if opts.NetworkMode != "" { + data["network_mode"] = opts.NetworkMode + } + if opts.Shell != "" { + data["shell"] = opts.Shell + } + if opts.VCPU > 0 { + data["vcpu"] = opts.VCPU + } + } + + response, err := makeRequest("POST", "/services", creds, data) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// GetService gets details of a specific service. +// Returns a channel that receives exactly one ServiceResult then closes. +func GetService(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("GET", fmt.Sprintf("/services/%s", serviceID), creds, nil) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// UpdateService updates a service's configuration. +// Returns a channel that receives exactly one ServiceResult then closes. +func UpdateService(creds *Credentials, serviceID string, opts *ServiceUpdateOptions) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + data := make(map[string]interface{}) + if opts != nil { + if opts.VCPU > 0 { + data["vcpu"] = opts.VCPU + } + } + response, err := makeRequest("PATCH", fmt.Sprintf("/services/%s", serviceID), creds, data) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// DeleteService destroys a service. +// Returns a channel that receives exactly one ServiceResult then closes. +func DeleteService(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("DELETE", fmt.Sprintf("/services/%s", serviceID), creds, nil) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// FreezeService freezes a service (pauses execution, preserves state). +// Returns a channel that receives exactly one ServiceResult then closes. +func FreezeService(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/freeze", serviceID), creds, map[string]interface{}{}) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// UnfreezeService unfreezes a previously frozen service. +// Returns a channel that receives exactly one ServiceResult then closes. +func UnfreezeService(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/unfreeze", serviceID), creds, map[string]interface{}{}) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// LockService locks a service to prevent modifications or deletion. +// Returns a channel that receives exactly one ServiceResult then closes. +func LockService(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/lock", serviceID), creds, map[string]interface{}{}) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// UnlockService unlocks a previously locked service. +// Returns a channel that receives exactly one ServiceResult then closes. +func UnlockService(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/unlock", serviceID), creds, map[string]interface{}{}) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// GetServiceLogs retrieves logs from a service. +// Returns a channel that receives exactly one ServiceResult then closes. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// all: If true, returns all logs; if false, returns only recent logs +func GetServiceLogs(creds *Credentials, serviceID string, all bool) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + path := fmt.Sprintf("/services/%s/logs", serviceID) + if all { + path = fmt.Sprintf("/services/%s/logs?all=true", serviceID) + } + response, err := makeRequest("GET", path, creds, nil) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// GetServiceEnv retrieves the environment variable names for a service. +// Returns a channel that receives exactly one ServiceResult then closes. +// Note: Values are not returned for security; use ExportServiceEnv for full export. +func GetServiceEnv(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("GET", fmt.Sprintf("/services/%s/env", serviceID), creds, nil) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// SetServiceEnv sets environment variables for a service. +// Returns a channel that receives exactly one ServiceResult then closes. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// env: Map of environment variable names to values +func SetServiceEnv(creds *Credentials, serviceID string, env map[string]string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/env", serviceID), creds, env) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// DeleteServiceEnv deletes environment variables from a service. +// Returns a channel that receives exactly one ServiceResult then closes. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// keys: List of environment variable names to delete (nil deletes all) +func DeleteServiceEnv(creds *Credentials, serviceID string, keys []string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + var data interface{} + if keys != nil { + data = map[string]interface{}{"keys": keys} + } + response, err := makeRequest("DELETE", fmt.Sprintf("/services/%s/env", serviceID), creds, data) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// ExportServiceEnv exports all environment variables for a service. +// Returns a channel that receives exactly one ServiceResult then closes. +// Returns the full .env format content with values. +func ExportServiceEnv(creds *Credentials, serviceID string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/env/export", serviceID), creds, map[string]interface{}{}) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// RedeployService redeploys a service with optional new bootstrap script. +// Returns a channel that receives exactly one ServiceResult then closes. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// bootstrap: New bootstrap script (empty string to keep existing) +func RedeployService(creds *Credentials, serviceID string, bootstrap string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + data := make(map[string]interface{}) + if bootstrap != "" { + data["bootstrap"] = bootstrap + } + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/redeploy", serviceID), creds, data) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// ExecuteInService executes a command in a running service. +// Returns a channel that receives exactly one ServiceResult then closes. +func ExecuteInService(creds *Credentials, serviceID, command string) <-chan ServiceResult { + resultChan := make(chan ServiceResult, 1) + + go func() { + defer close(resultChan) + + data := map[string]interface{}{ + "command": command, + } + response, err := makeRequest("POST", fmt.Sprintf("/services/%s/execute", serviceID), creds, data) + resultChan <- ServiceResult{Data: response, Err: err} + }() + + return resultChan +} + +// ============================================================================ +// Additional Snapshot Operations +// ============================================================================ + +// LockSnapshot locks a snapshot to prevent deletion. +// Returns a channel that receives exactly one DeleteResult then closes. +func LockSnapshot(creds *Credentials, snapshotID string) <-chan DeleteResult { + resultChan := make(chan DeleteResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/snapshots/%s/lock", snapshotID), creds, map[string]interface{}{}) + resultChan <- DeleteResult{Data: response, Err: err} + }() + + return resultChan +} + +// UnlockSnapshot unlocks a previously locked snapshot. +// Returns a channel that receives exactly one DeleteResult then closes. +func UnlockSnapshot(creds *Credentials, snapshotID string) <-chan DeleteResult { + resultChan := make(chan DeleteResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", fmt.Sprintf("/snapshots/%s/unlock", snapshotID), creds, map[string]interface{}{}) + resultChan <- DeleteResult{Data: response, Err: err} + }() + + return resultChan +} + +// CloneSnapshotOptions contains optional parameters for snapshot cloning. +type CloneSnapshotOptions struct { + Name string // Name for the cloned resource + Shell string // Shell to use (for session clones) + Ports []int // Ports to expose (for service clones) +} + +// CloneSnapshotResult contains the result of cloning a snapshot. +type CloneSnapshotResult struct { + Data map[string]interface{} + Err error +} + +// CloneSnapshot clones a snapshot into a new session or service. +// Returns a channel that receives exactly one CloneSnapshotResult then closes. +// +// Args: +// +// creds: API credentials +// snapshotID: Snapshot ID to clone +// cloneType: "session" or "service" +// opts: Optional clone configuration (can be nil) +func CloneSnapshot(creds *Credentials, snapshotID, cloneType string, opts *CloneSnapshotOptions) <-chan CloneSnapshotResult { + resultChan := make(chan CloneSnapshotResult, 1) + + go func() { + defer close(resultChan) + + data := map[string]interface{}{ + "type": cloneType, + } + + if opts != nil { + if opts.Name != "" { + data["name"] = opts.Name + } + if opts.Shell != "" { + data["shell"] = opts.Shell + } + if opts.Ports != nil { + data["ports"] = opts.Ports + } + } + + response, err := makeRequest("POST", fmt.Sprintf("/snapshots/%s/clone", snapshotID), creds, data) + resultChan <- CloneSnapshotResult{Data: response, Err: err} + }() + + return resultChan +} + +// ============================================================================ +// Key Validation +// ============================================================================ + +// ValidateKeysResult contains the result of validating API keys. +type ValidateKeysResult struct { + Data map[string]interface{} + Err error +} + +// ValidateKeys validates the API credentials with the server. +// Returns a channel that receives exactly one ValidateKeysResult then closes. +// Returns account information if valid, error if invalid. +func ValidateKeys(creds *Credentials) <-chan ValidateKeysResult { + resultChan := make(chan ValidateKeysResult, 1) + + go func() { + defer close(resultChan) + + response, err := makeRequest("POST", "/keys/validate", creds, map[string]interface{}{}) + resultChan <- ValidateKeysResult{Data: response, Err: err} + }() + + return resultChan +} diff --git a/clients/go/sync/src/un.go b/clients/go/sync/src/un.go index 74b53bd..f4c6825 100644 --- a/clients/go/sync/src/un.go +++ b/clients/go/sync/src/un.go @@ -649,3 +649,363 @@ func RestoreSnapshot(creds *Credentials, snapshotID string) (map[string]interfac func DeleteSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { return makeRequest("DELETE", fmt.Sprintf("/snapshots/%s", snapshotID), creds, nil) } + +// ============================================================================ +// Session Operations +// ============================================================================ + +// SessionOptions contains optional parameters for session creation. +type SessionOptions struct { + NetworkMode string // "zerotrust" (default) or "semitrusted" + Shell string // Shell to use (e.g., "bash", "python3") + TTL int // Time-to-live in seconds (default: 3600) + VCPU int // Number of virtual CPUs (default: 1) + Multiplexer string // Multiplexer to use (e.g., "tmux") +} + +// ListSessions lists all active sessions for the authenticated account. +func ListSessions(creds *Credentials) ([]map[string]interface{}, error) { + response, err := makeRequest("GET", "/sessions", creds, nil) + if err != nil { + return nil, err + } + + if sessions, ok := response["sessions"].([]interface{}); ok { + result := make([]map[string]interface{}, len(sessions)) + for i, session := range sessions { + if m, ok := session.(map[string]interface{}); ok { + result[i] = m + } + } + return result, nil + } + + return []map[string]interface{}{}, nil +} + +// GetSession gets details of a specific session. +func GetSession(creds *Credentials, sessionID string) (map[string]interface{}, error) { + return makeRequest("GET", fmt.Sprintf("/sessions/%s", sessionID), creds, nil) +} + +// CreateSession creates a new interactive session. +// +// Args: +// +// creds: API credentials +// opts: Optional session configuration (can be nil for defaults) +// +// Returns: +// +// Session info including session_id and container_name +func CreateSession(creds *Credentials, opts *SessionOptions) (map[string]interface{}, error) { + data := make(map[string]interface{}) + + if opts != nil { + if opts.NetworkMode != "" { + data["network_mode"] = opts.NetworkMode + } + if opts.Shell != "" { + data["shell"] = opts.Shell + } + if opts.TTL > 0 { + data["ttl"] = opts.TTL + } + if opts.VCPU > 0 { + data["vcpu"] = opts.VCPU + } + if opts.Multiplexer != "" { + data["multiplexer"] = opts.Multiplexer + } + } + + return makeRequest("POST", "/sessions", creds, data) +} + +// DeleteSession terminates a session. +func DeleteSession(creds *Credentials, sessionID string) (map[string]interface{}, error) { + return makeRequest("DELETE", fmt.Sprintf("/sessions/%s", sessionID), creds, nil) +} + +// FreezeSession freezes a session (pauses execution, preserves state). +func FreezeSession(creds *Credentials, sessionID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/sessions/%s/freeze", sessionID), creds, map[string]interface{}{}) +} + +// UnfreezeSession unfreezes a previously frozen session. +func UnfreezeSession(creds *Credentials, sessionID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/sessions/%s/unfreeze", sessionID), creds, map[string]interface{}{}) +} + +// BoostSession increases the vCPU allocation for a session. +// +// Args: +// +// creds: API credentials +// sessionID: Session ID to boost +// vcpu: Number of vCPUs (2, 4, 8, etc.) +func BoostSession(creds *Credentials, sessionID string, vcpu int) (map[string]interface{}, error) { + data := map[string]interface{}{ + "vcpu": vcpu, + } + return makeRequest("POST", fmt.Sprintf("/sessions/%s/boost", sessionID), creds, data) +} + +// UnboostSession resets the vCPU allocation for a session to default. +func UnboostSession(creds *Credentials, sessionID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/sessions/%s/unboost", sessionID), creds, map[string]interface{}{}) +} + +// ShellSession executes a command in a session's shell. +// +// Note: For interactive shell access, use the WebSocket-based shell endpoint. +// This function is for executing single commands. +func ShellSession(creds *Credentials, sessionID, command string) (map[string]interface{}, error) { + data := map[string]interface{}{ + "command": command, + } + return makeRequest("POST", fmt.Sprintf("/sessions/%s/shell", sessionID), creds, data) +} + +// ============================================================================ +// Service Operations +// ============================================================================ + +// ServiceOptions contains optional parameters for service creation. +type ServiceOptions struct { + NetworkMode string // "zerotrust" (default) or "semitrusted" + Shell string // Shell to use for bootstrap + VCPU int // Number of virtual CPUs +} + +// ServiceUpdateOptions contains optional parameters for service updates. +type ServiceUpdateOptions struct { + VCPU int // Number of virtual CPUs +} + +// ListServices lists all services for the authenticated account. +func ListServices(creds *Credentials) ([]map[string]interface{}, error) { + response, err := makeRequest("GET", "/services", creds, nil) + if err != nil { + return nil, err + } + + if services, ok := response["services"].([]interface{}); ok { + result := make([]map[string]interface{}, len(services)) + for i, service := range services { + if m, ok := service.(map[string]interface{}); ok { + result[i] = m + } + } + return result, nil + } + + return []map[string]interface{}{}, nil +} + +// CreateService creates a new persistent service. +// +// Args: +// +// creds: API credentials +// name: Service name +// ports: Array of port numbers to expose +// bootstrap: Bootstrap script to run on service start +// opts: Optional service configuration (can be nil for defaults) +func CreateService(creds *Credentials, name string, ports []int, bootstrap string, opts *ServiceOptions) (map[string]interface{}, error) { + data := map[string]interface{}{ + "name": name, + "ports": ports, + "bootstrap": bootstrap, + } + + if opts != nil { + if opts.NetworkMode != "" { + data["network_mode"] = opts.NetworkMode + } + if opts.Shell != "" { + data["shell"] = opts.Shell + } + if opts.VCPU > 0 { + data["vcpu"] = opts.VCPU + } + } + + return makeRequest("POST", "/services", creds, data) +} + +// GetService gets details of a specific service. +func GetService(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("GET", fmt.Sprintf("/services/%s", serviceID), creds, nil) +} + +// UpdateService updates a service's configuration. +func UpdateService(creds *Credentials, serviceID string, opts *ServiceUpdateOptions) (map[string]interface{}, error) { + data := make(map[string]interface{}) + if opts != nil { + if opts.VCPU > 0 { + data["vcpu"] = opts.VCPU + } + } + return makeRequest("PATCH", fmt.Sprintf("/services/%s", serviceID), creds, data) +} + +// DeleteService destroys a service. +func DeleteService(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("DELETE", fmt.Sprintf("/services/%s", serviceID), creds, nil) +} + +// FreezeService freezes a service (pauses execution, preserves state). +func FreezeService(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/services/%s/freeze", serviceID), creds, map[string]interface{}{}) +} + +// UnfreezeService unfreezes a previously frozen service. +func UnfreezeService(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/services/%s/unfreeze", serviceID), creds, map[string]interface{}{}) +} + +// LockService locks a service to prevent modifications or deletion. +func LockService(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/services/%s/lock", serviceID), creds, map[string]interface{}{}) +} + +// UnlockService unlocks a previously locked service. +func UnlockService(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/services/%s/unlock", serviceID), creds, map[string]interface{}{}) +} + +// GetServiceLogs retrieves logs from a service. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// all: If true, returns all logs; if false, returns only recent logs +func GetServiceLogs(creds *Credentials, serviceID string, all bool) (map[string]interface{}, error) { + path := fmt.Sprintf("/services/%s/logs", serviceID) + if all { + path = fmt.Sprintf("/services/%s/logs?all=true", serviceID) + } + return makeRequest("GET", path, creds, nil) +} + +// GetServiceEnv retrieves the environment variable names for a service. +// Note: Values are not returned for security; use ExportServiceEnv for full export. +func GetServiceEnv(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("GET", fmt.Sprintf("/services/%s/env", serviceID), creds, nil) +} + +// SetServiceEnv sets environment variables for a service. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// env: Map of environment variable names to values +func SetServiceEnv(creds *Credentials, serviceID string, env map[string]string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/services/%s/env", serviceID), creds, env) +} + +// DeleteServiceEnv deletes environment variables from a service. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// keys: List of environment variable names to delete (nil deletes all) +func DeleteServiceEnv(creds *Credentials, serviceID string, keys []string) (map[string]interface{}, error) { + var data interface{} + if keys != nil { + data = map[string]interface{}{"keys": keys} + } + return makeRequest("DELETE", fmt.Sprintf("/services/%s/env", serviceID), creds, data) +} + +// ExportServiceEnv exports all environment variables for a service. +// Returns the full .env format content with values. +func ExportServiceEnv(creds *Credentials, serviceID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/services/%s/env/export", serviceID), creds, map[string]interface{}{}) +} + +// RedeployService redeploys a service with optional new bootstrap script. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// bootstrap: New bootstrap script (empty string to keep existing) +func RedeployService(creds *Credentials, serviceID string, bootstrap string) (map[string]interface{}, error) { + data := make(map[string]interface{}) + if bootstrap != "" { + data["bootstrap"] = bootstrap + } + return makeRequest("POST", fmt.Sprintf("/services/%s/redeploy", serviceID), creds, data) +} + +// ExecuteInService executes a command in a running service. +func ExecuteInService(creds *Credentials, serviceID, command string) (map[string]interface{}, error) { + data := map[string]interface{}{ + "command": command, + } + return makeRequest("POST", fmt.Sprintf("/services/%s/execute", serviceID), creds, data) +} + +// ============================================================================ +// Additional Snapshot Operations +// ============================================================================ + +// LockSnapshot locks a snapshot to prevent deletion. +func LockSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/snapshots/%s/lock", snapshotID), creds, map[string]interface{}{}) +} + +// UnlockSnapshot unlocks a previously locked snapshot. +func UnlockSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { + return makeRequest("POST", fmt.Sprintf("/snapshots/%s/unlock", snapshotID), creds, map[string]interface{}{}) +} + +// CloneSnapshotOptions contains optional parameters for snapshot cloning. +type CloneSnapshotOptions struct { + Name string // Name for the cloned resource + Shell string // Shell to use (for session clones) + Ports []int // Ports to expose (for service clones) +} + +// CloneSnapshot clones a snapshot into a new session or service. +// +// Args: +// +// creds: API credentials +// snapshotID: Snapshot ID to clone +// cloneType: "session" or "service" +// opts: Optional clone configuration (can be nil) +func CloneSnapshot(creds *Credentials, snapshotID, cloneType string, opts *CloneSnapshotOptions) (map[string]interface{}, error) { + data := map[string]interface{}{ + "type": cloneType, + } + + if opts != nil { + if opts.Name != "" { + data["name"] = opts.Name + } + if opts.Shell != "" { + data["shell"] = opts.Shell + } + if opts.Ports != nil { + data["ports"] = opts.Ports + } + } + + return makeRequest("POST", fmt.Sprintf("/snapshots/%s/clone", snapshotID), creds, data) +} + +// ============================================================================ +// Key Validation +// ============================================================================ + +// ValidateKeys validates the API credentials with the server. +// Returns account information if valid, error if invalid. +func ValidateKeys(creds *Credentials) (map[string]interface{}, error) { + return makeRequest("POST", "/keys/validate", creds, map[string]interface{}{}) +} diff --git a/clients/java/async/src/UnsandboxAsync.java b/clients/java/async/src/UnsandboxAsync.java index fb69146..67beecf 100644 --- a/clients/java/async/src/UnsandboxAsync.java +++ b/clients/java/async/src/UnsandboxAsync.java @@ -1088,6 +1088,694 @@ public class UnsandboxAsync { return makeRequest("DELETE", "/snapshots/" + snapshotId, creds[0], creds[1], null); } + // ======================================================================== + // Session API Methods + // ======================================================================== + + /** + * List all active sessions for the authenticated account. + * + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing list of session maps + */ + @SuppressWarnings("unchecked") + public static CompletableFuture>> listSessions( + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/sessions", creds[0], creds[1], null) + .thenApply(response -> { + Object sessions = response.get("sessions"); + if (sessions instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) sessions) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + }); + } + + /** + * Get details of a specific session. + * + * @param sessionId Session ID to retrieve + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing session details map + */ + public static CompletableFuture> getSession( + String sessionId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/sessions/" + sessionId, creds[0], creds[1], null); + } + + /** + * Create a new interactive session. + * + * @param language Programming language/shell for the session (e.g., "bash", "python3") + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @param opts Optional parameters: network_mode, ttl, shell, multiplexer, vcpu + * @return CompletableFuture containing response map with session_id, container_name + */ + public static CompletableFuture> createSession( + String language, + String publicKey, + String secretKey, + Map opts + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + + Map data = new LinkedHashMap<>(); + data.put("network_mode", "zerotrust"); + data.put("ttl", 3600); + if (language != null && !language.isEmpty()) { + data.put("shell", language); + } + if (opts != null) { + data.putAll(opts); + } + + return makeRequest("POST", "/sessions", creds[0], creds[1], data); + } + + /** + * Delete (terminate) a session. + * + * @param sessionId Session ID to terminate + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with termination confirmation + */ + public static CompletableFuture> deleteSession( + String sessionId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("DELETE", "/sessions/" + sessionId, creds[0], creds[1], null); + } + + /** + * Freeze a session (pause execution, reduce resource consumption). + * + * @param sessionId Session ID to freeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with freeze confirmation + */ + public static CompletableFuture> freezeSession( + String sessionId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/sessions/" + sessionId + "/freeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unfreeze a session (resume execution). + * + * @param sessionId Session ID to unfreeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with unfreeze confirmation + */ + public static CompletableFuture> unfreezeSession( + String sessionId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/sessions/" + sessionId + "/unfreeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Boost a session's resources (increase vCPU and memory). + * + * @param sessionId Session ID to boost + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with boost confirmation + */ + public static CompletableFuture> boostSession( + String sessionId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("vcpu", 2); + return makeRequest("POST", "/sessions/" + sessionId + "/boost", creds[0], creds[1], data); + } + + /** + * Remove boost from a session (return to base resources). + * + * @param sessionId Session ID to unboost + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with unboost confirmation + */ + public static CompletableFuture> unboostSession( + String sessionId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/sessions/" + sessionId + "/unboost", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Execute a shell command in an existing session. + * + * @param sessionId Session ID to execute command in + * @param command Shell command to execute + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with command output + */ + public static CompletableFuture> shellSession( + String sessionId, + String command, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("command", command); + return makeRequest("POST", "/sessions/" + sessionId + "/shell", creds[0], creds[1], data); + } + + // ======================================================================== + // Service API Methods + // ======================================================================== + + /** + * List all services for the authenticated account. + * + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing list of service maps + */ + @SuppressWarnings("unchecked") + public static CompletableFuture>> listServices( + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/services", creds[0], creds[1], null) + .thenApply(response -> { + Object services = response.get("services"); + if (services instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) services) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + }); + } + + /** + * Create a new persistent service. + * + * @param name Service name + * @param ports Comma-separated list of ports to expose (e.g., "80,443") + * @param bootstrap Bootstrap script or URL to run on service creation + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with service_id + */ + public static CompletableFuture> createService( + String name, + String ports, + String bootstrap, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + + Map data = new LinkedHashMap<>(); + data.put("name", name); + if (ports != null && !ports.isEmpty()) { + List portList = new ArrayList<>(); + for (String p : ports.split(",")) { + try { + portList.add(Integer.parseInt(p.trim())); + } catch (NumberFormatException e) { + // Skip invalid port + } + } + data.put("ports", portList); + } + if (bootstrap != null && !bootstrap.isEmpty()) { + if (bootstrap.startsWith("http://") || bootstrap.startsWith("https://")) { + data.put("bootstrap_url", bootstrap); + } else { + data.put("bootstrap", bootstrap); + } + } + + return makeRequest("POST", "/services", creds[0], creds[1], data); + } + + /** + * Get details of a specific service. + * + * @param serviceId Service ID to retrieve + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing service details map + */ + public static CompletableFuture> getService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/services/" + serviceId, creds[0], creds[1], null); + } + + /** + * Update a service's configuration. + * + * @param serviceId Service ID to update + * @param opts Update options (e.g., vcpu for resizing) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with update confirmation + */ + public static CompletableFuture> updateService( + String serviceId, + Map opts, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], opts); + } + + /** + * Delete (destroy) a service. + * + * @param serviceId Service ID to destroy + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with deletion confirmation + */ + public static CompletableFuture> deleteService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("DELETE", "/services/" + serviceId, creds[0], creds[1], null); + } + + /** + * Freeze a service (pause execution, reduce resource consumption). + * + * @param serviceId Service ID to freeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with freeze confirmation + */ + public static CompletableFuture> freezeService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/freeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unfreeze a service (resume execution). + * + * @param serviceId Service ID to unfreeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with unfreeze confirmation + */ + public static CompletableFuture> unfreezeService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/unfreeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Lock a service (prevent modifications and termination). + * + * @param serviceId Service ID to lock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with lock confirmation + */ + public static CompletableFuture> lockService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/lock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unlock a service (allow modifications and termination). + * + * @param serviceId Service ID to unlock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with unlock confirmation + */ + public static CompletableFuture> unlockService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Get bootstrap logs for a service. + * + * @param serviceId Service ID to get logs for + * @param all If true, return all logs; if false, return recent logs only + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with logs + */ + public static CompletableFuture> getServiceLogs( + String serviceId, + boolean all, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + String path = "/services/" + serviceId + "/logs"; + if (all) { + path += "?all=true"; + } + return makeRequest("GET", path, creds[0], creds[1], null); + } + + /** + * Get environment vault status for a service. + * + * @param serviceId Service ID to get env status for + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with has_vault, count, updated_at + */ + public static CompletableFuture> getServiceEnv( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/services/" + serviceId + "/env", creds[0], creds[1], null); + } + + /** + * Set environment vault for a service. + * + * @param serviceId Service ID to set env for + * @param env Environment variables map (KEY=VALUE pairs) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with set confirmation + */ + public static CompletableFuture> setServiceEnv( + String serviceId, + Map env, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("env", env); + return makeRequest("POST", "/services/" + serviceId + "/env", creds[0], creds[1], data); + } + + /** + * Delete environment variables from a service's vault. + * + * @param serviceId Service ID to delete env from + * @param keys List of keys to delete (null to delete entire vault) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with deletion confirmation + */ + public static CompletableFuture> deleteServiceEnv( + String serviceId, + List keys, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + String path = "/services/" + serviceId + "/env"; + if (keys != null && !keys.isEmpty()) { + path += "?keys=" + String.join(",", keys); + } + return makeRequest("DELETE", path, creds[0], creds[1], null); + } + + /** + * Export environment vault for a service (returns decrypted values). + * + * @param serviceId Service ID to export env from + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with exported environment variables + */ + public static CompletableFuture> exportServiceEnv( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/env/export", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Redeploy a service (re-run bootstrap script). + * + * @param serviceId Service ID to redeploy + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with redeploy confirmation + */ + public static CompletableFuture> redeployService( + String serviceId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/redeploy", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Execute a command in a service container. + * + * @param serviceId Service ID to execute command in + * @param command Command to execute + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with stdout, stderr, exit_code + */ + public static CompletableFuture> executeInService( + String serviceId, + String command, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("command", command); + return makeRequest("POST", "/services/" + serviceId + "/execute", creds[0], creds[1], data); + } + + // ======================================================================== + // Additional Snapshot API Methods + // ======================================================================== + + /** + * Lock a snapshot (prevent deletion). + * + * @param snapshotId Snapshot ID to lock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with lock confirmation + */ + public static CompletableFuture> lockSnapshot( + String snapshotId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/snapshots/" + snapshotId + "/lock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unlock a snapshot (allow deletion). + * + * @param snapshotId Snapshot ID to unlock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with unlock confirmation + */ + public static CompletableFuture> unlockSnapshot( + String snapshotId, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/snapshots/" + snapshotId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Clone a snapshot to create a new snapshot with a different name. + * + * @param snapshotId Snapshot ID to clone + * @param name Name for the cloned snapshot + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return CompletableFuture containing response map with new snapshot_id + */ + public static CompletableFuture> cloneSnapshot( + String snapshotId, + String name, + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + if (name != null && !name.isEmpty()) { + data.put("name", name); + } + return makeRequest("POST", "/snapshots/" + snapshotId + "/clone", creds[0], creds[1], data); + } + + // ======================================================================== + // Key Validation API + // ======================================================================== + + /** + * Validate API key credentials. + * + * @param publicKey API public key to validate + * @param secretKey API secret key to validate + * @return CompletableFuture containing response map with validation result (valid, tier, etc.) + */ + public static CompletableFuture> validateKeys( + String publicKey, + String secretKey + ) { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/keys/validate", creds[0], creds[1], new LinkedHashMap<>()); + } + + // ======================================================================== + // HTTP Request Helpers + // ======================================================================== + + /** + * Make an HTTP request with a specified method (supports PATCH). + */ + private static CompletableFuture> makeRequestWithMethod( + String method, + String path, + String publicKey, + String secretKey, + Map data + ) { + return CompletableFuture.supplyAsync(() -> { + try { + return makeRequestSyncWithMethod(method, path, publicKey, secretKey, data); + } catch (IOException e) { + throw new CompletionException(e); + } + }, executor); + } + + private static Map makeRequestSyncWithMethod( + String method, + String path, + String publicKey, + String secretKey, + Map data + ) throws IOException { + String url = API_BASE + path; + long timestamp = System.currentTimeMillis() / 1000; + String body = (data != null) ? mapToJson(data) : ""; + + String signature = signRequest(secretKey, timestamp, method, path, data != null ? body : null); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(DEFAULT_TIMEOUT_MS); + conn.setReadTimeout(DEFAULT_TIMEOUT_MS); + + conn.setRequestProperty("Authorization", "Bearer " + publicKey); + conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); + conn.setRequestProperty("X-Signature", signature); + conn.setRequestProperty("Content-Type", "application/json"); + + if (data != null) { + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + + int responseCode = conn.getResponseCode(); + String responseBody; + + InputStream inputStream = (responseCode >= 200 && responseCode < 300) + ? conn.getInputStream() + : conn.getErrorStream(); + + if (inputStream == null) { + responseBody = ""; + } else { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + responseBody = sb.toString(); + } + } + + if (responseCode < 200 || responseCode >= 300) { + throw new ApiException( + "API request failed with status " + responseCode, + responseCode, + responseBody + ); + } + + return parseJson(responseBody); + } + /** * Shutdown the executor services used by this class. * Call this when your application is shutting down. diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index 8462a64..9887ad5 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -1050,4 +1050,761 @@ public class Un { String[] creds = resolveCredentials(publicKey, secretKey); return makeRequest("DELETE", "/snapshots/" + snapshotId, creds[0], creds[1], null); } + + // ======================================================================== + // Session API Methods + // ======================================================================== + + /** + * List all active sessions for the authenticated account. + * + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return List of session maps containing id, container_name, shell, status, remaining_ttl + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + @SuppressWarnings("unchecked") + public static List> listSessions( + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map response = makeRequest("GET", "/sessions", creds[0], creds[1], null); + Object sessions = response.get("sessions"); + if (sessions instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) sessions) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + } + + /** + * Get details of a specific session. + * + * @param sessionId Session ID to retrieve + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Session details map + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map getSession( + String sessionId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/sessions/" + sessionId, creds[0], creds[1], null); + } + + /** + * Create a new interactive session. + * + * @param language Programming language/shell for the session (e.g., "bash", "python3") + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @param opts Optional parameters: network_mode, ttl, shell, multiplexer, vcpu + * @return Response map containing session_id, container_name + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map createSession( + String language, + String publicKey, + String secretKey, + Map opts + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + + Map data = new LinkedHashMap<>(); + data.put("network_mode", "zerotrust"); + data.put("ttl", 3600); + if (language != null && !language.isEmpty()) { + data.put("shell", language); + } + if (opts != null) { + data.putAll(opts); + } + + return makeRequest("POST", "/sessions", creds[0], creds[1], data); + } + + /** + * Delete (terminate) a session. + * + * @param sessionId Session ID to terminate + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with termination confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map deleteSession( + String sessionId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("DELETE", "/sessions/" + sessionId, creds[0], creds[1], null); + } + + /** + * Freeze a session (pause execution, reduce resource consumption). + * + * @param sessionId Session ID to freeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with freeze confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map freezeSession( + String sessionId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/sessions/" + sessionId + "/freeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unfreeze a session (resume execution). + * + * @param sessionId Session ID to unfreeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with unfreeze confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map unfreezeSession( + String sessionId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/sessions/" + sessionId + "/unfreeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Boost a session's resources (increase vCPU and memory). + * + * @param sessionId Session ID to boost + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with boost confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map boostSession( + String sessionId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("vcpu", 2); + return makeRequest("POST", "/sessions/" + sessionId + "/boost", creds[0], creds[1], data); + } + + /** + * Remove boost from a session (return to base resources). + * + * @param sessionId Session ID to unboost + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with unboost confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map unboostSession( + String sessionId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/sessions/" + sessionId + "/unboost", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Execute a shell command in an existing session. + * + * @param sessionId Session ID to execute command in + * @param command Shell command to execute + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with command output + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map shellSession( + String sessionId, + String command, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("command", command); + return makeRequest("POST", "/sessions/" + sessionId + "/shell", creds[0], creds[1], data); + } + + // ======================================================================== + // Service API Methods + // ======================================================================== + + /** + * List all services for the authenticated account. + * + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return List of service maps containing id, name, state, ports, disk_used + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + @SuppressWarnings("unchecked") + public static List> listServices( + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map response = makeRequest("GET", "/services", creds[0], creds[1], null); + Object services = response.get("services"); + if (services instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) services) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + } + + /** + * Create a new persistent service. + * + * @param name Service name + * @param ports Comma-separated list of ports to expose (e.g., "80,443") + * @param bootstrap Bootstrap script or URL to run on service creation + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing service_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map createService( + String name, + String ports, + String bootstrap, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + + Map data = new LinkedHashMap<>(); + data.put("name", name); + if (ports != null && !ports.isEmpty()) { + // Parse ports string into list + List portList = new ArrayList<>(); + for (String p : ports.split(",")) { + try { + portList.add(Integer.parseInt(p.trim())); + } catch (NumberFormatException e) { + // Skip invalid port + } + } + data.put("ports", portList); + } + if (bootstrap != null && !bootstrap.isEmpty()) { + if (bootstrap.startsWith("http://") || bootstrap.startsWith("https://")) { + data.put("bootstrap_url", bootstrap); + } else { + data.put("bootstrap", bootstrap); + } + } + + return makeRequest("POST", "/services", creds[0], creds[1], data); + } + + /** + * Get details of a specific service. + * + * @param serviceId Service ID to retrieve + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Service details map + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map getService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/services/" + serviceId, creds[0], creds[1], null); + } + + /** + * Update a service's configuration. + * + * @param serviceId Service ID to update + * @param opts Update options (e.g., vcpu for resizing) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with update confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map updateService( + String serviceId, + Map opts, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], opts); + } + + /** + * Delete (destroy) a service. + * + * @param serviceId Service ID to destroy + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with deletion confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map deleteService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("DELETE", "/services/" + serviceId, creds[0], creds[1], null); + } + + /** + * Freeze a service (pause execution, reduce resource consumption). + * + * @param serviceId Service ID to freeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with freeze confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map freezeService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/freeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unfreeze a service (resume execution). + * + * @param serviceId Service ID to unfreeze + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with unfreeze confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map unfreezeService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/unfreeze", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Lock a service (prevent modifications and termination). + * + * @param serviceId Service ID to lock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with lock confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map lockService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/lock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unlock a service (allow modifications and termination). + * + * @param serviceId Service ID to unlock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with unlock confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map unlockService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Get bootstrap logs for a service. + * + * @param serviceId Service ID to get logs for + * @param all If true, return all logs; if false, return recent logs only + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing logs + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map getServiceLogs( + String serviceId, + boolean all, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + String path = "/services/" + serviceId + "/logs"; + if (all) { + path += "?all=true"; + } + return makeRequest("GET", path, creds[0], creds[1], null); + } + + /** + * Get environment vault status for a service. + * + * @param serviceId Service ID to get env status for + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing has_vault, count, updated_at + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map getServiceEnv( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/services/" + serviceId + "/env", creds[0], creds[1], null); + } + + /** + * Set environment vault for a service. + * + * @param serviceId Service ID to set env for + * @param env Environment variables map (KEY=VALUE pairs) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with set confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map setServiceEnv( + String serviceId, + Map env, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("env", env); + return makeRequest("POST", "/services/" + serviceId + "/env", creds[0], creds[1], data); + } + + /** + * Delete environment variables from a service's vault. + * + * @param serviceId Service ID to delete env from + * @param keys List of keys to delete (null to delete entire vault) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with deletion confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map deleteServiceEnv( + String serviceId, + List keys, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + String path = "/services/" + serviceId + "/env"; + if (keys != null && !keys.isEmpty()) { + // URL encode keys parameter + path += "?keys=" + String.join(",", keys); + } + return makeRequest("DELETE", path, creds[0], creds[1], null); + } + + /** + * Export environment vault for a service (returns decrypted values). + * + * @param serviceId Service ID to export env from + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing exported environment variables + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map exportServiceEnv( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/env/export", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Redeploy a service (re-run bootstrap script). + * + * @param serviceId Service ID to redeploy + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with redeploy confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map redeployService( + String serviceId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/services/" + serviceId + "/redeploy", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Execute a command in a service container. + * + * @param serviceId Service ID to execute command in + * @param command Command to execute + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing stdout, stderr, exit_code + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map executeInService( + String serviceId, + String command, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("command", command); + return makeRequest("POST", "/services/" + serviceId + "/execute", creds[0], creds[1], data); + } + + // ======================================================================== + // Additional Snapshot API Methods + // ======================================================================== + + /** + * Lock a snapshot (prevent deletion). + * + * @param snapshotId Snapshot ID to lock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with lock confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map lockSnapshot( + String snapshotId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/snapshots/" + snapshotId + "/lock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unlock a snapshot (allow deletion). + * + * @param snapshotId Snapshot ID to unlock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with unlock confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map unlockSnapshot( + String snapshotId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/snapshots/" + snapshotId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Clone a snapshot to create a new snapshot with a different name. + * + * @param snapshotId Snapshot ID to clone + * @param name Name for the cloned snapshot + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing new snapshot_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map cloneSnapshot( + String snapshotId, + String name, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + if (name != null && !name.isEmpty()) { + data.put("name", name); + } + return makeRequest("POST", "/snapshots/" + snapshotId + "/clone", creds[0], creds[1], data); + } + + // ======================================================================== + // Key Validation API + // ======================================================================== + + /** + * Validate API key credentials. + * + * @param publicKey API public key to validate + * @param secretKey API secret key to validate + * @return Response map with validation result (valid, tier, etc.) + * @throws IOException on network errors + * @throws ApiException if API returns an error (including invalid credentials) + */ + public static Map validateKeys( + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + // Note: validateKeys uses POST to /keys/validate + return makeRequest("POST", "/keys/validate", creds[0], creds[1], new LinkedHashMap<>()); + } + + // ======================================================================== + // HTTP Request Helpers + // ======================================================================== + + /** + * Make an HTTP request with a specified method (supports PATCH). + */ + private static Map makeRequestWithMethod( + String method, + String path, + String publicKey, + String secretKey, + Map data + ) throws IOException { + String url = API_BASE + path; + long timestamp = System.currentTimeMillis() / 1000; + String body = (data != null) ? mapToJson(data) : ""; + + String signature = signRequest(secretKey, timestamp, method, path, data != null ? body : null); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(DEFAULT_TIMEOUT_MS); + conn.setReadTimeout(DEFAULT_TIMEOUT_MS); + + conn.setRequestProperty("Authorization", "Bearer " + publicKey); + conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); + conn.setRequestProperty("X-Signature", signature); + conn.setRequestProperty("Content-Type", "application/json"); + + if (data != null) { + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + + int responseCode = conn.getResponseCode(); + String responseBody; + + InputStream inputStream = (responseCode >= 200 && responseCode < 300) + ? conn.getInputStream() + : conn.getErrorStream(); + + if (inputStream == null) { + responseBody = ""; + } else { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + responseBody = sb.toString(); + } + } + + if (responseCode < 200 || responseCode >= 300) { + throw new ApiException( + "API request failed with status " + responseCode, + responseCode, + responseBody + ); + } + + return parseJson(responseBody); + } } diff --git a/clients/javascript/async/src/un_async.js b/clients/javascript/async/src/un_async.js index 8d16cc0..5027997 100644 --- a/clients/javascript/async/src/un_async.js +++ b/clients/javascript/async/src/un_async.js @@ -5,19 +5,22 @@ * * Library Usage: * import { - * executeCode, - * executeAsync, - * getJob, - * waitForJob, - * cancelJob, - * listJobs, - * getLanguages, - * detectLanguage, - * sessionSnapshot, - * serviceSnapshot, - * listSnapshots, - * restoreSnapshot, - * deleteSnapshot, + * // Code execution + * executeCode, executeAsync, getJob, waitForJob, cancelJob, listJobs, + * getLanguages, detectLanguage, + * // Session management + * listSessions, getSession, createSession, deleteSession, + * freezeSession, unfreezeSession, boostSession, unboostSession, shellSession, + * // Service management + * listServices, createService, getService, updateService, deleteService, + * freezeService, unfreezeService, lockService, unlockService, + * getServiceLogs, getServiceEnv, setServiceEnv, deleteServiceEnv, + * exportServiceEnv, redeployService, executeInService, + * // Snapshot management + * sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, + * deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot, + * // Key validation + * validateKeys, * } from './un_async.js'; * * // Execute code (awaits until completion) @@ -220,7 +223,8 @@ async function makeRequest(method, urlPath, publicKey, secretKey, data) { signal: AbortSignal.timeout(120000), // 120 seconds timeout }; - if (method === 'POST' && body) { + // Add body for methods that support it + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) { options.body = body; } @@ -581,8 +585,491 @@ async function deleteSnapshot(snapshotId, publicKey, secretKey) { return makeRequest('DELETE', `/snapshots/${snapshotId}`, publicKey, secretKey); } +// ============================================================================ +// Session Management Functions +// ============================================================================ + +/** + * List all active sessions. + * + * Returns: Promise (list of session objects) + */ +async function listSessions(publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const response = await makeRequest('GET', '/sessions', publicKey, secretKey); + return response.sessions || []; +} + +/** + * Get details of a specific session. + * + * Args: + * sessionId: Session ID to retrieve + * + * Returns: Promise (session details) + */ +async function getSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/sessions/${sessionId}`, publicKey, secretKey); +} + +/** + * Create a new interactive session. + * + * Args: + * language: Optional programming language/shell (default: "bash") + * opts: Optional settings: + * - networkMode: "zerotrust" (default) or "semitrusted" + * - shell: Shell to use (e.g., "python3", "bash") + * - multiplexer: "tmux", "screen", or null + * - vcpu: Number of vCPUs (1-8) + * - ttl: Time-to-live in seconds + * + * Returns: Promise (session info with session_id, container_name) + */ +async function createSession(language, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = { + network_mode: opts.networkMode || 'zerotrust', + ttl: opts.ttl || 3600, + }; + if (language) data.shell = language; + if (opts.shell) data.shell = opts.shell; + if (opts.multiplexer) data.multiplexer = opts.multiplexer; + if (opts.vcpu && opts.vcpu > 1) data.vcpu = opts.vcpu; + + return makeRequest('POST', '/sessions', publicKey, secretKey, data); +} + +/** + * Delete/terminate a session. + * + * Args: + * sessionId: Session ID to terminate + * + * Returns: Promise (deletion confirmation) + */ +async function deleteSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('DELETE', `/sessions/${sessionId}`, publicKey, secretKey); +} + +/** + * Freeze a session (pause execution, preserve state). + * + * Args: + * sessionId: Session ID to freeze + * + * Returns: Promise (freeze confirmation) + */ +async function freezeSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/freeze`, publicKey, secretKey, {}); +} + +/** + * Unfreeze a session (resume execution). + * + * Args: + * sessionId: Session ID to unfreeze + * + * Returns: Promise (unfreeze confirmation) + */ +async function unfreezeSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/unfreeze`, publicKey, secretKey, {}); +} + +/** + * Boost a session's resources (increase vCPU, memory). + * + * Args: + * sessionId: Session ID to boost + * vcpu: Number of vCPUs (default: 2) + * + * Returns: Promise (boost confirmation) + */ +async function boostSession(sessionId, vcpu = 2, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/boost`, publicKey, secretKey, { vcpu }); +} + +/** + * Unboost a session (return to base resources). + * + * Args: + * sessionId: Session ID to unboost + * + * Returns: Promise (unboost confirmation) + */ +async function unboostSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/unboost`, publicKey, secretKey, {}); +} + +/** + * Execute a shell command in a session. + * + * Note: This initiates a WebSocket connection for interactive shell. + * For simple command execution, this sends the command via the shell endpoint. + * + * Args: + * sessionId: Session ID + * command: Command to execute + * + * Returns: Promise (command result) + */ +async function shellSession(sessionId, command, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/shell`, publicKey, secretKey, { command }); +} + +// ============================================================================ +// Service Management Functions +// ============================================================================ + +/** + * List all services. + * + * Returns: Promise (list of service objects) + */ +async function listServices(publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const response = await makeRequest('GET', '/services', publicKey, secretKey); + return response.services || []; +} + +/** + * Create a new service (persistent container). + * + * Args: + * name: Service name + * ports: Array of port numbers to expose (e.g., [80, 443]) + * bootstrap: Bootstrap script content or URL + * opts: Optional settings: + * - networkMode: "zerotrust" or "semitrusted" + * - vcpu: Number of vCPUs (1-8) + * - domains: Array of custom domains + * - serviceType: Service type for SRV records (minecraft, mumble, etc.) + * + * Returns: Promise (service info with service_id) + */ +async function createService(name, ports, bootstrap, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (name) data.name = name; + if (ports && ports.length > 0) data.ports = ports; + if (bootstrap) { + // If bootstrap starts with http, treat as URL, otherwise as content + if (bootstrap.startsWith('http://') || bootstrap.startsWith('https://')) { + data.bootstrap = bootstrap; + } else { + data.bootstrap_content = bootstrap; + } + } + if (opts.networkMode) data.network_mode = opts.networkMode; + if (opts.vcpu && opts.vcpu > 1) data.vcpu = opts.vcpu; + if (opts.domains) data.custom_domains = opts.domains; + if (opts.serviceType) data.service_type = opts.serviceType; + + return makeRequest('POST', '/services', publicKey, secretKey, data); +} + +/** + * Get details of a specific service. + * + * Args: + * serviceId: Service ID to retrieve + * + * Returns: Promise (service details) + */ +async function getService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/services/${serviceId}`, publicKey, secretKey); +} + +/** + * Update a service (resize vCPU/memory). + * + * Args: + * serviceId: Service ID to update + * opts: Update options: + * - vcpu: New vCPU count (1-8) + * + * Returns: Promise (update confirmation) + */ +async function updateService(serviceId, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (opts.vcpu) data.vcpu = opts.vcpu; + return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, data); +} + +/** + * Delete/destroy a service. + * + * Args: + * serviceId: Service ID to destroy + * + * Returns: Promise (deletion confirmation) + */ +async function deleteService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('DELETE', `/services/${serviceId}`, publicKey, secretKey); +} + +/** + * Freeze a service (stop container, preserve disk). + * + * Args: + * serviceId: Service ID to freeze + * + * Returns: Promise (freeze confirmation) + */ +async function freezeService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/freeze`, publicKey, secretKey, {}); +} + +/** + * Unfreeze a service (restart container). + * + * Args: + * serviceId: Service ID to unfreeze + * + * Returns: Promise (unfreeze confirmation) + */ +async function unfreezeService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/unfreeze`, publicKey, secretKey, {}); +} + +/** + * Lock a service to prevent deletion. + * + * Args: + * serviceId: Service ID to lock + * + * Returns: Promise (lock confirmation) + */ +async function lockService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/lock`, publicKey, secretKey, {}); +} + +/** + * Unlock a service to allow deletion. + * + * Args: + * serviceId: Service ID to unlock + * + * Returns: Promise (unlock confirmation) + */ +async function unlockService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/unlock`, publicKey, secretKey, {}); +} + +/** + * Get service logs. + * + * Args: + * serviceId: Service ID + * all: If true, get all logs; if false, get last ~9000 lines (default: false) + * + * Returns: Promise (log data) + */ +async function getServiceLogs(serviceId, all = false, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const path = all ? `/services/${serviceId}/logs?all=true` : `/services/${serviceId}/logs`; + return makeRequest('GET', path, publicKey, secretKey); +} + +/** + * Get service environment vault status. + * + * Args: + * serviceId: Service ID + * + * Returns: Promise (vault status with has_vault, count, updated_at) + */ +async function getServiceEnv(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/services/${serviceId}/env`, publicKey, secretKey); +} + +/** + * Set service environment vault. + * + * Args: + * serviceId: Service ID + * env: Environment content as string (KEY=VALUE format, newline separated) + * or object { KEY: "value", KEY2: "value2" } + * + * Returns: Promise (set confirmation) + */ +async function setServiceEnv(serviceId, env, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + // Convert object to KEY=VALUE format if needed + let envContent = env; + if (typeof env === 'object' && !Array.isArray(env)) { + envContent = Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + } + // Note: This endpoint uses PUT with text/plain body + // The makeRequest function sends JSON, so we need to handle this specially + return makeRequest('PUT', `/services/${serviceId}/env`, publicKey, secretKey, { content: envContent }); +} + +/** + * Delete service environment vault. + * + * Args: + * serviceId: Service ID + * keys: Optional array of specific keys to delete (deletes all if not specified) + * + * Returns: Promise (deletion confirmation) + */ +async function deleteServiceEnv(serviceId, keys = null, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = keys ? { keys } : {}; + return makeRequest('DELETE', `/services/${serviceId}/env`, publicKey, secretKey, data); +} + +/** + * Export service environment vault. + * + * Args: + * serviceId: Service ID + * + * Returns: Promise (exported environment data) + */ +async function exportServiceEnv(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/env/export`, publicKey, secretKey, {}); +} + +/** + * Redeploy a service with new bootstrap script. + * + * Args: + * serviceId: Service ID to redeploy + * bootstrap: Optional new bootstrap script content or URL + * + * Returns: Promise (redeploy confirmation) + */ +async function redeployService(serviceId, bootstrap = null, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (bootstrap) { + if (bootstrap.startsWith('http://') || bootstrap.startsWith('https://')) { + data.bootstrap = bootstrap; + } else { + data.bootstrap_content = bootstrap; + } + } + return makeRequest('POST', `/services/${serviceId}/redeploy`, publicKey, secretKey, data); +} + +/** + * Execute a command in a running service container. + * + * Args: + * serviceId: Service ID + * command: Command to execute + * timeout: Optional timeout in milliseconds (default: 30000) + * + * Returns: Promise (execution result with stdout, stderr, exit_code) + */ +async function executeInService(serviceId, command, timeout = 30000, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const response = await makeRequest('POST', `/services/${serviceId}/execute`, publicKey, secretKey, { + command, + timeout, + }); + + // If we got a job_id, poll until completion + const jobId = response.job_id; + if (jobId) { + return waitForJob(jobId, publicKey, secretKey); + } + + return response; +} + +// ============================================================================ +// Additional Snapshot Functions +// ============================================================================ + +/** + * Lock a snapshot to prevent deletion. + * + * Args: + * snapshotId: Snapshot ID to lock + * + * Returns: Promise (lock confirmation) + */ +async function lockSnapshot(snapshotId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/snapshots/${snapshotId}/lock`, publicKey, secretKey, {}); +} + +/** + * Unlock a snapshot to allow deletion. + * + * Args: + * snapshotId: Snapshot ID to unlock + * + * Returns: Promise (unlock confirmation) + */ +async function unlockSnapshot(snapshotId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/snapshots/${snapshotId}/unlock`, publicKey, secretKey, {}); +} + +/** + * Clone a snapshot to create a new session or service. + * + * Args: + * snapshotId: Snapshot ID to clone + * name: Name for the new resource + * opts: Optional settings: + * - type: "session" or "service" (default: inferred from snapshot) + * - shell: Shell for session clones + * - ports: Ports array for service clones + * + * Returns: Promise (clone result with new session_id or service_id) + */ +async function cloneSnapshot(snapshotId, name, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (name) data.name = name; + if (opts.type) data.type = opts.type; + if (opts.shell) data.shell = opts.shell; + if (opts.ports) data.ports = opts.ports; + return makeRequest('POST', `/snapshots/${snapshotId}/clone`, publicKey, secretKey, data); +} + +// ============================================================================ +// Key Validation +// ============================================================================ + +/** + * Validate API keys. + * + * Returns: Promise (validation result with valid, tier, expires_at, etc.) + */ +async function validateKeys(publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + // Note: This endpoint is on the portal (unsandbox.com), not the API + // For SDK purposes, we'll call the API endpoint if available + return makeRequest('POST', '/keys/validate', publicKey, secretKey, {}); +} + // ES Module exports export { + // Code execution executeCode, executeAsync, getJob, @@ -591,17 +1078,52 @@ export { listJobs, getLanguages, detectLanguage, + // Session management + listSessions, + getSession, + createSession, + deleteSession, + freezeSession, + unfreezeSession, + boostSession, + unboostSession, + shellSession, + // Service management + listServices, + createService, + getService, + updateService, + deleteService, + freezeService, + unfreezeService, + lockService, + unlockService, + getServiceLogs, + getServiceEnv, + setServiceEnv, + deleteServiceEnv, + exportServiceEnv, + redeployService, + executeInService, + // Snapshot management sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, deleteSnapshot, + lockSnapshot, + unlockSnapshot, + cloneSnapshot, + // Key validation + validateKeys, + // Errors CredentialsError, TimeoutError, }; // Default export for convenience export default { + // Code execution executeCode, executeAsync, getJob, @@ -610,11 +1132,45 @@ export default { listJobs, getLanguages, detectLanguage, + // Session management + listSessions, + getSession, + createSession, + deleteSession, + freezeSession, + unfreezeSession, + boostSession, + unboostSession, + shellSession, + // Service management + listServices, + createService, + getService, + updateService, + deleteService, + freezeService, + unfreezeService, + lockService, + unlockService, + getServiceLogs, + getServiceEnv, + setServiceEnv, + deleteServiceEnv, + exportServiceEnv, + redeployService, + executeInService, + // Snapshot management sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, deleteSnapshot, + lockSnapshot, + unlockSnapshot, + cloneSnapshot, + // Key validation + validateKeys, + // Errors CredentialsError, TimeoutError, }; diff --git a/clients/javascript/sync/src/un.js b/clients/javascript/sync/src/un.js index 73b676a..035ce32 100644 --- a/clients/javascript/sync/src/un.js +++ b/clients/javascript/sync/src/un.js @@ -4,21 +4,24 @@ * unsandbox.com JavaScript SDK (Synchronous/Async) * * Library Usage: - * import { - * executeCode, - * executeAsync, - * getJob, - * waitForJob, - * cancelJob, - * listJobs, - * getLanguages, - * detectLanguage, - * sessionSnapshot, - * serviceSnapshot, - * listSnapshots, - * restoreSnapshot, - * deleteSnapshot, - * } from './un.js'; + * const { + * // Code execution + * executeCode, executeAsync, getJob, waitForJob, cancelJob, listJobs, + * getLanguages, detectLanguage, + * // Session management + * listSessions, getSession, createSession, deleteSession, + * freezeSession, unfreezeSession, boostSession, unboostSession, shellSession, + * // Service management + * listServices, createService, getService, updateService, deleteService, + * freezeService, unfreezeService, lockService, unlockService, + * getServiceLogs, getServiceEnv, setServiceEnv, deleteServiceEnv, + * exportServiceEnv, redeployService, executeInService, + * // Snapshot management + * sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, + * deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot, + * // Key validation + * validateKeys, + * } = require('./un.js'); * * // Execute code asynchronously (returns Promise) * const result = await executeCode('python', 'print("hello")', publicKey, secretKey); @@ -201,7 +204,8 @@ function makeRequest(method, path, publicKey, secretKey, data) { timeout: 120000, // 120 seconds }; - if (method === 'POST' && body) { + // Set Content-Length for methods with body + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) { options.headers['Content-Length'] = Buffer.byteLength(body); } @@ -232,7 +236,8 @@ function makeRequest(method, path, publicKey, secretKey, data) { reject(new Error('Request timeout')); }); - if (method === 'POST' && body) { + // Write body for methods that support it + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) { req.write(body); } @@ -559,8 +564,491 @@ async function deleteSnapshot(snapshotId, publicKey, secretKey) { return makeRequest('DELETE', `/snapshots/${snapshotId}`, publicKey, secretKey); } +// ============================================================================ +// Session Management Functions +// ============================================================================ + +/** + * List all active sessions. + * + * Returns: Promise (list of session objects) + */ +async function listSessions(publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const response = await makeRequest('GET', '/sessions', publicKey, secretKey); + return response.sessions || []; +} + +/** + * Get details of a specific session. + * + * Args: + * sessionId: Session ID to retrieve + * + * Returns: Promise (session details) + */ +async function getSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/sessions/${sessionId}`, publicKey, secretKey); +} + +/** + * Create a new interactive session. + * + * Args: + * language: Optional programming language/shell (default: "bash") + * opts: Optional settings: + * - networkMode: "zerotrust" (default) or "semitrusted" + * - shell: Shell to use (e.g., "python3", "bash") + * - multiplexer: "tmux", "screen", or null + * - vcpu: Number of vCPUs (1-8) + * - ttl: Time-to-live in seconds + * + * Returns: Promise (session info with session_id, container_name) + */ +async function createSession(language, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = { + network_mode: opts.networkMode || 'zerotrust', + ttl: opts.ttl || 3600, + }; + if (language) data.shell = language; + if (opts.shell) data.shell = opts.shell; + if (opts.multiplexer) data.multiplexer = opts.multiplexer; + if (opts.vcpu && opts.vcpu > 1) data.vcpu = opts.vcpu; + + return makeRequest('POST', '/sessions', publicKey, secretKey, data); +} + +/** + * Delete/terminate a session. + * + * Args: + * sessionId: Session ID to terminate + * + * Returns: Promise (deletion confirmation) + */ +async function deleteSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('DELETE', `/sessions/${sessionId}`, publicKey, secretKey); +} + +/** + * Freeze a session (pause execution, preserve state). + * + * Args: + * sessionId: Session ID to freeze + * + * Returns: Promise (freeze confirmation) + */ +async function freezeSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/freeze`, publicKey, secretKey, {}); +} + +/** + * Unfreeze a session (resume execution). + * + * Args: + * sessionId: Session ID to unfreeze + * + * Returns: Promise (unfreeze confirmation) + */ +async function unfreezeSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/unfreeze`, publicKey, secretKey, {}); +} + +/** + * Boost a session's resources (increase vCPU, memory). + * + * Args: + * sessionId: Session ID to boost + * vcpu: Number of vCPUs (default: 2) + * + * Returns: Promise (boost confirmation) + */ +async function boostSession(sessionId, vcpu = 2, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/boost`, publicKey, secretKey, { vcpu }); +} + +/** + * Unboost a session (return to base resources). + * + * Args: + * sessionId: Session ID to unboost + * + * Returns: Promise (unboost confirmation) + */ +async function unboostSession(sessionId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/unboost`, publicKey, secretKey, {}); +} + +/** + * Execute a shell command in a session. + * + * Note: This initiates a WebSocket connection for interactive shell. + * For simple command execution, this sends the command via the shell endpoint. + * + * Args: + * sessionId: Session ID + * command: Command to execute + * + * Returns: Promise (command result) + */ +async function shellSession(sessionId, command, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/sessions/${sessionId}/shell`, publicKey, secretKey, { command }); +} + +// ============================================================================ +// Service Management Functions +// ============================================================================ + +/** + * List all services. + * + * Returns: Promise (list of service objects) + */ +async function listServices(publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const response = await makeRequest('GET', '/services', publicKey, secretKey); + return response.services || []; +} + +/** + * Create a new service (persistent container). + * + * Args: + * name: Service name + * ports: Array of port numbers to expose (e.g., [80, 443]) + * bootstrap: Bootstrap script content or URL + * opts: Optional settings: + * - networkMode: "zerotrust" or "semitrusted" + * - vcpu: Number of vCPUs (1-8) + * - domains: Array of custom domains + * - serviceType: Service type for SRV records (minecraft, mumble, etc.) + * + * Returns: Promise (service info with service_id) + */ +async function createService(name, ports, bootstrap, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (name) data.name = name; + if (ports && ports.length > 0) data.ports = ports; + if (bootstrap) { + // If bootstrap starts with http, treat as URL, otherwise as content + if (bootstrap.startsWith('http://') || bootstrap.startsWith('https://')) { + data.bootstrap = bootstrap; + } else { + data.bootstrap_content = bootstrap; + } + } + if (opts.networkMode) data.network_mode = opts.networkMode; + if (opts.vcpu && opts.vcpu > 1) data.vcpu = opts.vcpu; + if (opts.domains) data.custom_domains = opts.domains; + if (opts.serviceType) data.service_type = opts.serviceType; + + return makeRequest('POST', '/services', publicKey, secretKey, data); +} + +/** + * Get details of a specific service. + * + * Args: + * serviceId: Service ID to retrieve + * + * Returns: Promise (service details) + */ +async function getService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/services/${serviceId}`, publicKey, secretKey); +} + +/** + * Update a service (resize vCPU/memory). + * + * Args: + * serviceId: Service ID to update + * opts: Update options: + * - vcpu: New vCPU count (1-8) + * + * Returns: Promise (update confirmation) + */ +async function updateService(serviceId, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (opts.vcpu) data.vcpu = opts.vcpu; + return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, data); +} + +/** + * Delete/destroy a service. + * + * Args: + * serviceId: Service ID to destroy + * + * Returns: Promise (deletion confirmation) + */ +async function deleteService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('DELETE', `/services/${serviceId}`, publicKey, secretKey); +} + +/** + * Freeze a service (stop container, preserve disk). + * + * Args: + * serviceId: Service ID to freeze + * + * Returns: Promise (freeze confirmation) + */ +async function freezeService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/freeze`, publicKey, secretKey, {}); +} + +/** + * Unfreeze a service (restart container). + * + * Args: + * serviceId: Service ID to unfreeze + * + * Returns: Promise (unfreeze confirmation) + */ +async function unfreezeService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/unfreeze`, publicKey, secretKey, {}); +} + +/** + * Lock a service to prevent deletion. + * + * Args: + * serviceId: Service ID to lock + * + * Returns: Promise (lock confirmation) + */ +async function lockService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/lock`, publicKey, secretKey, {}); +} + +/** + * Unlock a service to allow deletion. + * + * Args: + * serviceId: Service ID to unlock + * + * Returns: Promise (unlock confirmation) + */ +async function unlockService(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/unlock`, publicKey, secretKey, {}); +} + +/** + * Get service logs. + * + * Args: + * serviceId: Service ID + * all: If true, get all logs; if false, get last ~9000 lines (default: false) + * + * Returns: Promise (log data) + */ +async function getServiceLogs(serviceId, all = false, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const path = all ? `/services/${serviceId}/logs?all=true` : `/services/${serviceId}/logs`; + return makeRequest('GET', path, publicKey, secretKey); +} + +/** + * Get service environment vault status. + * + * Args: + * serviceId: Service ID + * + * Returns: Promise (vault status with has_vault, count, updated_at) + */ +async function getServiceEnv(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/services/${serviceId}/env`, publicKey, secretKey); +} + +/** + * Set service environment vault. + * + * Args: + * serviceId: Service ID + * env: Environment content as string (KEY=VALUE format, newline separated) + * or object { KEY: "value", KEY2: "value2" } + * + * Returns: Promise (set confirmation) + */ +async function setServiceEnv(serviceId, env, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + // Convert object to KEY=VALUE format if needed + let envContent = env; + if (typeof env === 'object' && !Array.isArray(env)) { + envContent = Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + } + // Note: This endpoint uses PUT with text/plain body + // The makeRequest function sends JSON, so we need to handle this specially + return makeRequest('PUT', `/services/${serviceId}/env`, publicKey, secretKey, { content: envContent }); +} + +/** + * Delete service environment vault. + * + * Args: + * serviceId: Service ID + * keys: Optional array of specific keys to delete (deletes all if not specified) + * + * Returns: Promise (deletion confirmation) + */ +async function deleteServiceEnv(serviceId, keys = null, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = keys ? { keys } : {}; + return makeRequest('DELETE', `/services/${serviceId}/env`, publicKey, secretKey, data); +} + +/** + * Export service environment vault. + * + * Args: + * serviceId: Service ID + * + * Returns: Promise (exported environment data) + */ +async function exportServiceEnv(serviceId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/services/${serviceId}/env/export`, publicKey, secretKey, {}); +} + +/** + * Redeploy a service with new bootstrap script. + * + * Args: + * serviceId: Service ID to redeploy + * bootstrap: Optional new bootstrap script content or URL + * + * Returns: Promise (redeploy confirmation) + */ +async function redeployService(serviceId, bootstrap = null, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (bootstrap) { + if (bootstrap.startsWith('http://') || bootstrap.startsWith('https://')) { + data.bootstrap = bootstrap; + } else { + data.bootstrap_content = bootstrap; + } + } + return makeRequest('POST', `/services/${serviceId}/redeploy`, publicKey, secretKey, data); +} + +/** + * Execute a command in a running service container. + * + * Args: + * serviceId: Service ID + * command: Command to execute + * timeout: Optional timeout in milliseconds (default: 30000) + * + * Returns: Promise (execution result with stdout, stderr, exit_code) + */ +async function executeInService(serviceId, command, timeout = 30000, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const response = await makeRequest('POST', `/services/${serviceId}/execute`, publicKey, secretKey, { + command, + timeout, + }); + + // If we got a job_id, poll until completion + const jobId = response.job_id; + if (jobId) { + return waitForJob(jobId, publicKey, secretKey); + } + + return response; +} + +// ============================================================================ +// Additional Snapshot Functions +// ============================================================================ + +/** + * Lock a snapshot to prevent deletion. + * + * Args: + * snapshotId: Snapshot ID to lock + * + * Returns: Promise (lock confirmation) + */ +async function lockSnapshot(snapshotId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/snapshots/${snapshotId}/lock`, publicKey, secretKey, {}); +} + +/** + * Unlock a snapshot to allow deletion. + * + * Args: + * snapshotId: Snapshot ID to unlock + * + * Returns: Promise (unlock confirmation) + */ +async function unlockSnapshot(snapshotId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('POST', `/snapshots/${snapshotId}/unlock`, publicKey, secretKey, {}); +} + +/** + * Clone a snapshot to create a new session or service. + * + * Args: + * snapshotId: Snapshot ID to clone + * name: Name for the new resource + * opts: Optional settings: + * - type: "session" or "service" (default: inferred from snapshot) + * - shell: Shell for session clones + * - ports: Ports array for service clones + * + * Returns: Promise (clone result with new session_id or service_id) + */ +async function cloneSnapshot(snapshotId, name, opts = {}, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + const data = {}; + if (name) data.name = name; + if (opts.type) data.type = opts.type; + if (opts.shell) data.shell = opts.shell; + if (opts.ports) data.ports = opts.ports; + return makeRequest('POST', `/snapshots/${snapshotId}/clone`, publicKey, secretKey, data); +} + +// ============================================================================ +// Key Validation +// ============================================================================ + +/** + * Validate API keys. + * + * Returns: Promise (validation result with valid, tier, expires_at, etc.) + */ +async function validateKeys(publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + // Note: This endpoint is on the portal (unsandbox.com), not the API + // For SDK purposes, we'll call the API endpoint if available + return makeRequest('POST', '/keys/validate', publicKey, secretKey, {}); +} + // Export all functions module.exports = { + // Code execution executeCode, executeAsync, getJob, @@ -569,10 +1057,44 @@ module.exports = { listJobs, getLanguages, detectLanguage, + // Session management + listSessions, + getSession, + createSession, + deleteSession, + freezeSession, + unfreezeSession, + boostSession, + unboostSession, + shellSession, + // Service management + listServices, + createService, + getService, + updateService, + deleteService, + freezeService, + unfreezeService, + lockService, + unlockService, + getServiceLogs, + getServiceEnv, + setServiceEnv, + deleteServiceEnv, + exportServiceEnv, + redeployService, + executeInService, + // Snapshot management sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, deleteSnapshot, + lockSnapshot, + unlockSnapshot, + cloneSnapshot, + // Key validation + validateKeys, + // Errors CredentialsError, }; diff --git a/clients/php/async/src/UnsandboxAsync.php b/clients/php/async/src/UnsandboxAsync.php index a4fcd44..57dc25e 100644 --- a/clients/php/async/src/UnsandboxAsync.php +++ b/clients/php/async/src/UnsandboxAsync.php @@ -466,6 +466,545 @@ class UnsandboxAsync { return $this->makeRequest('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey); } + /** + * Lock a snapshot to prevent deletion. + * + * @param string $snapshotId Snapshot ID to lock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with lock confirmation + */ + public function lockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/snapshots/{$snapshotId}/lock", $publicKey, $secretKey, []); + } + + /** + * Unlock a snapshot to allow deletion. + * + * @param string $snapshotId Snapshot ID to unlock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with unlock confirmation + */ + public function unlockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/snapshots/{$snapshotId}/unlock", $publicKey, $secretKey, []); + } + + /** + * Clone a snapshot to create a new session or service. + * + * @param string $snapshotId Snapshot ID to clone + * @param string|null $name Optional name for the new resource + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'type' (session|service), 'shell', 'ports' + * @return PromiseInterface Resolves to response array with cloned resource info + */ + public function cloneSnapshot( + string $snapshotId, + ?string $name = null, + ?string $publicKey = null, + ?string $secretKey = null, + array $opts = [] + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = []; + if ($name !== null) { + $data['name'] = $name; + } + if (isset($opts['type'])) { + $data['type'] = $opts['type']; + } + if (isset($opts['shell'])) { + $data['shell'] = $opts['shell']; + } + if (isset($opts['ports'])) { + $data['ports'] = $opts['ports']; + } + + return $this->makeRequest('POST', "/snapshots/{$snapshotId}/clone", $publicKey, $secretKey, $data); + } + + // ========================================================================= + // Session Methods + // ========================================================================= + + /** + * List all active sessions for the authenticated account. + * + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to list of session arrays + */ + public function listSessions(?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', '/sessions', $publicKey, $secretKey)->then(function (array $response) { + return $response['sessions'] ?? []; + }); + } + + /** + * Get details of a specific session. + * + * @param string $sessionId Session ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to session details + */ + public function getSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/sessions/{$sessionId}", $publicKey, $secretKey); + } + + /** + * Create a new interactive session. + * + * @param string $language Programming language or shell (e.g., "bash", "python3") + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'network_mode', 'ttl', 'shell', 'multiplexer', 'vcpu' + * @return PromiseInterface Resolves to session info including session_id and container_name + */ + public function createSession( + string $language, + ?string $publicKey = null, + ?string $secretKey = null, + array $opts = [] + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = [ + 'network_mode' => $opts['network_mode'] ?? 'zerotrust', + 'ttl' => $opts['ttl'] ?? 3600, + ]; + if (!empty($language)) { + $data['shell'] = $language; + } + if (isset($opts['shell'])) { + $data['shell'] = $opts['shell']; + } + if (isset($opts['multiplexer'])) { + $data['multiplexer'] = $opts['multiplexer']; + } + if (isset($opts['vcpu']) && $opts['vcpu'] > 1) { + $data['vcpu'] = $opts['vcpu']; + } + if (isset($opts['input_files'])) { + $data['input_files'] = $opts['input_files']; + } + + return $this->makeRequest('POST', '/sessions', $publicKey, $secretKey, $data); + } + + /** + * Delete (terminate) a session. + * + * @param string $sessionId Session ID to delete + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with deletion confirmation + */ + public function deleteSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/sessions/{$sessionId}", $publicKey, $secretKey); + } + + /** + * Freeze a session to pause execution and reduce resource usage. + * + * @param string $sessionId Session ID to freeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with freeze confirmation + */ + public function freezeSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/freeze", $publicKey, $secretKey, []); + } + + /** + * Unfreeze a session to resume execution. + * + * @param string $sessionId Session ID to unfreeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with unfreeze confirmation + */ + public function unfreezeSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/unfreeze", $publicKey, $secretKey, []); + } + + /** + * Boost a session's resources (increase vCPU, memory is derived: vcpu * 2048MB). + * + * @param string $sessionId Session ID to boost + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param int $vcpu Number of vCPUs (default: 2) + * @return PromiseInterface Resolves to response array with boost confirmation + */ + public function boostSession( + string $sessionId, + ?string $publicKey = null, + ?string $secretKey = null, + int $vcpu = 2 + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/boost", $publicKey, $secretKey, ['vcpu' => $vcpu]); + } + + /** + * Remove boost from a session (return to base resources). + * + * @param string $sessionId Session ID to unboost + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with unboost confirmation + */ + public function unboostSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/unboost", $publicKey, $secretKey, []); + } + + /** + * Execute a shell command in an active session. + * + * Note: This is for one-shot commands. For interactive sessions, use WebSocket connection. + * + * @param string $sessionId Session ID + * @param string $command Command to execute + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with command output + */ + public function shellSession( + string $sessionId, + string $command, + ?string $publicKey = null, + ?string $secretKey = null + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/shell", $publicKey, $secretKey, ['command' => $command]); + } + + // ========================================================================= + // Service Methods + // ========================================================================= + + /** + * List all services for the authenticated account. + * + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to list of service arrays + */ + public function listServices(?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', '/services', $publicKey, $secretKey)->then(function (array $response) { + return $response['services'] ?? []; + }); + } + + /** + * Create a new persistent service. + * + * @param string $name Service name + * @param array|string $ports Port(s) to expose (array of ints or comma-separated string) + * @param string $bootstrap Bootstrap command or URL + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'network_mode', 'vcpu', 'service_type', 'custom_domains', 'bootstrap_content', 'input_files' + * @return PromiseInterface Resolves to service info including service_id + */ + public function createService( + string $name, + $ports, + string $bootstrap, + ?string $publicKey = null, + ?string $secretKey = null, + array $opts = [] + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + // Convert ports to array if string + if (is_string($ports)) { + $ports = array_map('intval', explode(',', $ports)); + } + + $data = [ + 'name' => $name, + 'ports' => $ports, + 'bootstrap' => $bootstrap, + ]; + + if (isset($opts['network_mode'])) { + $data['network_mode'] = $opts['network_mode']; + } + if (isset($opts['vcpu']) && $opts['vcpu'] > 1) { + $data['vcpu'] = $opts['vcpu']; + } + if (isset($opts['service_type'])) { + $data['service_type'] = $opts['service_type']; + } + if (isset($opts['custom_domains'])) { + $data['custom_domains'] = $opts['custom_domains']; + } + if (isset($opts['bootstrap_content'])) { + $data['bootstrap_content'] = $opts['bootstrap_content']; + } + if (isset($opts['input_files'])) { + $data['input_files'] = $opts['input_files']; + } + + return $this->makeRequest('POST', '/services', $publicKey, $secretKey, $data); + } + + /** + * Get details of a specific service. + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to service details + */ + public function getService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/services/{$serviceId}", $publicKey, $secretKey); + } + + /** + * Update a service (e.g., resize vCPU). + * + * @param string $serviceId Service ID + * @param array $opts Update parameters: 'vcpu', 'name', etc. + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to updated service details + */ + public function updateService( + string $serviceId, + array $opts, + ?string $publicKey = null, + ?string $secretKey = null + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, $opts); + } + + /** + * Delete (destroy) a service. + * + * @param string $serviceId Service ID to delete + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with deletion confirmation + */ + public function deleteService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/services/{$serviceId}", $publicKey, $secretKey); + } + + /** + * Freeze a service to pause execution and reduce resource usage. + * + * @param string $serviceId Service ID to freeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with freeze confirmation + */ + public function freezeService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/freeze", $publicKey, $secretKey, []); + } + + /** + * Unfreeze a service to resume execution. + * + * @param string $serviceId Service ID to unfreeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with unfreeze confirmation + */ + public function unfreezeService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/unfreeze", $publicKey, $secretKey, []); + } + + /** + * Lock a service to prevent deletion. + * + * @param string $serviceId Service ID to lock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with lock confirmation + */ + public function lockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/lock", $publicKey, $secretKey, []); + } + + /** + * Unlock a service to allow deletion. + * + * @param string $serviceId Service ID to unlock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with unlock confirmation + */ + public function unlockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/unlock", $publicKey, $secretKey, []); + } + + /** + * Get bootstrap logs for a service. + * + * @param string $serviceId Service ID + * @param bool $all If true, get all logs; if false, get last 9000 lines + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with log content + */ + public function getServiceLogs( + string $serviceId, + bool $all = false, + ?string $publicKey = null, + ?string $secretKey = null + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + $path = "/services/{$serviceId}/logs" . ($all ? '?all=true' : ''); + return $this->makeRequest('GET', $path, $publicKey, $secretKey); + } + + /** + * Get environment vault status for a service. + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with vault status (has_vault, count, updated_at) + */ + public function getServiceEnv(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/services/{$serviceId}/env", $publicKey, $secretKey); + } + + /** + * Set environment vault for a service. + * + * @param string $serviceId Service ID + * @param string $env Environment content in .env format (KEY=VALUE\nKEY2=VALUE2) + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with update confirmation + */ + public function setServiceEnv( + string $serviceId, + string $env, + ?string $publicKey = null, + ?string $secretKey = null + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequestRaw('PUT', "/services/{$serviceId}/env", $publicKey, $secretKey, $env, 'text/plain'); + } + + /** + * Delete environment vault for a service. + * + * @param string $serviceId Service ID + * @param array|null $keys Optional specific keys to delete; if null, deletes entire vault + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with deletion confirmation + */ + public function deleteServiceEnv( + string $serviceId, + ?array $keys = null, + ?string $publicKey = null, + ?string $secretKey = null + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/services/{$serviceId}/env", $publicKey, $secretKey); + } + + /** + * Export environment vault for a service (returns .env format). + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with env content + */ + public function exportServiceEnv(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/env/export", $publicKey, $secretKey, []); + } + + /** + * Redeploy a service (re-run bootstrap script). + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with redeploy confirmation + */ + public function redeployService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/redeploy", $publicKey, $secretKey, []); + } + + /** + * Execute a command in a running service container. + * + * @param string $serviceId Service ID + * @param string $command Command to execute + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param int $timeout Timeout in milliseconds (default: 30000) + * @return PromiseInterface Resolves to response array with command output (stdout, stderr, exit_code) + */ + public function executeInService( + string $serviceId, + string $command, + ?string $publicKey = null, + ?string $secretKey = null, + int $timeout = 30000 + ): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + return $this->makeRequest('POST', "/services/{$serviceId}/execute", $publicKey, $secretKey, [ + 'command' => $command, + 'timeout' => $timeout, + ])->then(function (array $response) use ($publicKey, $secretKey) { + // If we got a job_id, poll until completion + $jobId = $response['job_id'] ?? null; + if ($jobId) { + return $this->waitForJob($jobId, $publicKey, $secretKey); + } + return $response; + }); + } + + // ========================================================================= + // Key Validation + // ========================================================================= + + /** + * Validate API keys. + * + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return PromiseInterface Resolves to response array with validation result + */ + public function validateKeys(?string $publicKey = null, ?string $secretKey = null): PromiseInterface { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', '/keys/validate', $publicKey, $secretKey, []); + } + /** * Get path to ~/.unsandbox directory, creating if necessary. * @@ -660,6 +1199,58 @@ class UnsandboxAsync { ); } + /** + * Make an authenticated HTTP request with raw body content (non-JSON) asynchronously. + * + * @param string $method HTTP method (PUT, POST, etc.) + * @param string $path API endpoint path + * @param string $publicKey API public key + * @param string $secretKey API secret key + * @param string $body Raw request body + * @param string $contentType Content type header (e.g., 'text/plain') + * @return PromiseInterface Resolves to decoded JSON response array + */ + private function makeRequestRaw(string $method, string $path, string $publicKey, string $secretKey, string $body, string $contentType = 'text/plain'): PromiseInterface { + $timestamp = time(); + + $signature = $this->signRequest($secretKey, $timestamp, $method, $path, $body); + + $headers = [ + 'Authorization' => 'Bearer ' . $publicKey, + 'X-Timestamp' => (string)$timestamp, + 'X-Signature' => $signature, + 'Content-Type' => $contentType, + ]; + + $options = [ + 'headers' => $headers, + 'body' => $body, + ]; + + return $this->httpClient->requestAsync($method, $path, $options)->then( + function ($response) { + $body = (string)$response->getBody(); + $decoded = json_decode($body, true); + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + throw new AsyncApiException("Invalid JSON response: " . json_last_error_msg()); + } + return $decoded; + }, + function ($exception) { + if ($exception instanceof RequestException) { + $response = $exception->getResponse(); + if ($response !== null) { + $body = (string)$response->getBody(); + $decoded = json_decode($body, true); + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP " . $response->getStatusCode(); + throw new AsyncApiException($errorMessage, $response->getStatusCode(), $decoded, $exception); + } + } + throw new AsyncApiException($exception->getMessage(), 0, null, $exception); + } + ); + } + /** * Get path to languages cache file. * diff --git a/clients/php/sync/src/un.php b/clients/php/sync/src/un.php index 75bfb87..ce6027a 100644 --- a/clients/php/sync/src/un.php +++ b/clients/php/sync/src/un.php @@ -442,6 +442,602 @@ class Unsandbox { return $this->makeRequest('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey); } + /** + * Lock a snapshot to prevent deletion. + * + * @param string $snapshotId Snapshot ID to lock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with lock confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function lockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/snapshots/{$snapshotId}/lock", $publicKey, $secretKey, []); + } + + /** + * Unlock a snapshot to allow deletion. + * + * @param string $snapshotId Snapshot ID to unlock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with unlock confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function unlockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/snapshots/{$snapshotId}/unlock", $publicKey, $secretKey, []); + } + + /** + * Clone a snapshot to create a new session or service. + * + * @param string $snapshotId Snapshot ID to clone + * @param string|null $name Optional name for the new resource + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'type' (session|service), 'shell', 'ports' + * @return array Response array with cloned resource info + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function cloneSnapshot( + string $snapshotId, + ?string $name = null, + ?string $publicKey = null, + ?string $secretKey = null, + array $opts = [] + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = []; + if ($name !== null) { + $data['name'] = $name; + } + if (isset($opts['type'])) { + $data['type'] = $opts['type']; + } + if (isset($opts['shell'])) { + $data['shell'] = $opts['shell']; + } + if (isset($opts['ports'])) { + $data['ports'] = $opts['ports']; + } + + return $this->makeRequest('POST', "/snapshots/{$snapshotId}/clone", $publicKey, $secretKey, $data); + } + + // ========================================================================= + // Session Methods + // ========================================================================= + + /** + * List all active sessions for the authenticated account. + * + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array List of session arrays + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function listSessions(?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + $response = $this->makeRequest('GET', '/sessions', $publicKey, $secretKey); + return $response['sessions'] ?? []; + } + + /** + * Get details of a specific session. + * + * @param string $sessionId Session ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Session details + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function getSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/sessions/{$sessionId}", $publicKey, $secretKey); + } + + /** + * Create a new interactive session. + * + * @param string $language Programming language or shell (e.g., "bash", "python3") + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'network_mode', 'ttl', 'shell', 'multiplexer', 'vcpu' + * @return array Session info including session_id and container_name + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function createSession( + string $language, + ?string $publicKey = null, + ?string $secretKey = null, + array $opts = [] + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = [ + 'network_mode' => $opts['network_mode'] ?? 'zerotrust', + 'ttl' => $opts['ttl'] ?? 3600, + ]; + if (!empty($language)) { + $data['shell'] = $language; + } + if (isset($opts['shell'])) { + $data['shell'] = $opts['shell']; + } + if (isset($opts['multiplexer'])) { + $data['multiplexer'] = $opts['multiplexer']; + } + if (isset($opts['vcpu']) && $opts['vcpu'] > 1) { + $data['vcpu'] = $opts['vcpu']; + } + if (isset($opts['input_files'])) { + $data['input_files'] = $opts['input_files']; + } + + return $this->makeRequest('POST', '/sessions', $publicKey, $secretKey, $data); + } + + /** + * Delete (terminate) a session. + * + * @param string $sessionId Session ID to delete + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with deletion confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function deleteSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/sessions/{$sessionId}", $publicKey, $secretKey); + } + + /** + * Freeze a session to pause execution and reduce resource usage. + * + * @param string $sessionId Session ID to freeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with freeze confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function freezeSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/freeze", $publicKey, $secretKey, []); + } + + /** + * Unfreeze a session to resume execution. + * + * @param string $sessionId Session ID to unfreeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with unfreeze confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function unfreezeSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/unfreeze", $publicKey, $secretKey, []); + } + + /** + * Boost a session's resources (increase vCPU, memory is derived: vcpu * 2048MB). + * + * @param string $sessionId Session ID to boost + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param int $vcpu Number of vCPUs (default: 2) + * @return array Response array with boost confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function boostSession( + string $sessionId, + ?string $publicKey = null, + ?string $secretKey = null, + int $vcpu = 2 + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/boost", $publicKey, $secretKey, ['vcpu' => $vcpu]); + } + + /** + * Remove boost from a session (return to base resources). + * + * @param string $sessionId Session ID to unboost + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with unboost confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function unboostSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/unboost", $publicKey, $secretKey, []); + } + + /** + * Execute a shell command in an active session. + * + * Note: This is for one-shot commands. For interactive sessions, use WebSocket connection. + * + * @param string $sessionId Session ID + * @param string $command Command to execute + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with command output + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function shellSession( + string $sessionId, + string $command, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/sessions/{$sessionId}/shell", $publicKey, $secretKey, ['command' => $command]); + } + + // ========================================================================= + // Service Methods + // ========================================================================= + + /** + * List all services for the authenticated account. + * + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array List of service arrays + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function listServices(?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + $response = $this->makeRequest('GET', '/services', $publicKey, $secretKey); + return $response['services'] ?? []; + } + + /** + * Create a new persistent service. + * + * @param string $name Service name + * @param array|string $ports Port(s) to expose (array of ints or comma-separated string) + * @param string $bootstrap Bootstrap command or URL + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'network_mode', 'vcpu', 'service_type', 'custom_domains', 'bootstrap_content', 'input_files' + * @return array Service info including service_id + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function createService( + string $name, + $ports, + string $bootstrap, + ?string $publicKey = null, + ?string $secretKey = null, + array $opts = [] + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + // Convert ports to array if string + if (is_string($ports)) { + $ports = array_map('intval', explode(',', $ports)); + } + + $data = [ + 'name' => $name, + 'ports' => $ports, + 'bootstrap' => $bootstrap, + ]; + + if (isset($opts['network_mode'])) { + $data['network_mode'] = $opts['network_mode']; + } + if (isset($opts['vcpu']) && $opts['vcpu'] > 1) { + $data['vcpu'] = $opts['vcpu']; + } + if (isset($opts['service_type'])) { + $data['service_type'] = $opts['service_type']; + } + if (isset($opts['custom_domains'])) { + $data['custom_domains'] = $opts['custom_domains']; + } + if (isset($opts['bootstrap_content'])) { + $data['bootstrap_content'] = $opts['bootstrap_content']; + } + if (isset($opts['input_files'])) { + $data['input_files'] = $opts['input_files']; + } + + return $this->makeRequest('POST', '/services', $publicKey, $secretKey, $data); + } + + /** + * Get details of a specific service. + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Service details + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function getService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/services/{$serviceId}", $publicKey, $secretKey); + } + + /** + * Update a service (e.g., resize vCPU). + * + * @param string $serviceId Service ID + * @param array $opts Update parameters: 'vcpu', 'name', etc. + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Updated service details + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function updateService( + string $serviceId, + array $opts, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, $opts); + } + + /** + * Delete (destroy) a service. + * + * @param string $serviceId Service ID to delete + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with deletion confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function deleteService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/services/{$serviceId}", $publicKey, $secretKey); + } + + /** + * Freeze a service to pause execution and reduce resource usage. + * + * @param string $serviceId Service ID to freeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with freeze confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function freezeService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/freeze", $publicKey, $secretKey, []); + } + + /** + * Unfreeze a service to resume execution. + * + * @param string $serviceId Service ID to unfreeze + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with unfreeze confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function unfreezeService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/unfreeze", $publicKey, $secretKey, []); + } + + /** + * Lock a service to prevent deletion. + * + * @param string $serviceId Service ID to lock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with lock confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function lockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/lock", $publicKey, $secretKey, []); + } + + /** + * Unlock a service to allow deletion. + * + * @param string $serviceId Service ID to unlock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with unlock confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function unlockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/unlock", $publicKey, $secretKey, []); + } + + /** + * Get bootstrap logs for a service. + * + * @param string $serviceId Service ID + * @param bool $all If true, get all logs; if false, get last 9000 lines + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with log content + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function getServiceLogs( + string $serviceId, + bool $all = false, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + $path = "/services/{$serviceId}/logs" . ($all ? '?all=true' : ''); + return $this->makeRequest('GET', $path, $publicKey, $secretKey); + } + + /** + * Get environment vault status for a service. + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with vault status (has_vault, count, updated_at) + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function getServiceEnv(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/services/{$serviceId}/env", $publicKey, $secretKey); + } + + /** + * Set environment vault for a service. + * + * @param string $serviceId Service ID + * @param string $env Environment content in .env format (KEY=VALUE\nKEY2=VALUE2) + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with update confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function setServiceEnv( + string $serviceId, + string $env, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequestRaw('PUT', "/services/{$serviceId}/env", $publicKey, $secretKey, $env, 'text/plain'); + } + + /** + * Delete environment vault for a service. + * + * @param string $serviceId Service ID + * @param array|null $keys Optional specific keys to delete; if null, deletes entire vault + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with deletion confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function deleteServiceEnv( + string $serviceId, + ?array $keys = null, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/services/{$serviceId}/env", $publicKey, $secretKey); + } + + /** + * Export environment vault for a service (returns .env format). + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with env content + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function exportServiceEnv(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/env/export", $publicKey, $secretKey, []); + } + + /** + * Redeploy a service (re-run bootstrap script). + * + * @param string $serviceId Service ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with redeploy confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function redeployService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/services/{$serviceId}/redeploy", $publicKey, $secretKey, []); + } + + /** + * Execute a command in a running service container. + * + * @param string $serviceId Service ID + * @param string $command Command to execute + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @param int $timeout Timeout in milliseconds (default: 30000) + * @return array Response array with command output (stdout, stderr, exit_code) + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function executeInService( + string $serviceId, + string $command, + ?string $publicKey = null, + ?string $secretKey = null, + int $timeout = 30000 + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $response = $this->makeRequest('POST', "/services/{$serviceId}/execute", $publicKey, $secretKey, [ + 'command' => $command, + 'timeout' => $timeout, + ]); + + // If we got a job_id, poll until completion + $jobId = $response['job_id'] ?? null; + if ($jobId) { + return $this->waitForJob($jobId, $publicKey, $secretKey); + } + + return $response; + } + + // ========================================================================= + // Key Validation + // ========================================================================= + + /** + * Validate API keys. + * + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with validation result + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function validateKeys(?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', '/keys/validate', $publicKey, $secretKey, []); + } + /** * Get path to ~/.unsandbox directory, creating if necessary. * @@ -616,6 +1212,14 @@ class Unsandbox { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; + case 'PUT': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'PATCH': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; case 'DELETE': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); break; @@ -647,6 +1251,61 @@ class Unsandbox { return $decoded; } + /** + * Make an authenticated HTTP request with raw body content (non-JSON). + * + * @param string $method HTTP method (PUT, POST, etc.) + * @param string $path API endpoint path + * @param string $publicKey API public key + * @param string $secretKey API secret key + * @param string $body Raw request body + * @param string $contentType Content type header (e.g., 'text/plain') + * @return array Decoded JSON response + * @throws ApiException On network errors or non-2xx response + */ + private function makeRequestRaw(string $method, string $path, string $publicKey, string $secretKey, string $body, string $contentType = 'text/plain'): array { + $url = self::API_BASE . $path; + $timestamp = time(); + + $signature = $this->signRequest($secretKey, $timestamp, $method, $path, $body); + + $headers = [ + 'Authorization: Bearer ' . $publicKey, + 'X-Timestamp: ' . $timestamp, + 'X-Signature: ' . $signature, + 'Content-Type: ' . $contentType, + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 120); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false) { + throw new ApiException("cURL error: {$error}"); + } + + $decoded = json_decode($response, true); + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + throw new ApiException("Invalid JSON response: " . json_last_error_msg()); + } + + if ($httpCode >= 400) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP {$httpCode}"; + throw new ApiException($errorMessage, $httpCode, $decoded); + } + + return $decoded; + } + /** * Get path to languages cache file. * diff --git a/clients/python/async/src/un_async.py b/clients/python/async/src/un_async.py index f63c90b..f0723ec 100644 --- a/clients/python/async/src/un_async.py +++ b/clients/python/async/src/un_async.py @@ -6,6 +6,7 @@ unsandbox.com Python SDK (Asynchronous) Library Usage: import asyncio from un_async import ( + # Execution execute_code, execute_async, get_job, @@ -14,11 +15,44 @@ Library Usage: list_jobs, get_languages, detect_language, + # Sessions + list_sessions, + get_session, + create_session, + delete_session, + freeze_session, + unfreeze_session, + boost_session, + unboost_session, + shell_session, + # Services + list_services, + create_service, + get_service, + update_service, + delete_service, + freeze_service, + unfreeze_service, + lock_service, + unlock_service, + get_service_logs, + get_service_env, + set_service_env, + delete_service_env, + export_service_env, + redeploy_service, + execute_in_service, + # Snapshots session_snapshot, service_snapshot, list_snapshots, restore_snapshot, delete_snapshot, + lock_snapshot, + unlock_snapshot, + clone_snapshot, + # Key validation + validate_keys, ) async def main(): @@ -214,6 +248,10 @@ async def _make_request( async with session.post(url, headers=headers, json=data, timeout=aiohttp.ClientTimeout(total=120)) as resp: resp.raise_for_status() return await resp.json() + elif method == "PATCH": + async with session.patch(url, headers=headers, json=data, timeout=aiohttp.ClientTimeout(total=120)) as resp: + resp.raise_for_status() + return await resp.json() elif method == "DELETE": async with session.delete(url, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as resp: resp.raise_for_status() @@ -702,3 +740,913 @@ async def delete_snapshot( """ public_key, secret_key = _resolve_credentials(public_key, secret_key) return await _make_request("DELETE", f"/snapshots/{snapshot_id}", public_key, secret_key) + + +# ============================================================================= +# Session Management Functions +# ============================================================================= + + +async def list_sessions( + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> List[Dict[str, Any]]: + """ + List all sessions for the authenticated account. + + Args: + public_key: Optional API key + secret_key: Optional API secret + + Returns: + List of session dicts containing id, container_name, status, etc. + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + response = await _make_request("GET", "/sessions", public_key, secret_key) + return response.get("sessions", []) + + +async def get_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get details of a specific session. + + Args: + session_id: Session ID to get details for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Session details dict + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("GET", f"/sessions/{session_id}", public_key, secret_key) + + +async def create_session( + language: Optional[str] = None, + network_mode: str = "zerotrust", + ttl: int = 3600, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + shell: Optional[str] = None, + multiplexer: Optional[str] = None, + vcpu: int = 1, +) -> Dict[str, Any]: + """ + Create a new interactive session. + + Args: + language: Optional programming language for the session + network_mode: Network mode - "zerotrust" (default, no network) or "semitrusted" (with network) + ttl: Time to live in seconds (default 3600) + public_key: Optional API key + secret_key: Optional API secret + shell: Optional shell to use (e.g., "bash", "python3") + multiplexer: Optional terminal multiplexer ("tmux" or "screen") + vcpu: Number of vCPUs (1-8, default 1) + + Returns: + Response dict containing session_id, container_name, etc. + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = { + "network_mode": network_mode, + "ttl": ttl, + } + if language: + data["language"] = language + if shell: + data["shell"] = shell + if multiplexer: + data["multiplexer"] = multiplexer + if vcpu > 1: + data["vcpu"] = vcpu + + return await _make_request("POST", "/sessions", public_key, secret_key, data) + + +async def delete_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Delete/terminate a session. + + Args: + session_id: Session ID to delete + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with deletion confirmation and optional artifacts + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("DELETE", f"/sessions/{session_id}", public_key, secret_key) + + +async def freeze_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Freeze a session (pause execution, preserve state). + + Args: + session_id: Session ID to freeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with freeze confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/sessions/{session_id}/freeze", public_key, secret_key, {}) + + +async def unfreeze_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unfreeze a session (resume execution). + + Args: + session_id: Session ID to unfreeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unfreeze confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/sessions/{session_id}/unfreeze", public_key, secret_key, {}) + + +async def boost_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Boost a session (increase resources). + + Args: + session_id: Session ID to boost + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with boost confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/sessions/{session_id}/boost", public_key, secret_key, {}) + + +async def unboost_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unboost a session (return to normal resources). + + Args: + session_id: Session ID to unboost + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unboost confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/sessions/{session_id}/unboost", public_key, secret_key, {}) + + +async def shell_session( + session_id: str, + command: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Execute a shell command in a session. + + Note: This is for one-off commands. For interactive shell access, + use WebSocket connection to /sessions/{id}/shell. + + Args: + session_id: Session ID to execute command in + command: Shell command to execute + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with command output + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request( + "POST", + f"/sessions/{session_id}/shell", + public_key, + secret_key, + {"command": command}, + ) + + +# ============================================================================= +# Service Management Functions +# ============================================================================= + + +async def list_services( + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> List[Dict[str, Any]]: + """ + List all services for the authenticated account. + + Args: + public_key: Optional API key + secret_key: Optional API secret + + Returns: + List of service dicts containing id, name, status, ports, etc. + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + response = await _make_request("GET", "/services", public_key, secret_key) + return response.get("services", []) + + +async def create_service( + name: str, + ports: List[int], + bootstrap: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + network_mode: str = "semitrusted", + custom_domains: Optional[List[str]] = None, + vcpu: int = 1, + service_type: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a new persistent service. + + Args: + name: Service name (used for subdomain: name.on.unsandbox.com) + ports: List of ports to expose (e.g., [80, 443]) + bootstrap: Bootstrap script content, URL, or inline command + public_key: Optional API key + secret_key: Optional API secret + network_mode: Network mode (default "semitrusted" for services) + custom_domains: Optional list of custom domain names + vcpu: Number of vCPUs (1-8, default 1) + service_type: Optional service type for SRV records (e.g., "minecraft") + + Returns: + Response dict containing service_id, etc. + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = { + "name": name, + "ports": ports, + "network_mode": network_mode, + } + if bootstrap: + # Check if it looks like a URL + if bootstrap.startswith("http://") or bootstrap.startswith("https://"): + data["bootstrap"] = bootstrap + else: + data["bootstrap_content"] = bootstrap + if custom_domains: + data["custom_domains"] = custom_domains + if vcpu > 1: + data["vcpu"] = vcpu + if service_type: + data["service_type"] = service_type + + return await _make_request("POST", "/services", public_key, secret_key, data) + + +async def get_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get details of a specific service. + + Args: + service_id: Service ID to get details for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Service details dict + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("GET", f"/services/{service_id}", public_key, secret_key) + + +async def update_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + vcpu: Optional[int] = None, + **kwargs, +) -> Dict[str, Any]: + """ + Update a service (e.g., resize vCPU/memory). + + Args: + service_id: Service ID to update + public_key: Optional API key + secret_key: Optional API secret + vcpu: Optional new vCPU count (1-8) + **kwargs: Additional fields to update + + Returns: + Response dict with update confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = {} + if vcpu is not None: + data["vcpu"] = vcpu + data.update(kwargs) + + return await _make_request("PATCH", f"/services/{service_id}", public_key, secret_key, data) + + +async def delete_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Delete/destroy a service. + + Args: + service_id: Service ID to delete + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with deletion confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("DELETE", f"/services/{service_id}", public_key, secret_key) + + +async def freeze_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Freeze a service (pause execution, preserve state). + + Args: + service_id: Service ID to freeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with freeze confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/services/{service_id}/freeze", public_key, secret_key, {}) + + +async def unfreeze_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unfreeze a service (resume execution). + + Args: + service_id: Service ID to unfreeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unfreeze confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/services/{service_id}/unfreeze", public_key, secret_key, {}) + + +async def lock_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lock a service to prevent accidental deletion. + + Args: + service_id: Service ID to lock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with lock confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/services/{service_id}/lock", public_key, secret_key, {}) + + +async def unlock_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unlock a service to allow deletion. + + Args: + service_id: Service ID to unlock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unlock confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/services/{service_id}/unlock", public_key, secret_key, {}) + + +async def get_service_logs( + service_id: str, + all_logs: bool = False, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get bootstrap/runtime logs for a service. + + Args: + service_id: Service ID to get logs for + all_logs: If True, get all logs; if False, get last ~9000 lines (tail) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict containing "log" field with log content + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + path = f"/services/{service_id}/logs" + if all_logs: + path += "?all=true" + return await _make_request("GET", path, public_key, secret_key) + + +async def get_service_env( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get environment vault status for a service. + + Returns metadata about the vault (has_vault, count, updated_at) + but NOT the actual secrets. Use export_service_env to retrieve secrets. + + Args: + service_id: Service ID to get env status for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with has_vault, count, updated_at fields + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("GET", f"/services/{service_id}/env", public_key, secret_key) + + +async def set_service_env( + service_id: str, + env_dict: Dict[str, str], + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Set environment variables for a service. + + Replaces the entire environment vault with the provided variables. + Variables are encrypted at rest and injected into the container. + + Args: + service_id: Service ID to set env for + env_dict: Dictionary of environment variables (KEY: VALUE) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with count of variables set + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + # Convert dict to .env format for the API + env_content = "\n".join(f"{k}={v}" for k, v in env_dict.items()) + + # Note: This endpoint expects text/plain body, but we'll send as JSON + # and let the API handle conversion + return await _make_request( + "POST", + f"/services/{service_id}/env", + public_key, + secret_key, + {"env": env_content}, + ) + + +async def delete_service_env( + service_id: str, + keys: Optional[List[str]] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Delete environment vault or specific keys from a service. + + Args: + service_id: Service ID to delete env from + keys: Optional list of specific keys to delete; if None, deletes entire vault + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with deletion confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + path = f"/services/{service_id}/env" + # If specific keys provided, could add as query params (API dependent) + return await _make_request("DELETE", path, public_key, secret_key) + + +async def export_service_env( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Export environment vault secrets for a service. + + Requires HMAC authentication to prove ownership. + Returns the actual secret values in .env format. + + Args: + service_id: Service ID to export env from + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict containing "env" field with KEY=VALUE content + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/services/{service_id}/env/export", public_key, secret_key, {}) + + +async def redeploy_service( + service_id: str, + bootstrap: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Redeploy a service (re-run bootstrap script). + + Bootstrap scripts should be idempotent for proper upgrade behavior. + + Args: + service_id: Service ID to redeploy + bootstrap: Optional new bootstrap script/URL (uses existing if not provided) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with redeploy confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = {} + if bootstrap: + if bootstrap.startswith("http://") or bootstrap.startswith("https://"): + data["bootstrap"] = bootstrap + else: + data["bootstrap_content"] = bootstrap + + return await _make_request("POST", f"/services/{service_id}/redeploy", public_key, secret_key, data) + + +async def execute_in_service( + service_id: str, + command: str, + timeout: int = 30000, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Execute a command in a running service container. + + Uses async job polling for long-running commands. + + Args: + service_id: Service ID to execute command in + command: Shell command to execute + timeout: Command timeout in milliseconds (default 30000) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with job_id for async polling, or direct result + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request( + "POST", + f"/services/{service_id}/execute", + public_key, + secret_key, + {"command": command, "timeout": timeout}, + ) + + +# ============================================================================= +# Additional Snapshot Functions +# ============================================================================= + + +async def lock_snapshot( + snapshot_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lock a snapshot to prevent accidental deletion. + + Args: + snapshot_id: Snapshot ID to lock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with lock confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/snapshots/{snapshot_id}/lock", public_key, secret_key, {}) + + +async def unlock_snapshot( + snapshot_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unlock a snapshot to allow deletion. + + Args: + snapshot_id: Snapshot ID to unlock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unlock confirmation + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return await _make_request("POST", f"/snapshots/{snapshot_id}/unlock", public_key, secret_key, {}) + + +async def clone_snapshot( + snapshot_id: str, + clone_type: str = "session", + name: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + shell: Optional[str] = None, + ports: Optional[List[int]] = None, +) -> Dict[str, Any]: + """ + Clone a snapshot to create a new session or service. + + Args: + snapshot_id: Snapshot ID to clone from + clone_type: Type of resource to create ("session" or "service") + name: Optional name for the new resource + public_key: Optional API key + secret_key: Optional API secret + shell: Optional shell for session clones + ports: Optional ports list for service clones + + Returns: + Response dict containing session_id or service_id + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = {"type": clone_type} + if name: + data["name"] = name + if shell: + data["shell"] = shell + if ports: + data["ports"] = ports + + return await _make_request("POST", f"/snapshots/{snapshot_id}/clone", public_key, secret_key, data) + + +# ============================================================================= +# Key Validation +# ============================================================================= + + +PORTAL_BASE = "https://unsandbox.com" + + +async def validate_keys( + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Validate API keys against the portal. + + Checks if the keys are valid, not expired, and not suspended. + + Args: + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with validation result: + - valid: True if keys are valid + - tier: Account tier level + - expires_at: Expiration timestamp (if applicable) + - reason: Reason for invalid status (if applicable) + + Raises: + aiohttp.ClientError: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + + url = f"{PORTAL_BASE}/keys/validate" + timestamp = int(time.time()) + body = "" + + signature = _sign_request(secret_key, timestamp, "POST", "/keys/validate", body) + + headers = { + "Authorization": f"Bearer {public_key}", + "X-Timestamp": str(timestamp), + "X-Signature": signature, + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, data=body, timeout=aiohttp.ClientTimeout(total=30)) as resp: + resp.raise_for_status() + return await resp.json() diff --git a/clients/python/sync/src/un.py b/clients/python/sync/src/un.py index f6d2287..da36bff 100644 --- a/clients/python/sync/src/un.py +++ b/clients/python/sync/src/un.py @@ -5,6 +5,7 @@ unsandbox.com Python SDK (Synchronous) Library Usage: from un import ( + # Execution execute_code, execute_async, get_job, @@ -13,11 +14,44 @@ Library Usage: list_jobs, get_languages, detect_language, + # Sessions + list_sessions, + get_session, + create_session, + delete_session, + freeze_session, + unfreeze_session, + boost_session, + unboost_session, + shell_session, + # Services + list_services, + create_service, + get_service, + update_service, + delete_service, + freeze_service, + unfreeze_service, + lock_service, + unlock_service, + get_service_logs, + get_service_env, + set_service_env, + delete_service_env, + export_service_env, + redeploy_service, + execute_in_service, + # Snapshots session_snapshot, service_snapshot, list_snapshots, restore_snapshot, delete_snapshot, + lock_snapshot, + unlock_snapshot, + clone_snapshot, + # Key validation + validate_keys, ) # Execute code synchronously @@ -221,6 +255,8 @@ def _make_request( response = requests.get(url, headers=headers, timeout=120) elif method == "POST": response = requests.post(url, headers=headers, json=data, timeout=120) + elif method == "PATCH": + response = requests.patch(url, headers=headers, json=data, timeout=120) elif method == "DELETE": response = requests.delete(url, headers=headers, timeout=120) else: @@ -718,3 +754,912 @@ def delete_snapshot( """ public_key, secret_key = _resolve_credentials(public_key, secret_key) return _make_request("DELETE", f"/snapshots/{snapshot_id}", public_key, secret_key) + + +# ============================================================================= +# Session Management Functions +# ============================================================================= + + +def list_sessions( + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> List[Dict[str, Any]]: + """ + List all sessions for the authenticated account. + + Args: + public_key: Optional API key + secret_key: Optional API secret + + Returns: + List of session dicts containing id, container_name, status, etc. + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + response = _make_request("GET", "/sessions", public_key, secret_key) + return response.get("sessions", []) + + +def get_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get details of a specific session. + + Args: + session_id: Session ID to get details for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Session details dict + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("GET", f"/sessions/{session_id}", public_key, secret_key) + + +def create_session( + language: Optional[str] = None, + network_mode: str = "zerotrust", + ttl: int = 3600, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + shell: Optional[str] = None, + multiplexer: Optional[str] = None, + vcpu: int = 1, +) -> Dict[str, Any]: + """ + Create a new interactive session. + + Args: + language: Optional programming language for the session + network_mode: Network mode - "zerotrust" (default, no network) or "semitrusted" (with network) + ttl: Time to live in seconds (default 3600) + public_key: Optional API key + secret_key: Optional API secret + shell: Optional shell to use (e.g., "bash", "python3") + multiplexer: Optional terminal multiplexer ("tmux" or "screen") + vcpu: Number of vCPUs (1-8, default 1) + + Returns: + Response dict containing session_id, container_name, etc. + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = { + "network_mode": network_mode, + "ttl": ttl, + } + if language: + data["language"] = language + if shell: + data["shell"] = shell + if multiplexer: + data["multiplexer"] = multiplexer + if vcpu > 1: + data["vcpu"] = vcpu + + return _make_request("POST", "/sessions", public_key, secret_key, data) + + +def delete_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Delete/terminate a session. + + Args: + session_id: Session ID to delete + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with deletion confirmation and optional artifacts + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("DELETE", f"/sessions/{session_id}", public_key, secret_key) + + +def freeze_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Freeze a session (pause execution, preserve state). + + Args: + session_id: Session ID to freeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with freeze confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/sessions/{session_id}/freeze", public_key, secret_key, {}) + + +def unfreeze_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unfreeze a session (resume execution). + + Args: + session_id: Session ID to unfreeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unfreeze confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/sessions/{session_id}/unfreeze", public_key, secret_key, {}) + + +def boost_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Boost a session (increase resources). + + Args: + session_id: Session ID to boost + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with boost confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/sessions/{session_id}/boost", public_key, secret_key, {}) + + +def unboost_session( + session_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unboost a session (return to normal resources). + + Args: + session_id: Session ID to unboost + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unboost confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/sessions/{session_id}/unboost", public_key, secret_key, {}) + + +def shell_session( + session_id: str, + command: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Execute a shell command in a session. + + Note: This is for one-off commands. For interactive shell access, + use WebSocket connection to /sessions/{id}/shell. + + Args: + session_id: Session ID to execute command in + command: Shell command to execute + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with command output + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request( + "POST", + f"/sessions/{session_id}/shell", + public_key, + secret_key, + {"command": command}, + ) + + +# ============================================================================= +# Service Management Functions +# ============================================================================= + + +def list_services( + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> List[Dict[str, Any]]: + """ + List all services for the authenticated account. + + Args: + public_key: Optional API key + secret_key: Optional API secret + + Returns: + List of service dicts containing id, name, status, ports, etc. + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + response = _make_request("GET", "/services", public_key, secret_key) + return response.get("services", []) + + +def create_service( + name: str, + ports: List[int], + bootstrap: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + network_mode: str = "semitrusted", + custom_domains: Optional[List[str]] = None, + vcpu: int = 1, + service_type: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a new persistent service. + + Args: + name: Service name (used for subdomain: name.on.unsandbox.com) + ports: List of ports to expose (e.g., [80, 443]) + bootstrap: Bootstrap script content, URL, or inline command + public_key: Optional API key + secret_key: Optional API secret + network_mode: Network mode (default "semitrusted" for services) + custom_domains: Optional list of custom domain names + vcpu: Number of vCPUs (1-8, default 1) + service_type: Optional service type for SRV records (e.g., "minecraft") + + Returns: + Response dict containing service_id, etc. + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = { + "name": name, + "ports": ports, + "network_mode": network_mode, + } + if bootstrap: + # Check if it looks like a URL + if bootstrap.startswith("http://") or bootstrap.startswith("https://"): + data["bootstrap"] = bootstrap + else: + data["bootstrap_content"] = bootstrap + if custom_domains: + data["custom_domains"] = custom_domains + if vcpu > 1: + data["vcpu"] = vcpu + if service_type: + data["service_type"] = service_type + + return _make_request("POST", "/services", public_key, secret_key, data) + + +def get_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get details of a specific service. + + Args: + service_id: Service ID to get details for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Service details dict + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("GET", f"/services/{service_id}", public_key, secret_key) + + +def update_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + vcpu: Optional[int] = None, + **kwargs, +) -> Dict[str, Any]: + """ + Update a service (e.g., resize vCPU/memory). + + Args: + service_id: Service ID to update + public_key: Optional API key + secret_key: Optional API secret + vcpu: Optional new vCPU count (1-8) + **kwargs: Additional fields to update + + Returns: + Response dict with update confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = {} + if vcpu is not None: + data["vcpu"] = vcpu + data.update(kwargs) + + return _make_request("PATCH", f"/services/{service_id}", public_key, secret_key, data) + + +def delete_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Delete/destroy a service. + + Args: + service_id: Service ID to delete + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with deletion confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("DELETE", f"/services/{service_id}", public_key, secret_key) + + +def freeze_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Freeze a service (pause execution, preserve state). + + Args: + service_id: Service ID to freeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with freeze confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/services/{service_id}/freeze", public_key, secret_key, {}) + + +def unfreeze_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unfreeze a service (resume execution). + + Args: + service_id: Service ID to unfreeze + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unfreeze confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/services/{service_id}/unfreeze", public_key, secret_key, {}) + + +def lock_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lock a service to prevent accidental deletion. + + Args: + service_id: Service ID to lock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with lock confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/services/{service_id}/lock", public_key, secret_key, {}) + + +def unlock_service( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unlock a service to allow deletion. + + Args: + service_id: Service ID to unlock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unlock confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/services/{service_id}/unlock", public_key, secret_key, {}) + + +def get_service_logs( + service_id: str, + all_logs: bool = False, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get bootstrap/runtime logs for a service. + + Args: + service_id: Service ID to get logs for + all_logs: If True, get all logs; if False, get last ~9000 lines (tail) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict containing "log" field with log content + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + path = f"/services/{service_id}/logs" + if all_logs: + path += "?all=true" + return _make_request("GET", path, public_key, secret_key) + + +def get_service_env( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get environment vault status for a service. + + Returns metadata about the vault (has_vault, count, updated_at) + but NOT the actual secrets. Use export_service_env to retrieve secrets. + + Args: + service_id: Service ID to get env status for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with has_vault, count, updated_at fields + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("GET", f"/services/{service_id}/env", public_key, secret_key) + + +def set_service_env( + service_id: str, + env_dict: Dict[str, str], + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Set environment variables for a service. + + Replaces the entire environment vault with the provided variables. + Variables are encrypted at rest and injected into the container. + + Args: + service_id: Service ID to set env for + env_dict: Dictionary of environment variables (KEY: VALUE) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with count of variables set + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + # Convert dict to .env format for the API + env_content = "\n".join(f"{k}={v}" for k, v in env_dict.items()) + + # Note: This endpoint expects text/plain body, but we'll send as JSON + # and let the API handle conversion + return _make_request( + "POST", + f"/services/{service_id}/env", + public_key, + secret_key, + {"env": env_content}, + ) + + +def delete_service_env( + service_id: str, + keys: Optional[List[str]] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Delete environment vault or specific keys from a service. + + Args: + service_id: Service ID to delete env from + keys: Optional list of specific keys to delete; if None, deletes entire vault + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with deletion confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + path = f"/services/{service_id}/env" + # If specific keys provided, could add as query params (API dependent) + return _make_request("DELETE", path, public_key, secret_key) + + +def export_service_env( + service_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Export environment vault secrets for a service. + + Requires HMAC authentication to prove ownership. + Returns the actual secret values in .env format. + + Args: + service_id: Service ID to export env from + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict containing "env" field with KEY=VALUE content + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/services/{service_id}/env/export", public_key, secret_key, {}) + + +def redeploy_service( + service_id: str, + bootstrap: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Redeploy a service (re-run bootstrap script). + + Bootstrap scripts should be idempotent for proper upgrade behavior. + + Args: + service_id: Service ID to redeploy + bootstrap: Optional new bootstrap script/URL (uses existing if not provided) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with redeploy confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = {} + if bootstrap: + if bootstrap.startswith("http://") or bootstrap.startswith("https://"): + data["bootstrap"] = bootstrap + else: + data["bootstrap_content"] = bootstrap + + return _make_request("POST", f"/services/{service_id}/redeploy", public_key, secret_key, data) + + +def execute_in_service( + service_id: str, + command: str, + timeout: int = 30000, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Execute a command in a running service container. + + Uses async job polling for long-running commands. + + Args: + service_id: Service ID to execute command in + command: Shell command to execute + timeout: Command timeout in milliseconds (default 30000) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with job_id for async polling, or direct result + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request( + "POST", + f"/services/{service_id}/execute", + public_key, + secret_key, + {"command": command, "timeout": timeout}, + ) + + +# ============================================================================= +# Additional Snapshot Functions +# ============================================================================= + + +def lock_snapshot( + snapshot_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lock a snapshot to prevent accidental deletion. + + Args: + snapshot_id: Snapshot ID to lock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with lock confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/snapshots/{snapshot_id}/lock", public_key, secret_key, {}) + + +def unlock_snapshot( + snapshot_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Unlock a snapshot to allow deletion. + + Args: + snapshot_id: Snapshot ID to unlock + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with unlock confirmation + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("POST", f"/snapshots/{snapshot_id}/unlock", public_key, secret_key, {}) + + +def clone_snapshot( + snapshot_id: str, + clone_type: str = "session", + name: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + shell: Optional[str] = None, + ports: Optional[List[int]] = None, +) -> Dict[str, Any]: + """ + Clone a snapshot to create a new session or service. + + Args: + snapshot_id: Snapshot ID to clone from + clone_type: Type of resource to create ("session" or "service") + name: Optional name for the new resource + public_key: Optional API key + secret_key: Optional API secret + shell: Optional shell for session clones + ports: Optional ports list for service clones + + Returns: + Response dict containing session_id or service_id + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + data: Dict[str, Any] = {"type": clone_type} + if name: + data["name"] = name + if shell: + data["shell"] = shell + if ports: + data["ports"] = ports + + return _make_request("POST", f"/snapshots/{snapshot_id}/clone", public_key, secret_key, data) + + +# ============================================================================= +# Key Validation +# ============================================================================= + + +PORTAL_BASE = "https://unsandbox.com" + + +def validate_keys( + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Validate API keys against the portal. + + Checks if the keys are valid, not expired, and not suspended. + + Args: + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with validation result: + - valid: True if keys are valid + - tier: Account tier level + - expires_at: Expiration timestamp (if applicable) + - reason: Reason for invalid status (if applicable) + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + + url = f"{PORTAL_BASE}/keys/validate" + timestamp = int(time.time()) + body = "" + + signature = _sign_request(secret_key, timestamp, "POST", "/keys/validate", body) + + headers = { + "Authorization": f"Bearer {public_key}", + "X-Timestamp": str(timestamp), + "X-Signature": signature, + "Content-Type": "application/json", + } + + response = requests.post(url, headers=headers, data=body, timeout=30) + response.raise_for_status() + return response.json() diff --git a/clients/ruby/async/src/un_async.rb b/clients/ruby/async/src/un_async.rb index 006edd7..2c61464 100644 --- a/clients/ruby/async/src/un_async.rb +++ b/clients/ruby/async/src/un_async.rb @@ -477,6 +477,619 @@ module UnAsync end end + # Lock a snapshot to prevent deletion + # + # @param snapshot_id [String] Snapshot ID to lock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with lock confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.lock_snapshot(snapshot_id).value + def lock_snapshot(snapshot_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/snapshots/#{snapshot_id}/lock", pk, sk, {}) + end + end + + # Unlock a snapshot to allow deletion + # + # @param snapshot_id [String] Snapshot ID to unlock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with unlock confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.unlock_snapshot(snapshot_id).value + def unlock_snapshot(snapshot_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {}) + end + end + + # Clone a snapshot to create a new session or service + # + # @param snapshot_id [String] Snapshot ID to clone + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param name [String, nil] Optional name for the cloned resource + # @param type [String] Type of resource to create ("session" or "service") + # @param shell [String, nil] Optional shell for session clones + # @param ports [Array, nil] Optional ports for service clones + # @return [Future] Future resolving to response hash with cloned resource info + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example Clone to session + # result = UnAsync.clone_snapshot(snapshot_id, type: "session").value + # puts result["session_id"] + def clone_snapshot(snapshot_id, public_key: nil, secret_key: nil, name: nil, type: 'session', shell: nil, ports: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + data = { type: type } + data[:name] = name if name + data[:shell] = shell if shell + data[:ports] = ports if ports + make_request_sync('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data) + end + end + + # ============================================================================ + # Session Functions + # ============================================================================ + + # List all sessions for the authenticated account + # + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future>] Future resolving to list of session hashes + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # sessions = UnAsync.list_sessions.value + # sessions.each { |s| puts "#{s['id']}: #{s['status']}" } + def list_sessions(public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + response = make_request_sync('GET', '/sessions', pk, sk) + response['sessions'] || [] + end + end + + # Get session details by ID + # + # @param session_id [String] Session ID to retrieve + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to session details hash + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # session = UnAsync.get_session(session_id).value + # puts session["status"] + def get_session(session_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('GET', "/sessions/#{session_id}", pk, sk) + end + end + + # Create a new interactive session + # + # @param language [String] Shell or language for the session (e.g., "bash", "python3") + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param network_mode [String] Network mode ("zerotrust" or "semitrusted") + # @param ttl [Integer] Time-to-live in seconds (default: 3600) + # @param multiplexer [String, nil] Terminal multiplexer ("tmux" or "screen") + # @param vcpu [Integer] Number of vCPUs (1-8) + # @return [Future] Future resolving to response hash with session_id and container_name + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # result = UnAsync.create_session("bash", network_mode: "semitrusted").value + # puts result["session_id"] + def create_session(language, public_key: nil, secret_key: nil, network_mode: 'zerotrust', ttl: 3600, multiplexer: nil, vcpu: 1) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + data = { + network_mode: network_mode, + ttl: ttl + } + data[:shell] = language if language + data[:multiplexer] = multiplexer if multiplexer + data[:vcpu] = vcpu if vcpu > 1 + make_request_sync('POST', '/sessions', pk, sk, data) + end + end + + # Delete (terminate) a session + # + # @param session_id [String] Session ID to delete + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.delete_session(session_id).value + def delete_session(session_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('DELETE', "/sessions/#{session_id}", pk, sk) + end + end + + # Freeze a session (pause execution, reduce resource usage) + # + # @param session_id [String] Session ID to freeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with freeze confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.freeze_session(session_id).value + def freeze_session(session_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/sessions/#{session_id}/freeze", pk, sk, {}) + end + end + + # Unfreeze a session (resume execution) + # + # @param session_id [String] Session ID to unfreeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with unfreeze confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.unfreeze_session(session_id).value + def unfreeze_session(session_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/sessions/#{session_id}/unfreeze", pk, sk, {}) + end + end + + # Boost a session (increase vCPU allocation) + # + # @param session_id [String] Session ID to boost + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with boost confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.boost_session(session_id).value + def boost_session(session_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/sessions/#{session_id}/boost", pk, sk, {}) + end + end + + # Unboost a session (reduce vCPU allocation) + # + # @param session_id [String] Session ID to unboost + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with unboost confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.unboost_session(session_id).value + def unboost_session(session_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/sessions/#{session_id}/unboost", pk, sk, {}) + end + end + + # Execute a shell command in an existing session + # + # @param session_id [String] Session ID to execute command in + # @param command [String] Command to execute + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with command output + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # result = UnAsync.shell_session(session_id, "ls -la").value + # puts result["stdout"] + def shell_session(session_id, command, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/sessions/#{session_id}/shell", pk, sk, { command: command }) + end + end + + # ============================================================================ + # Service Functions + # ============================================================================ + + # List all services for the authenticated account + # + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future>] Future resolving to list of service hashes + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # services = UnAsync.list_services.value + # services.each { |s| puts "#{s['id']}: #{s['name']} (#{s['state']})" } + def list_services(public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + response = make_request_sync('GET', '/services', pk, sk) + response['services'] || [] + end + end + + # Create a new persistent service + # + # @param name [String] Service name + # @param ports [Array] Ports to expose + # @param bootstrap [String] Bootstrap script content or URL + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param network_mode [String] Network mode ("zerotrust" or "semitrusted") + # @param vcpu [Integer] Number of vCPUs (1-8) + # @param custom_domains [Array, nil] Custom domains for the service + # @param service_type [String, nil] Service type for SRV records + # @return [Future] Future resolving to response hash with service_id + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # result = UnAsync.create_service("web", [80, 443], "apt install -y nginx && nginx").value + # puts result["service_id"] + def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + data = { + name: name, + ports: ports, + bootstrap: bootstrap, + network_mode: network_mode + } + data[:vcpu] = vcpu if vcpu > 1 + data[:custom_domains] = custom_domains if custom_domains + data[:service_type] = service_type if service_type + make_request_sync('POST', '/services', pk, sk, data) + end + end + + # Get service details by ID + # + # @param service_id [String] Service ID to retrieve + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to service details hash + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # service = UnAsync.get_service(service_id).value + # puts service["status"] + def get_service(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('GET', "/services/#{service_id}", pk, sk) + end + end + + # Update a service (e.g., resize vCPU) + # + # @param service_id [String] Service ID to update + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param vcpu [Integer, nil] New vCPU count (1-8) + # @param name [String, nil] New service name + # @return [Future] Future resolving to response hash with update confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.update_service(service_id, vcpu: 4).value + def update_service(service_id, public_key: nil, secret_key: nil, vcpu: nil, name: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + data = {} + data[:vcpu] = vcpu if vcpu + data[:name] = name if name + make_request_sync('PATCH', "/services/#{service_id}", pk, sk, data) + end + end + + # Delete (destroy) a service + # + # @param service_id [String] Service ID to delete + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.delete_service(service_id).value + def delete_service(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('DELETE', "/services/#{service_id}", pk, sk) + end + end + + # Freeze a service (stop container, reduce resource usage) + # + # @param service_id [String] Service ID to freeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with freeze confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.freeze_service(service_id).value + def freeze_service(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/services/#{service_id}/freeze", pk, sk, {}) + end + end + + # Unfreeze a service (start container) + # + # @param service_id [String] Service ID to unfreeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with unfreeze confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.unfreeze_service(service_id).value + def unfreeze_service(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/services/#{service_id}/unfreeze", pk, sk, {}) + end + end + + # Lock a service to prevent deletion + # + # @param service_id [String] Service ID to lock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with lock confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.lock_service(service_id).value + def lock_service(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/services/#{service_id}/lock", pk, sk, {}) + end + end + + # Unlock a service to allow deletion + # + # @param service_id [String] Service ID to unlock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with unlock confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.unlock_service(service_id).value + def unlock_service(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/services/#{service_id}/unlock", pk, sk, {}) + end + end + + # Get service logs (bootstrap output) + # + # @param service_id [String] Service ID to get logs for + # @param all [Boolean] If true, get all logs; if false, get last 9000 lines + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with log content + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # logs = UnAsync.get_service_logs(service_id).value + # puts logs["log"] + def get_service_logs(service_id, all: false, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + path = "/services/#{service_id}/logs" + path += '?all=true' if all + make_request_sync('GET', path, pk, sk) + end + end + + # Get service environment vault status + # + # @param service_id [String] Service ID to get env status for + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with vault status + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # status = UnAsync.get_service_env(service_id).value + # puts "Variables: #{status['count']}" + def get_service_env(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('GET', "/services/#{service_id}/env", pk, sk) + end + end + + # Set service environment variables (replaces existing vault) + # + # @param service_id [String] Service ID to set env for + # @param env [String] Environment content in .env format (KEY=VALUE per line) + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.set_service_env(service_id, "API_KEY=secret\nDEBUG=true").value + def set_service_env(service_id, env, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_text_sync('PUT', "/services/#{service_id}/env", pk, sk, env) + end + end + + # Delete service environment vault + # + # @param service_id [String] Service ID to delete env for + # @param keys [Array, nil] Specific keys to delete (nil = delete entire vault) + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example Delete entire vault + # UnAsync.delete_service_env(service_id).value + def delete_service_env(service_id, keys: nil, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + if keys + make_request_sync('DELETE', "/services/#{service_id}/env", pk, sk, { keys: keys }) + else + make_request_sync('DELETE', "/services/#{service_id}/env", pk, sk) + end + end + end + + # Export service environment vault (returns .env format) + # + # @param service_id [String] Service ID to export env from + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with env content + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # result = UnAsync.export_service_env(service_id).value + # puts result["env"] + def export_service_env(service_id, public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', "/services/#{service_id}/env/export", pk, sk, {}) + end + end + + # Redeploy a service (re-run bootstrap script) + # + # @param service_id [String] Service ID to redeploy + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param bootstrap [String, nil] New bootstrap script (optional) + # @return [Future] Future resolving to response hash with redeploy confirmation + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # UnAsync.redeploy_service(service_id).value + def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + data = {} + data[:bootstrap] = bootstrap if bootstrap + make_request_sync('POST', "/services/#{service_id}/redeploy", pk, sk, data) + end + end + + # Execute a command in a running service + # + # @param service_id [String] Service ID to execute command in + # @param command [String] Command to execute + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param timeout [Integer] Command timeout in milliseconds (default: 30000) + # @return [Future] Future resolving to response hash with command output + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails (on .value) + # + # @example + # result = UnAsync.execute_in_service(service_id, "ls -la").value + # puts result["stdout"] + def execute_in_service(service_id, command, public_key: nil, secret_key: nil, timeout: 30_000) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + + # Start async execution + response = make_request_sync('POST', "/services/#{service_id}/execute", pk, sk, { + command: command, + timeout: timeout + }) + + job_id = response['job_id'] + if job_id + # Poll for completion + wait_for_job_sync(job_id, pk, sk, (timeout / 1000) + 10) + else + response + end + end + end + + # ============================================================================ + # Key Validation + # ============================================================================ + + # Validate API keys + # + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Future] Future resolving to response hash with validation result + # @raise [CredentialsError] If no credentials found (on .value) + # @raise [APIError] If API request fails or keys invalid (on .value) + # + # @example + # result = UnAsync.validate_keys.value + # puts result["valid"] + def validate_keys(public_key: nil, secret_key: nil) + Future.new do + pk, sk = resolve_credentials(public_key, secret_key) + make_request_sync('POST', '/keys/validate', pk, sk, {}) + end + end + # Execute multiple futures concurrently and wait for all to complete # # @param futures [Array] Array of futures to wait for @@ -741,8 +1354,14 @@ module UnAsync http.get(uri.request_uri, headers) when 'POST' http.post(uri.request_uri, body, headers) + when 'PATCH' + http.patch(uri.request_uri, body, headers) + when 'PUT' + http.put(uri.request_uri, body, headers) when 'DELETE' - http.delete(uri.request_uri, headers) + req = Net::HTTP::Delete.new(uri.request_uri, headers) + req.body = body if data + http.request(req) else raise APIError, "Unsupported HTTP method: #{method}" end @@ -766,6 +1385,59 @@ module UnAsync raise end + # Make a synchronous authenticated HTTP request with text/plain content type + # + # @param method [String] HTTP method (PUT) + # @param path [String] API path + # @param public_key [String] API public key + # @param secret_key [String] API secret key + # @param body [String] Plain text request body + # @return [Hash] Parsed JSON response + # @raise [APIError] If request fails + def make_request_text_sync(method, path, public_key, secret_key, body) + uri = URI.parse("#{API_BASE}#{path}") + timestamp = Time.now.to_i + + signature = sign_request(secret_key, timestamp, method, path, body) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = REQUEST_TIMEOUT + http.read_timeout = REQUEST_TIMEOUT + + headers = { + 'Authorization' => "Bearer #{public_key}", + 'X-Timestamp' => timestamp.to_s, + 'X-Signature' => signature, + 'Content-Type' => 'text/plain' + } + + response = case method + when 'PUT' + http.put(uri.request_uri, body, headers) + else + raise APIError, "Unsupported HTTP method for text: #{method}" + end + + unless response.is_a?(Net::HTTPSuccess) + raise APIError.new( + "API request failed: #{response.code} #{response.message}", + status_code: response.code.to_i, + response_body: response.body + ) + end + + JSON.parse(response.body) + rescue JSON::ParserError => e + raise APIError, "Invalid JSON response: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise APIError, "Request timeout: #{e.message}" + rescue StandardError => e + raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError) + + raise + end + # Wait for job completion synchronously (used internally) # # @param job_id [String] Job ID diff --git a/clients/ruby/sync/src/un.rb b/clients/ruby/sync/src/un.rb index dd4fdd2..3dd6350 100644 --- a/clients/ruby/sync/src/un.rb +++ b/clients/ruby/sync/src/un.rb @@ -366,6 +366,569 @@ module Un make_request('DELETE', "/snapshots/#{snapshot_id}", pk, sk) end + # Lock a snapshot to prevent deletion + # + # @param snapshot_id [String] Snapshot ID to lock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with lock confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.lock_snapshot(snapshot_id) + def lock_snapshot(snapshot_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/snapshots/#{snapshot_id}/lock", pk, sk, {}) + end + + # Unlock a snapshot to allow deletion + # + # @param snapshot_id [String] Snapshot ID to unlock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with unlock confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.unlock_snapshot(snapshot_id) + def unlock_snapshot(snapshot_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {}) + end + + # Clone a snapshot to create a new session or service + # + # @param snapshot_id [String] Snapshot ID to clone + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param name [String, nil] Optional name for the cloned resource + # @param type [String] Type of resource to create ("session" or "service") + # @param shell [String, nil] Optional shell for session clones + # @param ports [Array, nil] Optional ports for service clones + # @return [Hash] Response hash with cloned resource info (session_id or service_id) + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example Clone to session + # result = Un.clone_snapshot(snapshot_id, type: "session") + # puts result["session_id"] + # + # @example Clone to service + # result = Un.clone_snapshot(snapshot_id, type: "service", ports: [80, 443]) + # puts result["service_id"] + def clone_snapshot(snapshot_id, public_key: nil, secret_key: nil, name: nil, type: 'session', shell: nil, ports: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = { type: type } + data[:name] = name if name + data[:shell] = shell if shell + data[:ports] = ports if ports + make_request('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data) + end + + # ============================================================================ + # Session Functions + # ============================================================================ + + # List all sessions for the authenticated account + # + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Array] List of session hashes + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # sessions = Un.list_sessions + # sessions.each { |s| puts "#{s['id']}: #{s['status']}" } + def list_sessions(public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + response = make_request('GET', '/sessions', pk, sk) + response['sessions'] || [] + end + + # Get session details by ID + # + # @param session_id [String] Session ID to retrieve + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Session details hash + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # session = Un.get_session(session_id) + # puts session["status"] + def get_session(session_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('GET', "/sessions/#{session_id}", pk, sk) + end + + # Create a new interactive session + # + # @param language [String] Shell or language for the session (e.g., "bash", "python3") + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param network_mode [String] Network mode ("zerotrust" or "semitrusted") + # @param ttl [Integer] Time-to-live in seconds (default: 3600) + # @param multiplexer [String, nil] Terminal multiplexer ("tmux" or "screen") + # @param vcpu [Integer] Number of vCPUs (1-8) + # @return [Hash] Response hash with session_id and container_name + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # result = Un.create_session("bash", network_mode: "semitrusted") + # puts result["session_id"] + def create_session(language, public_key: nil, secret_key: nil, network_mode: 'zerotrust', ttl: 3600, multiplexer: nil, vcpu: 1) + pk, sk = resolve_credentials(public_key, secret_key) + data = { + network_mode: network_mode, + ttl: ttl + } + data[:shell] = language if language + data[:multiplexer] = multiplexer if multiplexer + data[:vcpu] = vcpu if vcpu > 1 + make_request('POST', '/sessions', pk, sk, data) + end + + # Delete (terminate) a session + # + # @param session_id [String] Session ID to delete + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.delete_session(session_id) + def delete_session(session_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('DELETE', "/sessions/#{session_id}", pk, sk) + end + + # Freeze a session (pause execution, reduce resource usage) + # + # @param session_id [String] Session ID to freeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with freeze confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.freeze_session(session_id) + def freeze_session(session_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/sessions/#{session_id}/freeze", pk, sk, {}) + end + + # Unfreeze a session (resume execution) + # + # @param session_id [String] Session ID to unfreeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with unfreeze confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.unfreeze_session(session_id) + def unfreeze_session(session_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/sessions/#{session_id}/unfreeze", pk, sk, {}) + end + + # Boost a session (increase vCPU allocation) + # + # @param session_id [String] Session ID to boost + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with boost confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.boost_session(session_id) + def boost_session(session_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/sessions/#{session_id}/boost", pk, sk, {}) + end + + # Unboost a session (reduce vCPU allocation) + # + # @param session_id [String] Session ID to unboost + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with unboost confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.unboost_session(session_id) + def unboost_session(session_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/sessions/#{session_id}/unboost", pk, sk, {}) + end + + # Execute a shell command in an existing session + # Note: This is for non-interactive command execution, not for WebSocket shell access + # + # @param session_id [String] Session ID to execute command in + # @param command [String] Command to execute + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with command output + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # result = Un.shell_session(session_id, "ls -la") + # puts result["stdout"] + def shell_session(session_id, command, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/sessions/#{session_id}/shell", pk, sk, { command: command }) + end + + # ============================================================================ + # Service Functions + # ============================================================================ + + # List all services for the authenticated account + # + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Array] List of service hashes + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # services = Un.list_services + # services.each { |s| puts "#{s['id']}: #{s['name']} (#{s['state']})" } + def list_services(public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + response = make_request('GET', '/services', pk, sk) + response['services'] || [] + end + + # Create a new persistent service + # + # @param name [String] Service name + # @param ports [Array] Ports to expose + # @param bootstrap [String] Bootstrap script content or URL + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param network_mode [String] Network mode ("zerotrust" or "semitrusted") + # @param vcpu [Integer] Number of vCPUs (1-8) + # @param custom_domains [Array, nil] Custom domains for the service + # @param service_type [String, nil] Service type for SRV records (e.g., "minecraft") + # @return [Hash] Response hash with service_id + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # result = Un.create_service("web", [80, 443], "apt install -y nginx && nginx") + # puts result["service_id"] + def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = { + name: name, + ports: ports, + bootstrap: bootstrap, + network_mode: network_mode + } + data[:vcpu] = vcpu if vcpu > 1 + data[:custom_domains] = custom_domains if custom_domains + data[:service_type] = service_type if service_type + make_request('POST', '/services', pk, sk, data) + end + + # Get service details by ID + # + # @param service_id [String] Service ID to retrieve + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Service details hash + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # service = Un.get_service(service_id) + # puts service["status"] + def get_service(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('GET', "/services/#{service_id}", pk, sk) + end + + # Update a service (e.g., resize vCPU) + # + # @param service_id [String] Service ID to update + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param vcpu [Integer, nil] New vCPU count (1-8) + # @param name [String, nil] New service name + # @return [Hash] Response hash with update confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.update_service(service_id, vcpu: 4) + def update_service(service_id, public_key: nil, secret_key: nil, vcpu: nil, name: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = {} + data[:vcpu] = vcpu if vcpu + data[:name] = name if name + make_request('PATCH', "/services/#{service_id}", pk, sk, data) + end + + # Delete (destroy) a service + # + # @param service_id [String] Service ID to delete + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.delete_service(service_id) + def delete_service(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('DELETE', "/services/#{service_id}", pk, sk) + end + + # Freeze a service (stop container, reduce resource usage) + # + # @param service_id [String] Service ID to freeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with freeze confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.freeze_service(service_id) + def freeze_service(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/services/#{service_id}/freeze", pk, sk, {}) + end + + # Unfreeze a service (start container) + # + # @param service_id [String] Service ID to unfreeze + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with unfreeze confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.unfreeze_service(service_id) + def unfreeze_service(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/services/#{service_id}/unfreeze", pk, sk, {}) + end + + # Lock a service to prevent deletion + # + # @param service_id [String] Service ID to lock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with lock confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.lock_service(service_id) + def lock_service(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/services/#{service_id}/lock", pk, sk, {}) + end + + # Unlock a service to allow deletion + # + # @param service_id [String] Service ID to unlock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with unlock confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.unlock_service(service_id) + def unlock_service(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/services/#{service_id}/unlock", pk, sk, {}) + end + + # Get service logs (bootstrap output) + # + # @param service_id [String] Service ID to get logs for + # @param all [Boolean] If true, get all logs; if false, get last 9000 lines (default: false) + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with log content + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # logs = Un.get_service_logs(service_id) + # puts logs["log"] + def get_service_logs(service_id, all: false, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + path = "/services/#{service_id}/logs" + path += '?all=true' if all + make_request('GET', path, pk, sk) + end + + # Get service environment vault status + # + # @param service_id [String] Service ID to get env status for + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with vault status (has_vault, count, updated_at) + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # status = Un.get_service_env(service_id) + # puts "Variables: #{status['count']}" + def get_service_env(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('GET', "/services/#{service_id}/env", pk, sk) + end + + # Set service environment variables (replaces existing vault) + # + # @param service_id [String] Service ID to set env for + # @param env [String] Environment content in .env format (KEY=VALUE per line) + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.set_service_env(service_id, "API_KEY=secret\nDEBUG=true") + def set_service_env(service_id, env, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + # This endpoint uses text/plain content type and PUT method + make_request_text('PUT', "/services/#{service_id}/env", pk, sk, env) + end + + # Delete service environment vault + # + # @param service_id [String] Service ID to delete env for + # @param keys [Array, nil] Specific keys to delete (nil = delete entire vault) + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example Delete entire vault + # Un.delete_service_env(service_id) + # + # @example Delete specific keys (if API supports it) + # Un.delete_service_env(service_id, keys: ["API_KEY", "DEBUG"]) + def delete_service_env(service_id, keys: nil, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + if keys + make_request('DELETE', "/services/#{service_id}/env", pk, sk, { keys: keys }) + else + make_request('DELETE', "/services/#{service_id}/env", pk, sk) + end + end + + # Export service environment vault (returns .env format) + # + # @param service_id [String] Service ID to export env from + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with env content + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # result = Un.export_service_env(service_id) + # puts result["env"] # API_KEY=secret\nDEBUG=true + def export_service_env(service_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/services/#{service_id}/env/export", pk, sk, {}) + end + + # Redeploy a service (re-run bootstrap script) + # + # @param service_id [String] Service ID to redeploy + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param bootstrap [String, nil] New bootstrap script (optional) + # @return [Hash] Response hash with redeploy confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.redeploy_service(service_id) + def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = {} + data[:bootstrap] = bootstrap if bootstrap + make_request('POST', "/services/#{service_id}/redeploy", pk, sk, data) + end + + # Execute a command in a running service + # + # @param service_id [String] Service ID to execute command in + # @param command [String] Command to execute + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @param timeout [Integer] Command timeout in milliseconds (default: 30000) + # @return [Hash] Response hash with command output (stdout, stderr, exit_code) + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # result = Un.execute_in_service(service_id, "ls -la") + # puts result["stdout"] + def execute_in_service(service_id, command, public_key: nil, secret_key: nil, timeout: 30_000) + pk, sk = resolve_credentials(public_key, secret_key) + + # Start async execution + response = make_request('POST', "/services/#{service_id}/execute", pk, sk, { + command: command, + timeout: timeout + }) + + job_id = response['job_id'] + return response unless job_id + + # Poll for completion + wait_for_job(job_id, public_key: pk, secret_key: sk, timeout: (timeout / 1000) + 10) + end + + # ============================================================================ + # Key Validation + # ============================================================================ + + # Validate API keys + # + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with validation result and account info + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails or keys invalid + # + # @example + # result = Un.validate_keys + # puts result["valid"] # true or false + def validate_keys(public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + # Note: This endpoint is on the portal, not API, but we use same auth + make_request('POST', '/keys/validate', pk, sk, {}) + end + private # Language detection mapping (file extension -> language) @@ -545,8 +1108,14 @@ module Un http.get(uri.request_uri, headers) when 'POST' http.post(uri.request_uri, body, headers) + when 'PATCH' + http.patch(uri.request_uri, body, headers) + when 'PUT' + http.put(uri.request_uri, body, headers) when 'DELETE' - http.delete(uri.request_uri, headers) + req = Net::HTTP::Delete.new(uri.request_uri, headers) + req.body = body if data + http.request(req) else raise APIError, "Unsupported HTTP method: #{method}" end @@ -570,6 +1139,59 @@ module Un raise end + # Make an authenticated HTTP request with text/plain content type + # + # @param method [String] HTTP method (PUT) + # @param path [String] API path + # @param public_key [String] API public key + # @param secret_key [String] API secret key + # @param body [String] Plain text request body + # @return [Hash] Parsed JSON response + # @raise [APIError] If request fails + def make_request_text(method, path, public_key, secret_key, body) + uri = URI.parse("#{API_BASE}#{path}") + timestamp = Time.now.to_i + + signature = sign_request(secret_key, timestamp, method, path, body) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = REQUEST_TIMEOUT + http.read_timeout = REQUEST_TIMEOUT + + headers = { + 'Authorization' => "Bearer #{public_key}", + 'X-Timestamp' => timestamp.to_s, + 'X-Signature' => signature, + 'Content-Type' => 'text/plain' + } + + response = case method + when 'PUT' + http.put(uri.request_uri, body, headers) + else + raise APIError, "Unsupported HTTP method for text: #{method}" + end + + unless response.is_a?(Net::HTTPSuccess) + raise APIError.new( + "API request failed: #{response.code} #{response.message}", + status_code: response.code.to_i, + response_body: response.body + ) + end + + JSON.parse(response.body) + rescue JSON::ParserError => e + raise APIError, "Invalid JSON response: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise APIError, "Request timeout: #{e.message}" + rescue StandardError => e + raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError) + + raise + end + # Get path to languages cache file # # @return [String] Path to languages.json diff --git a/clients/rust/async/src/lib.rs b/clients/rust/async/src/lib.rs index 28a0585..7b20a4f 100644 --- a/clients/rust/async/src/lib.rs +++ b/clients/rust/async/src/lib.rs @@ -250,6 +250,153 @@ pub struct RestoreResult { 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, + /// Custom domains + #[serde(default)] + pub domains: Vec, + /// 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, + /// 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, + /// Shell to use (e.g., "bash", "python3") + pub shell: Option, + /// Number of vCPUs (default: 1) + pub vcpu: Option, + /// Whether to use tmux multiplexer + pub tmux: Option, + /// Whether to use screen multiplexer + pub screen: Option, +} + +/// Options for creating a service +#[derive(Debug, Clone, Default)] +pub struct ServiceCreateOptions { + /// Network mode: "zerotrust" (default) or "semitrusted" + pub network_mode: Option, + /// Number of vCPUs (default: 1) + pub vcpu: Option, + /// Custom domains for the service + pub domains: Option>, + /// Bootstrap script content + pub bootstrap: Option, + /// Bootstrap script URL + pub bootstrap_url: Option, +} + +/// Options for updating a service +#[derive(Debug, Clone, Default)] +pub struct ServiceUpdateOptions { + /// New service name + pub name: Option, + /// New ports + pub ports: Option>, + /// New domains + pub domains: Option>, + /// New vCPU count + pub vcpu: Option, +} + // ============================================================================= // Internal Response Types // ============================================================================= @@ -304,6 +451,26 @@ struct LanguagesCache { timestamp: u64, } +#[derive(Debug, Deserialize)] +struct SessionsListResponse { + sessions: Vec, +} + +#[derive(Debug, Deserialize)] +struct ServicesListResponse { + services: Vec, +} + +#[derive(Debug, Deserialize)] +struct EnvResponse { + env: HashMap, +} + +#[derive(Debug, Deserialize)] +struct EnvExportResponse { + content: String, +} + // ============================================================================= // Language Detection // ============================================================================= @@ -632,6 +799,8 @@ async fn make_request Deserialize<'de>>( 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), }; @@ -989,6 +1158,672 @@ pub async fn delete_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<( Ok(()) } +/// Lock a snapshot to prevent deletion. +/// +/// # Arguments +/// * `snapshot_id` - Snapshot ID to lock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Snapshot information +pub async fn lock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result { + let path = format!("/snapshots/{}/lock", snapshot_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// Unlock a snapshot to allow deletion. +/// +/// # Arguments +/// * `snapshot_id` - Snapshot ID to unlock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Snapshot information +pub async fn unlock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result { + let path = format!("/snapshots/{}/unlock", snapshot_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// 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 async fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Result { + let path = format!("/snapshots/{}/clone", snapshot_id); + let body = serde_json::json!({ + "name": name + }); + make_request("POST", &path, creds, Some(&body)).await +} + +// ============================================================================= +// 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).await?; +/// for session in sessions { +/// println!("{}: {} ({})", session.session_id, session.container_name, session.status); +/// } +/// ``` +pub async fn list_sessions(creds: &Credentials) -> Result> { + let response: SessionsListResponse = make_request("GET", "/sessions", creds, None::<&()>).await?; + Ok(response.sessions) +} + +/// Get details of a specific session. +/// +/// # Arguments +/// * `session_id` - Session ID to retrieve +/// * `creds` - API credentials +/// +/// # Returns +/// Session information +pub async fn get_session(session_id: &str, creds: &Credentials) -> Result { + let path = format!("/sessions/{}", session_id); + make_request("GET", &path, creds, None::<&()>).await +} + +/// 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).await?; +/// +/// // 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)).await?; +/// ``` +pub async fn create_session( + language: &str, + creds: &Credentials, + opts: Option, +) -> Result { + 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)).await +} + +/// Delete (terminate) a session. +/// +/// # Arguments +/// * `session_id` - Session ID to delete +/// * `creds` - API credentials +pub async 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::<&()>).await?; + 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 async fn freeze_session(session_id: &str, creds: &Credentials) -> Result { + let path = format!("/sessions/{}/freeze", session_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// Unfreeze a frozen session to resume work. +/// +/// # Arguments +/// * `session_id` - Session ID to unfreeze +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Session information +pub async fn unfreeze_session(session_id: &str, creds: &Credentials) -> Result { + let path = format!("/sessions/{}/unfreeze", session_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// 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 async fn boost_session(session_id: &str, creds: &Credentials) -> Result { + let path = format!("/sessions/{}/boost", session_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// Remove boost from a session (return to base resources). +/// +/// # Arguments +/// * `session_id` - Session ID to unboost +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Session information +pub async fn unboost_session(session_id: &str, creds: &Credentials) -> Result { + let path = format!("/sessions/{}/unboost", session_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// 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).await?; +/// println!("Output: {}", result.output); +/// println!("Exit code: {}", result.exit_code); +/// ``` +pub async fn shell_session(session_id: &str, command: &str, creds: &Credentials) -> Result { + let path = format!("/sessions/{}/shell", session_id); + let body = serde_json::json!({ + "command": command + }); + make_request("POST", &path, creds, Some(&body)).await +} + +// ============================================================================= +// 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).await?; +/// for service in services { +/// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url); +/// } +/// ``` +pub async fn list_services(creds: &Credentials) -> Result> { + let response: ServicesListResponse = make_request("GET", "/services", creds, None::<&()>).await?; + 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 +/// ).await?; +/// 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)).await?; +/// ``` +pub async fn create_service( + name: &str, + ports: &[u16], + bootstrap: &str, + creds: &Credentials, + opts: Option, +) -> Result { + 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); + } + } + + make_request("POST", "/services", creds, Some(&body)).await +} + +/// Get details of a specific service. +/// +/// # Arguments +/// * `service_id` - Service ID to retrieve +/// * `creds` - API credentials +/// +/// # Returns +/// Service information +pub async fn get_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}", service_id); + make_request("GET", &path, creds, None::<&()>).await +} + +/// 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 async fn update_service( + service_id: &str, + creds: &Credentials, + opts: ServiceUpdateOptions, +) -> Result { + 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))).await +} + +/// Delete (destroy) a service. +/// +/// # Arguments +/// * `service_id` - Service ID to delete +/// * `creds` - API credentials +pub async fn delete_service(service_id: &str, creds: &Credentials) -> Result<()> { + let path = format!("/services/{}", service_id); + let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>).await?; + 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 async fn freeze_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/freeze", service_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// Unfreeze a frozen service to resume operation. +/// +/// # Arguments +/// * `service_id` - Service ID to unfreeze +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Service information +pub async fn unfreeze_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/unfreeze", service_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// Lock a service to prevent modifications. +/// +/// # Arguments +/// * `service_id` - Service ID to lock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Service information +pub async fn lock_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/lock", service_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// Unlock a service to allow modifications. +/// +/// # Arguments +/// * `service_id` - Service ID to unlock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Service information +pub async fn unlock_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/unlock", service_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// 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 async fn get_service_logs(service_id: &str, all: bool, creds: &Credentials) -> Result { + 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::<&()>).await?; + 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 async fn get_service_env(service_id: &str, creds: &Credentials) -> Result> { + let path = format!("/services/{}/env", service_id); + let response: EnvResponse = make_request("GET", &path, creds, None::<&()>).await?; + 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 async fn set_service_env( + service_id: &str, + env: &HashMap, + 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::>() + .join("\n"); + + // Use PUT with text/plain content type + let client = 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-async/2.0") + .body(content) + .send() + .await?; + + let status = response.status().as_u16(); + if status < 200 || status >= 300 { + let response_text = response.text().await?; + 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 async 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)).await?; + 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 async fn export_service_env(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/env/export", service_id); + let body = serde_json::json!({}); + let response: EnvExportResponse = make_request("POST", &path, creds, Some(&body)).await?; + 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 async fn redeploy_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/redeploy", service_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)).await +} + +/// 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).await?; +/// println!("Output: {}", result.output); +/// ``` +pub async fn execute_in_service( + service_id: &str, + command: &str, + creds: &Credentials, +) -> Result { + let path = format!("/services/{}/execute", service_id); + let body = serde_json::json!({ + "command": command, + "timeout": 30000 + }); + make_request("POST", &path, creds, Some(&body)).await +} + +// ============================================================================= +// 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).await?; +/// if result.valid { +/// println!("Keys valid for account: {}", result.account_id); +/// } else { +/// println!("Invalid keys: {}", result.error); +/// } +/// ``` +pub async fn validate_keys(creds: &Credentials) -> Result { + // Note: This endpoint is on the portal, not the API + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let url = "https://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-async/2.0") + .body("") + .send() + .await?; + + let status = response.status().as_u16(); + let response_text = response.text().await?; + + if status < 200 || status >= 300 { + return Err(UnsandboxError::ApiError { + status, + message: response_text, + }); + } + + let result: KeysValid = serde_json::from_str(&response_text)?; + Ok(result) +} + // ============================================================================= // Tests // ============================================================================= diff --git a/clients/rust/sync/src/lib.rs b/clients/rust/sync/src/lib.rs index 39d9b04..33565d5 100644 --- a/clients/rust/sync/src/lib.rs +++ b/clients/rust/sync/src/lib.rs @@ -245,6 +245,153 @@ pub struct RestoreResult { 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, + /// Custom domains + #[serde(default)] + pub domains: Vec, + /// 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, + /// 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, + /// Shell to use (e.g., "bash", "python3") + pub shell: Option, + /// Number of vCPUs (default: 1) + pub vcpu: Option, + /// Whether to use tmux multiplexer + pub tmux: Option, + /// Whether to use screen multiplexer + pub screen: Option, +} + +/// Options for creating a service +#[derive(Debug, Clone, Default)] +pub struct ServiceCreateOptions { + /// Network mode: "zerotrust" (default) or "semitrusted" + pub network_mode: Option, + /// Number of vCPUs (default: 1) + pub vcpu: Option, + /// Custom domains for the service + pub domains: Option>, + /// Bootstrap script content + pub bootstrap: Option, + /// Bootstrap script URL + pub bootstrap_url: Option, +} + +/// Options for updating a service +#[derive(Debug, Clone, Default)] +pub struct ServiceUpdateOptions { + /// New service name + pub name: Option, + /// New ports + pub ports: Option>, + /// New domains + pub domains: Option>, + /// New vCPU count + pub vcpu: Option, +} + // ============================================================================= // Internal Response Types // ============================================================================= @@ -299,6 +446,26 @@ struct LanguagesCache { timestamp: u64, } +#[derive(Debug, Deserialize)] +struct SessionsListResponse { + sessions: Vec, +} + +#[derive(Debug, Deserialize)] +struct ServicesListResponse { + services: Vec, +} + +#[derive(Debug, Deserialize)] +struct EnvResponse { + env: HashMap, +} + +#[derive(Debug, Deserialize)] +struct EnvExportResponse { + content: String, +} + // ============================================================================= // Language Detection // ============================================================================= @@ -549,6 +716,8 @@ fn make_request Deserialize<'de>>( 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), }; @@ -906,6 +1075,670 @@ pub fn delete_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<()> { 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 { + 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. +/// +/// # Arguments +/// * `snapshot_id` - Snapshot ID to unlock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Snapshot information +pub fn unlock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result { + let path = format!("/snapshots/{}/unlock", snapshot_id); + let body = serde_json::json!({}); + make_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 { + let path = format!("/snapshots/{}/clone", snapshot_id); + let body = serde_json::json!({ + "name": name + }); + 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> { + 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 { + 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, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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, +) -> Result { + 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); + } + } + + 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 { + 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 { + 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. +/// +/// # 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_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 { + 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 { + 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 { + 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. +/// +/// # Arguments +/// * `service_id` - Service ID to unlock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated Service information +pub fn unlock_service(service_id: &str, creds: &Credentials) -> Result { + let path = format!("/services/{}/unlock", service_id); + let body = serde_json::json!({}); + make_request("POST", &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 { + 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> { + 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, + 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::>() + .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 { + 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 { + let path = format!("/services/{}/redeploy", service_id); + let body = serde_json::json!({}); + 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 { + 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 { + // 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://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) +} + // ============================================================================= // Tests // =============================================================================