Add service environment vault to all 40 un-inception implementations

Implements encrypted vault for storing service environment variables:
- service env status <id> - Check vault status (GET /services/:id/env)
- service env set <id> -e KEY=VAL - Set vault contents (PUT /services/:id/env)
- service env export <id> - Export vault as .env format (POST /services/:id/env/export)
- service env delete <id> - Delete vault (DELETE /services/:id/env)
- Auto-vault on service creation with -e or --env-file flags

All implementations use HMAC-SHA256 authentication and text/plain content type
for vault PUT requests.
This commit is contained in:
Russell Ballestrini 2026-01-10 04:22:07 -05:00
parent 1b35f1099e
commit 87397949d1
40 changed files with 7523 additions and 291 deletions

292
Un.cs
View file

@ -338,6 +338,13 @@ class Un
{ {
var (publicKey, secretKey) = GetApiKeys(args.ApiKey); var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
// Handle env subcommand
if (!string.IsNullOrEmpty(args.EnvAction))
{
CmdServiceEnv(args, publicKey, secretKey);
return;
}
if (args.ServiceList) if (args.ServiceList)
{ {
var result = ApiRequest("/services", "GET", null, publicKey, secretKey); var result = ApiRequest("/services", "GET", null, publicKey, secretKey);
@ -496,12 +503,30 @@ class Un
} }
var result = ApiRequest("/services", "POST", payload, publicKey, secretKey); var result = ApiRequest("/services", "POST", payload, publicKey, secretKey);
Console.WriteLine($"{GREEN}Service created: {result["id"]}{RESET}"); string serviceId = result.ContainsKey("id") ? (string)result["id"] : null;
Console.WriteLine($"{GREEN}Service created: {serviceId}{RESET}");
Console.WriteLine($"Name: {result["name"]}"); Console.WriteLine($"Name: {result["name"]}");
if (result.ContainsKey("url")) if (result.ContainsKey("url"))
{ {
Console.WriteLine($"URL: {result["url"]}"); Console.WriteLine($"URL: {result["url"]}");
} }
// Auto-set vault if env vars were provided
if (!string.IsNullOrEmpty(serviceId) && (args.Env.Count > 0 || !string.IsNullOrEmpty(args.EnvFile)))
{
string envContent = BuildEnvContent(args.Env, args.EnvFile);
if (!string.IsNullOrEmpty(envContent))
{
if (ServiceEnvSet(serviceId, envContent, publicKey, secretKey))
{
Console.WriteLine($"{GREEN}Vault configured with environment variables{RESET}");
}
else
{
Console.Error.WriteLine($"{YELLOW}Warning: Failed to set vault{RESET}");
}
}
}
return; return;
} }
@ -634,6 +659,242 @@ class Un
} }
} }
static string ApiRequestText(string endpoint, string method, string body, string publicKey, string secretKey)
{
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint);
request.Method = method;
request.ContentType = "text/plain";
request.Timeout = 300000;
if (body == null) body = "";
// Add HMAC authentication headers
if (!string.IsNullOrEmpty(secretKey))
{
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string message = $"{timestamp}:{method}:{endpoint}:{body}";
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)))
{
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
string signature = BitConverter.ToString(hash).Replace("-", "").ToLower();
request.Headers.Add("Authorization", $"Bearer {publicKey}");
request.Headers.Add("X-Timestamp", timestamp.ToString());
request.Headers.Add("X-Signature", signature);
}
}
else
{
request.Headers.Add("Authorization", $"Bearer {publicKey}");
}
if (!string.IsNullOrEmpty(body))
{
byte[] bytes = Encoding.UTF8.GetBytes(body);
request.ContentLength = bytes.Length;
using (Stream stream = request.GetRequestStream())
{
stream.Write(bytes, 0, bytes.Length);
}
}
try
{
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
{
using (StreamReader reader = new StreamReader(response.GetResponseStream()))
{
return reader.ReadToEnd();
}
}
}
catch (WebException ex)
{
string error = "";
if (ex.Response != null)
{
using (StreamReader reader = new StreamReader(ex.Response.GetResponseStream()))
{
error = reader.ReadToEnd();
}
}
throw new Exception($"HTTP error - {error}");
}
}
static string ReadEnvFile(string path)
{
if (!File.Exists(path))
{
throw new Exception($"Env file not found: {path}");
}
return File.ReadAllText(path);
}
static string BuildEnvContent(List<string> envs, string envFile)
{
var lines = new List<string>();
// Add from -e flags
foreach (var env in envs)
{
lines.Add(env);
}
// Add from --env-file
if (!string.IsNullOrEmpty(envFile))
{
string content = ReadEnvFile(envFile);
foreach (var line in content.Split('\n'))
{
string trimmed = line.Trim();
if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith("#"))
{
lines.Add(trimmed);
}
}
}
return string.Join("\n", lines);
}
static Dictionary<string, object> ServiceEnvStatus(string serviceId, string publicKey, string secretKey)
{
return ApiRequest($"/services/{serviceId}/env", "GET", null, publicKey, secretKey);
}
static bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string secretKey)
{
const int MAX_ENV_CONTENT_SIZE = 65536;
if (envContent.Length > MAX_ENV_CONTENT_SIZE)
{
Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}");
return false;
}
try
{
ApiRequestText($"/services/{serviceId}/env", "PUT", envContent, publicKey, secretKey);
return true;
}
catch
{
return false;
}
}
static Dictionary<string, object> ServiceEnvExport(string serviceId, string publicKey, string secretKey)
{
return ApiRequest($"/services/{serviceId}/env/export", "POST", null, publicKey, secretKey);
}
static bool ServiceEnvDelete(string serviceId, string publicKey, string secretKey)
{
try
{
ApiRequest($"/services/{serviceId}/env", "DELETE", null, publicKey, secretKey);
return true;
}
catch
{
return false;
}
}
static void CmdServiceEnv(Args args, string publicKey, string secretKey)
{
string action = args.EnvAction;
string target = args.EnvTarget;
if (action == "status")
{
if (string.IsNullOrEmpty(target))
{
Console.Error.WriteLine($"{RED}Error: service env status requires service ID{RESET}");
Environment.Exit(1);
}
var result = ServiceEnvStatus(target, publicKey, secretKey);
if (result.ContainsKey("has_vault") && (bool)result["has_vault"])
{
Console.WriteLine($"{GREEN}Vault: configured{RESET}");
if (result.ContainsKey("env_count"))
{
Console.WriteLine($"Variables: {result["env_count"]}");
}
if (result.ContainsKey("updated_at"))
{
Console.WriteLine($"Updated: {result["updated_at"]}");
}
}
else
{
Console.WriteLine($"{YELLOW}Vault: not configured{RESET}");
}
}
else if (action == "set")
{
if (string.IsNullOrEmpty(target))
{
Console.Error.WriteLine($"{RED}Error: service env set requires service ID{RESET}");
Environment.Exit(1);
}
if (args.Env.Count == 0 && string.IsNullOrEmpty(args.EnvFile))
{
Console.Error.WriteLine($"{RED}Error: service env set requires -e or --env-file{RESET}");
Environment.Exit(1);
}
string envContent = BuildEnvContent(args.Env, args.EnvFile);
if (ServiceEnvSet(target, envContent, publicKey, secretKey))
{
Console.WriteLine($"{GREEN}Vault updated for service {target}{RESET}");
}
else
{
Console.Error.WriteLine($"{RED}Error: Failed to update vault{RESET}");
Environment.Exit(1);
}
}
else if (action == "export")
{
if (string.IsNullOrEmpty(target))
{
Console.Error.WriteLine($"{RED}Error: service env export requires service ID{RESET}");
Environment.Exit(1);
}
var result = ServiceEnvExport(target, publicKey, secretKey);
if (result.ContainsKey("content"))
{
Console.Write(result["content"]);
}
}
else if (action == "delete")
{
if (string.IsNullOrEmpty(target))
{
Console.Error.WriteLine($"{RED}Error: service env delete requires service ID{RESET}");
Environment.Exit(1);
}
if (ServiceEnvDelete(target, publicKey, secretKey))
{
Console.WriteLine($"{GREEN}Vault deleted for service {target}{RESET}");
}
else
{
Console.Error.WriteLine($"{RED}Error: Failed to delete vault{RESET}");
Environment.Exit(1);
}
}
else
{
Console.Error.WriteLine($"{RED}Error: Unknown env action: {action}{RESET}");
Console.Error.WriteLine("Usage: Un service env <status|set|export|delete> <service_id>");
Environment.Exit(1);
}
}
static string ToJson(object obj) static string ToJson(object obj)
{ {
if (obj == null) return "null"; if (obj == null) return "null";
@ -884,6 +1145,9 @@ class Un
public string ServiceCommand = null; public string ServiceCommand = null;
public string ServiceDumpBootstrap = null; public string ServiceDumpBootstrap = null;
public string ServiceDumpFile = null; public string ServiceDumpFile = null;
public string EnvFile = null;
public string EnvAction = null;
public string EnvTarget = null;
public bool KeyExtend = false; public bool KeyExtend = false;
} }
@ -896,10 +1160,23 @@ class Un
if (arg == "session") result.Command = "session"; if (arg == "session") result.Command = "session";
else if (arg == "service") result.Command = "service"; else if (arg == "service") result.Command = "service";
else if (arg == "key") result.Command = "key"; else if (arg == "key") result.Command = "key";
else if (arg == "env" && result.Command == "service")
{
// Parse: service env <action> <target>
if (i + 1 < args.Length && !args[i + 1].StartsWith("-"))
{
result.EnvAction = args[++i];
if (i + 1 < args.Length && !args[i + 1].StartsWith("-"))
{
result.EnvTarget = args[++i];
}
}
}
else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i]; else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i];
else if (arg == "-n" || arg == "--network") result.Network = args[++i]; else if (arg == "-n" || arg == "--network") result.Network = args[++i];
else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]); else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]);
else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]); else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]);
else if (arg == "--env-file") result.EnvFile = args[++i];
else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]); else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]);
else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true; else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true;
else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i]; else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i];
@ -935,6 +1212,7 @@ class Un
Console.WriteLine(@"Usage: Un [options] <source_file> Console.WriteLine(@"Usage: Un [options] <source_file>
Un session [options] Un session [options]
Un service [options] Un service [options]
Un service env <action> <service_id> [options]
Un key [options] Un key [options]
Execute options: Execute options:
@ -960,13 +1238,21 @@ Service options:
--info ID Get service details --info ID Get service details
--logs ID Get all logs --logs ID Get all logs
--tail ID Get last 9000 lines --tail ID Get last 9000 lines
--freeze ID Freeze service --freeze ID Freeze service
--unfreeze ID Unfreeze service --unfreeze ID Unfreeze service
--destroy ID Destroy service --destroy ID Destroy service
--execute ID Execute command in service --execute ID Execute command in service
--command CMD Command to execute (with --execute) --command CMD Command to execute (with --execute)
--dump-bootstrap ID Dump bootstrap script --dump-bootstrap ID Dump bootstrap script
--dump-file FILE File to save bootstrap (with --dump-bootstrap) --dump-file FILE File to save bootstrap (with --dump-bootstrap)
-e KEY=VALUE Set vault env var (with --name or env set)
--env-file FILE Load vault vars from file
Service env commands:
env status ID Check vault status
env set ID Set vault (use -e or --env-file)
env export ID Export vault contents
env delete ID Delete vault
Key options: Key options:
--extend Open browser to extend expired key"); --extend Open browser to extend expired key");

179
Un.java
View file

@ -234,6 +234,12 @@ public class Un {
String publicKey = keys[0]; String publicKey = keys[0];
String secretKey = keys[1]; String secretKey = keys[1];
// Handle service env subcommand
if (args.envAction != null) {
cmdServiceEnv(args, publicKey, secretKey);
return;
}
if (args.serviceList) { if (args.serviceList) {
Map<String, Object> result = apiRequest("/services", "GET", null, publicKey, secretKey); Map<String, Object> result = apiRequest("/services", "GET", null, publicKey, secretKey);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@ -374,11 +380,25 @@ public class Un {
} }
Map<String, Object> result = apiRequest("/services", "POST", payload, publicKey, secretKey); Map<String, Object> result = apiRequest("/services", "POST", payload, publicKey, secretKey);
System.out.println(GREEN + "Service created: " + result.getOrDefault("id", "N/A") + RESET); String serviceId = (String) result.get("id");
System.out.println(GREEN + "Service created: " + (serviceId != null ? serviceId : "N/A") + RESET);
System.out.println("Name: " + result.getOrDefault("name", "N/A")); System.out.println("Name: " + result.getOrDefault("name", "N/A"));
if (result.containsKey("url")) { if (result.containsKey("url")) {
System.out.println("URL: " + result.get("url")); System.out.println("URL: " + result.get("url"));
} }
// Auto-set vault if env vars provided
if (serviceId != null && (!args.env.isEmpty() || (args.envFile != null && !args.envFile.isEmpty()))) {
try {
String envContent = buildEnvContent(args.env, args.envFile);
if (!envContent.isEmpty() && envContent.length() <= 65536) {
serviceEnvSet(serviceId, envContent, publicKey, secretKey);
System.out.println(GREEN + "Vault configured with environment variables" + RESET);
}
} catch (Exception e) {
System.err.println(YELLOW + "Warning: Failed to set vault: " + e.getMessage() + RESET);
}
}
return; return;
} }
@ -599,6 +619,141 @@ public class Un {
return parseJson(response); return parseJson(response);
} }
private static String apiRequestText(String endpoint, String method, String body, String publicKey, String secretKey) throws Exception {
long timestamp = System.currentTimeMillis() / 1000;
String signatureData = timestamp + ":" + method + ":" + endpoint + ":" + body;
String signature = hmacSha256(secretKey, signatureData);
URL url = new URL(API_BASE + endpoint);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setRequestProperty("Authorization", "Bearer " + (publicKey != null ? publicKey : secretKey));
conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp));
conn.setRequestProperty("X-Signature", signature);
conn.setRequestProperty("Content-Type", "text/plain");
conn.setConnectTimeout(30000);
conn.setReadTimeout(300000);
if (body != null && !body.isEmpty()) {
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes("UTF-8"));
}
}
int status = conn.getResponseCode();
if (status < 200 || status >= 300) {
String error = readStream(conn.getErrorStream());
throw new Exception("HTTP " + status + " - " + error);
}
return readStream(conn.getInputStream());
}
private static String readEnvFile(String path) throws Exception {
return new String(Files.readAllBytes(Paths.get(path)), "UTF-8");
}
private static String buildEnvContent(List<String> envs, String envFile) throws Exception {
StringBuilder parts = new StringBuilder();
if (envFile != null && !envFile.isEmpty()) {
parts.append(readEnvFile(envFile).trim());
}
for (String e : envs) {
if (e.contains("=")) {
if (parts.length() > 0) parts.append("\n");
parts.append(e);
}
}
return parts.toString();
}
private static Map<String, Object> serviceEnvStatus(String serviceId, String publicKey, String secretKey) throws Exception {
return apiRequest("/services/" + serviceId + "/env", "GET", null, publicKey, secretKey);
}
private static boolean serviceEnvSet(String serviceId, String envContent, String publicKey, String secretKey) throws Exception {
apiRequestText("/services/" + serviceId + "/env", "PUT", envContent, publicKey, secretKey);
return true;
}
private static Map<String, Object> serviceEnvExport(String serviceId, String publicKey, String secretKey) throws Exception {
return apiRequest("/services/" + serviceId + "/env/export", "POST", null, publicKey, secretKey);
}
private static boolean serviceEnvDelete(String serviceId, String publicKey, String secretKey) throws Exception {
apiRequest("/services/" + serviceId + "/env", "DELETE", null, publicKey, secretKey);
return true;
}
private static void cmdServiceEnv(Args args, String publicKey, String secretKey) throws Exception {
String action = args.envAction;
String target = args.envTarget;
if (action == null) {
System.err.println(RED + "Error: Usage: service env <status|set|export|delete> <service_id>" + RESET);
System.exit(1);
}
if (action.equals("status")) {
if (target == null) {
System.err.println(RED + "Error: Usage: service env status <service_id>" + RESET);
System.exit(1);
}
Map<String, Object> result = serviceEnvStatus(target, publicKey, secretKey);
Boolean hasEnv = (Boolean) result.get("has_env");
Number size = (Number) result.get("size");
String updatedAt = (String) result.get("updated_at");
System.out.println("Service: " + target);
System.out.println("Has Vault: " + (hasEnv != null && hasEnv ? "Yes" : "No"));
if (hasEnv != null && hasEnv) {
System.out.println("Size: " + (size != null ? size.intValue() : 0) + " bytes");
System.out.println("Updated: " + (updatedAt != null ? updatedAt : "N/A"));
}
} else if (action.equals("set")) {
if (target == null) {
System.err.println(RED + "Error: Usage: service env set <service_id> [-e KEY=VAL] [--env-file FILE]" + RESET);
System.exit(1);
}
String envContent = buildEnvContent(args.env, args.envFile);
if (envContent.isEmpty()) {
System.err.println(RED + "Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE" + RESET);
System.exit(1);
}
if (envContent.length() > 65536) {
System.err.println(RED + "Error: Environment content exceeds 64KB limit" + RESET);
System.exit(1);
}
serviceEnvSet(target, envContent, publicKey, secretKey);
System.out.println(GREEN + "Vault updated for service: " + target + RESET);
} else if (action.equals("export")) {
if (target == null) {
System.err.println(RED + "Error: Usage: service env export <service_id>" + RESET);
System.exit(1);
}
Map<String, Object> result = serviceEnvExport(target, publicKey, secretKey);
String content = (String) result.get("content");
if (content != null && !content.isEmpty()) {
System.out.print(content);
if (!content.endsWith("\n")) {
System.out.println();
}
} else {
System.err.println(YELLOW + "Vault is empty" + RESET);
}
} else if (action.equals("delete")) {
if (target == null) {
System.err.println(RED + "Error: Usage: service env delete <service_id>" + RESET);
System.exit(1);
}
serviceEnvDelete(target, publicKey, secretKey);
System.out.println(GREEN + "Vault deleted for service: " + target + RESET);
} else {
System.err.println(RED + "Error: Unknown env action: " + action + ". Use status, set, export, or delete" + RESET);
System.exit(1);
}
}
private static String readStream(InputStream is) throws IOException { private static String readStream(InputStream is) throws IOException {
if (is == null) return ""; if (is == null) return "";
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@ -810,6 +965,11 @@ public class Un {
String serviceDumpBootstrap = null; String serviceDumpBootstrap = null;
String serviceDumpFile = null; String serviceDumpFile = null;
// Vault args
String envFile = null;
String envAction = null;
String envTarget = null;
// Key args // Key args
boolean keyExtend = false; boolean keyExtend = false;
} }
@ -873,6 +1033,16 @@ public class Un {
result.serviceDumpBootstrap = args[++i]; result.serviceDumpBootstrap = args[++i];
} else if (arg.equals("--dump-file")) { } else if (arg.equals("--dump-file")) {
result.serviceDumpFile = args[++i]; result.serviceDumpFile = args[++i];
} else if (arg.equals("--env-file")) {
result.envFile = args[++i];
} else if (arg.equals("env") && "service".equals(result.command)) {
// service env <action> <target>
if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
result.envAction = args[++i];
if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
result.envTarget = args[++i];
}
}
} else if (arg.equals("--extend")) { } else if (arg.equals("--extend")) {
result.keyExtend = true; result.keyExtend = true;
} else if (!arg.startsWith("-")) { } else if (!arg.startsWith("-")) {
@ -923,6 +1093,13 @@ public class Un {
System.out.println(" --dump-bootstrap ID Dump bootstrap script"); System.out.println(" --dump-bootstrap ID Dump bootstrap script");
System.out.println(" --dump-file FILE File to save bootstrap (with --dump-bootstrap)"); System.out.println(" --dump-file FILE File to save bootstrap (with --dump-bootstrap)");
System.out.println(); System.out.println();
System.out.println("Service env (vault) commands:");
System.out.println(" service env status ID Check vault status");
System.out.println(" service env set ID [-e K=V] Set vault contents");
System.out.println(" service env export ID Export vault contents");
System.out.println(" service env delete ID Delete vault");
System.out.println(" --env-file FILE Read env vars from file");
System.out.println();
System.out.println("Key options:"); System.out.println("Key options:");
System.out.println(" --extend Open browser to extend key"); System.out.println(" --extend Open browser to extend key");
} }

220
un.awk
View file

@ -409,7 +409,7 @@ function session_create(shell, network, vcpu, input_files , json, tmp, timest
print response print response
} }
function service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, boot_content, line, input_files_json) { function service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, boot_content, line, input_files_json, response) {
get_api_keys() get_api_keys()
# Build JSON payload # Build JSON payload
@ -495,6 +495,12 @@ function service_create(name, ports, domains, service_type, bootstrap, bootstrap
# Clean up # Clean up
system("rm -f " tmp) system("rm -f " tmp)
# Extract service ID for auto-vault
LAST_SERVICE_ID = ""
if (match(response, /"id":"([^"]+)"/, arr)) {
LAST_SERVICE_ID = arr[1]
}
# Print response # Print response
print response print response
} }
@ -833,6 +839,141 @@ function service_restore(snapshot_id , endpoint, json, tmp, timestamp, sig_he
print GREEN "Service restored from snapshot" RESET print GREEN "Service restored from snapshot" RESET
} }
# Build env content from env_vars array and env_file
function build_env_content(env_vars_str, env_file , content, n, vars, i, line) {
content = ""
# Parse comma-separated env vars
if (env_vars_str != "") {
n = split(env_vars_str, vars, ",")
for (i = 1; i <= n; i++) {
if (content != "") content = content "\n"
content = content vars[i]
}
}
# Read env file if provided
if (env_file != "") {
while ((getline line < env_file) > 0) {
# Skip empty lines and comments
if (line ~ /^[[:space:]]*$/) continue
if (line ~ /^[[:space:]]*#/) continue
if (content != "") content = content "\n"
content = content line
}
close(env_file)
}
return content
}
function service_env_status(id , endpoint, timestamp, sig_headers, signature, sig_input, sig_cmd, line) {
get_api_keys()
endpoint = "/services/" id "/env"
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":GET:" endpoint ":"
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
}
cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
while ((cmd | getline line) > 0) print line
close(cmd)
}
function service_env_set(id, content , endpoint, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
get_api_keys()
endpoint = "/services/" id "/env"
# Write content to temp file
tmp = "/tmp/un_awk_env_" PROCINFO["pid"] ".txt"
print content > tmp
close(tmp)
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":PUT:" endpoint ":" content
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
}
cmd = "curl -s -X PUT '" API_BASE endpoint "' " \
"-H 'Content-Type: text/plain' " \
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
sig_headers \
"--data-binary '@" tmp "'"
response = ""
while ((cmd | getline line) > 0) {
response = response line
}
close(cmd)
system("rm -f " tmp)
print response
}
function service_env_export(id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
get_api_keys()
endpoint = "/services/" id "/env/export"
json = "{}"
tmp = "/tmp/un_awk_envexp_" PROCINFO["pid"] ".json"
print json > tmp
close(tmp)
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":POST:" endpoint ":" json
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
}
cmd = "curl -s -X POST '" API_BASE endpoint "' " \
"-H 'Content-Type: application/json' " \
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
sig_headers \
"-d '@" tmp "'"
response = ""
while ((cmd | getline line) > 0) {
response = response line
}
close(cmd)
system("rm -f " tmp)
# Extract content field from response
if (match(response, /"content":"([^"]*)"/, arr)) {
content = arr[1]
gsub(/\\n/, "\n", content)
printf "%s", content
} else {
print response
}
}
function service_env_delete(id , endpoint, timestamp, sig_headers, signature, sig_input, sig_cmd) {
get_api_keys()
endpoint = "/services/" id "/env"
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":DELETE:" endpoint ":"
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
}
cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
system(cmd)
print GREEN "Vault deleted: " id RESET
}
function show_help() { function show_help() {
print "Usage: awk -f un.awk <source_file>" print "Usage: awk -f un.awk <source_file>"
print " awk -f un.awk session --list" print " awk -f un.awk session --list"
@ -842,11 +983,15 @@ function show_help() {
print " awk -f un.awk session --restore SNAPSHOT_ID" print " awk -f un.awk session --restore SNAPSHOT_ID"
print " awk -f un.awk key [--extend]" print " awk -f un.awk key [--extend]"
print " awk -f un.awk service --list" print " awk -f un.awk service --list"
print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD] [-f FILE]..." print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD] [-e KEY=VAL] [--env-file FILE] [-f FILE]..."
print " awk -f un.awk service --destroy ID" print " awk -f un.awk service --destroy ID"
print " awk -f un.awk service --dump-bootstrap ID [--dump-file FILE]" print " awk -f un.awk service --dump-bootstrap ID [--dump-file FILE]"
print " awk -f un.awk service --snapshot SERVICE_ID [--snapshot-name NAME] [--hot]" print " awk -f un.awk service --snapshot SERVICE_ID [--snapshot-name NAME] [--hot]"
print " awk -f un.awk service --restore SNAPSHOT_ID" print " awk -f un.awk service --restore SNAPSHOT_ID"
print " awk -f un.awk service env status ID"
print " awk -f un.awk service env set ID [-e KEY=VAL]... [--env-file FILE]"
print " awk -f un.awk service env export ID"
print " awk -f un.awk service env delete ID"
print " awk -f un.awk snapshot --list" print " awk -f un.awk snapshot --list"
print " awk -f un.awk snapshot --info ID" print " awk -f un.awk snapshot --info ID"
print " awk -f un.awk snapshot --delete ID" print " awk -f un.awk snapshot --delete ID"
@ -867,12 +1012,20 @@ function show_help() {
print " --bootstrap CMD Bootstrap command or script" print " --bootstrap CMD Bootstrap command or script"
print " --dump-bootstrap ID Dump bootstrap script from service" print " --dump-bootstrap ID Dump bootstrap script from service"
print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)" print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)"
print " -e KEY=VAL Environment variable for vault (can be repeated)"
print " --env-file FILE Load env vars from file for vault"
print " -f FILE Input file to upload (can be repeated)" print " -f FILE Input file to upload (can be repeated)"
print " --snapshot SERVICE_ID Create snapshot of service" print " --snapshot SERVICE_ID Create snapshot of service"
print " --restore SNAPSHOT_ID Restore from snapshot ID" print " --restore SNAPSHOT_ID Restore from snapshot ID"
print " --snapshot-name N Name for snapshot" print " --snapshot-name N Name for snapshot"
print " --hot Take snapshot without freezing (live snapshot)" print " --hot Take snapshot without freezing (live snapshot)"
print "" print ""
print "Vault options (service env):"
print " status ID Check vault status"
print " set ID Set vault contents"
print " export ID Export vault contents"
print " delete ID Delete vault"
print ""
print "Snapshot options:" print "Snapshot options:"
print " -l, --list List all snapshots" print " -l, --list List all snapshots"
print " --info ID Get snapshot details" print " --info ID Get snapshot details"
@ -1013,6 +1166,50 @@ END {
} else if (ARGC >= 4 && ARGV[2] == "--restore") { } else if (ARGC >= 4 && ARGV[2] == "--restore") {
# --restore takes snapshot ID directly # --restore takes snapshot ID directly
service_restore(ARGV[3]) service_restore(ARGV[3])
} else if (ARGC >= 4 && ARGV[2] == "env") {
# Service vault commands: service env <action> <id> [options]
env_action = ARGV[3]
if (ARGC < 5) {
print RED "Error: service env requires action and service ID" RESET > "/dev/stderr"
print "Usage: awk -f un.awk service env <status|set|export|delete> <service_id> [options]" > "/dev/stderr"
exit 1
}
env_service_id = ARGV[4]
if (env_action == "status") {
service_env_status(env_service_id)
} else if (env_action == "set") {
# Parse -e and --env-file options
env_vars = ""
env_file = ""
i = 5
while (i < ARGC) {
if (ARGV[i] == "-e" && i + 1 < ARGC) {
if (env_vars != "") env_vars = env_vars ","
env_vars = env_vars ARGV[i + 1]
i += 2
} else if (ARGV[i] == "--env-file" && i + 1 < ARGC) {
env_file = ARGV[i + 1]
i += 2
} else {
i++
}
}
env_content = build_env_content(env_vars, env_file)
if (env_content == "") {
print RED "Error: No environment variables to set. Use -e KEY=VALUE or --env-file FILE" RESET > "/dev/stderr"
exit 1
}
service_env_set(env_service_id, env_content)
} else if (env_action == "export") {
service_env_export(env_service_id)
} else if (env_action == "delete") {
service_env_delete(env_service_id)
} else {
print RED "Unknown env action: " env_action RESET > "/dev/stderr"
print "Usage: awk -f un.awk service env <status|set|export|delete> <service_id>" > "/dev/stderr"
exit 1
}
} else if (ARGV[2] == "--create") { } else if (ARGV[2] == "--create") {
# Parse service creation arguments # Parse service creation arguments
name = "" name = ""
@ -1022,6 +1219,8 @@ END {
bootstrap = "" bootstrap = ""
bootstrap_file = "" bootstrap_file = ""
input_files = "" input_files = ""
env_vars = ""
env_file = ""
i = 3 i = 3
while (i < ARGC) { while (i < ARGC) {
@ -1047,6 +1246,13 @@ END {
if (input_files != "") input_files = input_files "," if (input_files != "") input_files = input_files ","
input_files = input_files ARGV[i + 1] input_files = input_files ARGV[i + 1]
i += 2 i += 2
} else if (ARGV[i] == "-e" && i + 1 < ARGC) {
if (env_vars != "") env_vars = env_vars ","
env_vars = env_vars ARGV[i + 1]
i += 2
} else if (ARGV[i] == "--env-file" && i + 1 < ARGC) {
env_file = ARGV[i + 1]
i += 2
} else { } else {
i++ i++
} }
@ -1058,6 +1264,16 @@ END {
} }
service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files) service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files)
# Auto-set vault if env vars were provided
env_content = build_env_content(env_vars, env_file)
if (env_content != "") {
# Extract service ID from response (stored in LAST_SERVICE_ID global)
if (LAST_SERVICE_ID != "") {
print YELLOW "Setting vault for service..." RESET
service_env_set(LAST_SERVICE_ID, env_content)
}
}
} else { } else {
print "Usage: awk -f un.awk service --list|--create|--destroy ID" print "Usage: awk -f un.awk service --list|--create|--destroy ID"
} }

209
un.clj
View file

@ -202,6 +202,121 @@
(check-clock-drift-error result) (check-clock-drift-error result)
result)) result))
(defn curl-put-text [endpoint body]
(let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".txt")
[public-key secret-key] (get-api-keys)
auth-headers (build-auth-headers public-key secret-key "PUT" endpoint body)]
(spit tmp-file body)
(let [args (concat ["curl" "-s" "-o" "/dev/null" "-w" "%{http_code}" "-X" "PUT"
(str "https://api.unsandbox.com" endpoint)
"-H" "Content-Type: text/plain"]
auth-headers
["-d" (str "@" tmp-file)])
{:keys [out]} (apply sh args)]
(io/delete-file tmp-file true)
(let [status (Integer/parseInt (str/trim out))]
(and (>= status 200) (< status 300))))))
(def max-env-content-size 65536)
(defn read-env-file [path]
(if (.exists (io/file path))
(slurp path)
(do
(binding [*out* *err*]
(println (str red "Error: Env file not found: " path reset)))
(System/exit 1))))
(defn build-env-content [envs env-file]
(let [file-lines (if env-file
(->> (str/split (read-env-file env-file) #"\n")
(map str/trim)
(filter #(and (> (count %) 0) (not (.startsWith % "#")))))
[])]
(str/join "\n" (concat envs file-lines))))
(defn service-env-status [service-id]
(let [api-key (get-api-key)]
(curl-get api-key (str "/services/" service-id "/env"))))
(defn service-env-set [service-id env-content]
(if (> (count env-content) max-env-content-size)
(do
(binding [*out* *err*]
(println (str red "Error: Env content exceeds maximum size of 64KB" reset)))
false)
(curl-put-text (str "/services/" service-id "/env") env-content)))
(defn service-env-export [service-id]
(let [api-key (get-api-key)]
(curl-post api-key (str "/services/" service-id "/env/export") "{}")))
(defn service-env-delete [service-id]
(let [api-key (get-api-key)]
(try
(curl-delete api-key (str "/services/" service-id "/env"))
true
(catch Exception _ false))))
(defn service-env-command [action target envs env-file]
(case action
"status" (if target
(let [response (service-env-status target)
has-vault (= (extract-field "has_vault" response) "true")]
(if has-vault
(do
(println (str green "Vault: configured" reset))
(when-let [env-count (extract-field "env_count" response)]
(println (str "Variables: " env-count)))
(when-let [updated-at (extract-field "updated_at" response)]
(println (str "Updated: " updated-at))))
(println (str yellow "Vault: not configured" reset))))
(do
(binding [*out* *err*]
(println (str red "Error: service env status requires service ID" reset)))
(System/exit 1)))
"set" (if target
(if (and (empty? envs) (nil? env-file))
(do
(binding [*out* *err*]
(println (str red "Error: service env set requires -e or --env-file" reset)))
(System/exit 1))
(let [env-content (build-env-content envs env-file)]
(if (service-env-set target env-content)
(println (str green "Vault updated for service " target reset))
(do
(binding [*out* *err*]
(println (str red "Error: Failed to update vault" reset)))
(System/exit 1)))))
(do
(binding [*out* *err*]
(println (str red "Error: service env set requires service ID" reset)))
(System/exit 1)))
"export" (if target
(let [response (service-env-export target)
content (extract-field "content" response)]
(when content (print (unescape-json content))))
(do
(binding [*out* *err*]
(println (str red "Error: service env export requires service ID" reset)))
(System/exit 1)))
"delete" (if target
(if (service-env-delete target)
(println (str green "Vault deleted for service " target reset))
(do
(binding [*out* *err*]
(println (str red "Error: Failed to delete vault" reset)))
(System/exit 1)))
(do
(binding [*out* *err*]
(println (str red "Error: service env delete requires service ID" reset)))
(System/exit 1)))
(do
(binding [*out* *err*]
(println (str red "Error: Unknown env action: " action reset))
(println "Usage: un.clj service env <status|set|export|delete> <service_id>"))
(System/exit 1))))
(defn curl-portal-post [api-key endpoint json-data] (defn curl-portal-post [api-key endpoint json-data]
(let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json") (let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json")
[public-key secret-key] (get-api-keys) [public-key secret-key] (get-api-keys)
@ -265,9 +380,10 @@
(println (str yellow "Session created (WebSocket required)" reset)) (println (str yellow "Session created (WebSocket required)" reset))
(println (curl-post api-key "/sessions" json)))))) (println (curl-post api-key "/sessions" json))))))
(defn service-command [action sid name ports bootstrap bootstrap-file service-type network vcpu input-files] (defn service-command [action sid name ports bootstrap bootstrap-file service-type network vcpu input-files envs env-file]
(let [api-key (get-api-key)] (let [api-key (get-api-key)]
(case action (case action
:env (service-env-command sid name envs env-file)
:list (println (curl-get api-key "/services")) :list (println (curl-get api-key "/services"))
:info (println (curl-get api-key (str "/services/" sid))) :info (println (curl-get api-key (str "/services/" sid)))
:logs (println (curl-get api-key (str "/services/" sid "/logs"))) :logs (println (curl-get api-key (str "/services/" sid "/logs")))
@ -315,9 +431,18 @@
network-json (if network (str ",\"network\":\"" network "\"") "") network-json (if network (str ",\"network\":\"" network "\"") "")
vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "") vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "")
input-files-json (build-input-files-json input-files) input-files-json (build-input-files-json input-files)
json (str "{\"name\":\"" name "\"" ports-json bootstrap-json bootstrap-content-json service-type-json network-json vcpu-json input-files-json "}")] json (str "{\"name\":\"" name "\"" ports-json bootstrap-json bootstrap-content-json service-type-json network-json vcpu-json input-files-json "}")
response (curl-post api-key "/services" json)
service-id (extract-field "id" response)]
(println (str green "Service created" reset)) (println (str green "Service created" reset))
(println (curl-post api-key "/services" json))))))) (println response)
;; Auto-set vault if env vars were provided
(when (and service-id (or (seq envs) env-file))
(let [env-content (build-env-content envs env-file)]
(when (> (count env-content) 0)
(if (service-env-set service-id env-content)
(println (str green "Vault configured with environment variables" reset))
(println (str yellow "Warning: Failed to set vault" reset))))))))))))
(defn validate-key [api-key extend?] (defn validate-key [api-key extend?]
(let [response (curl-portal-post api-key "/keys/validate" "{}") (let [response (curl-portal-post api-key "/keys/validate" "{}")
@ -388,33 +513,36 @@
service-bootstrap-file nil service-bootstrap-file nil
service-type nil service-type nil
service-input-files [] service-input-files []
service-envs []
service-env-file nil
key-extend false key-extend false
mode :execute] mode :execute]
(cond (cond
(empty? args) (empty? args)
(case mode (case mode
:session (session-command (or session-action :create) session-id session-shell network vcpu session-input-files) :session (session-command (or session-action :create) session-id session-shell network vcpu session-input-files)
:service (service-command (or service-action :create) service-id service-name service-ports service-bootstrap service-bootstrap-file service-type network vcpu service-input-files) :service (service-command (or service-action :create) service-id service-name service-ports service-bootstrap service-bootstrap-file service-type network vcpu service-input-files service-envs service-env-file)
:key (key-command key-extend) :key (key-command key-extend)
:execute (if file :execute (if file
(execute-command file env-vars artifacts out-dir network vcpu) (execute-command file env-vars artifacts out-dir network vcpu)
(do (println "Usage: un.clj [options] <source_file>") (do (println "Usage: un.clj [options] <source_file>")
(println " un.clj session [options]") (println " un.clj session [options]")
(println " un.clj service [options]") (println " un.clj service [options]")
(println " un.clj service env <action> <service_id>")
(println " un.clj key [options]") (println " un.clj key [options]")
(System/exit 1)))) (System/exit 1))))
(= (first args) "session") (= (first args) "session")
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :session) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend :session)
(= (first args) "service") (= (first args) "service")
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :service) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend :service)
(= (first args) "key") (= (first args) "key")
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :key) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend :key)
;; Key options ;; Key options
(and (= mode :key) (= (first args) "--extend")) (and (= mode :key) (= (first args) "--extend"))
@ -424,107 +552,122 @@
;; Session options ;; Session options
(and (= mode :session) (= (first args) "--list")) (and (= mode :session) (= (first args) "--list"))
(recur (rest args) file env-vars artifacts out-dir network vcpu :list session-id session-shell session-input-files (recur (rest args) file env-vars artifacts out-dir network vcpu :list session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :session) (= (first args) "--kill")) (and (= mode :session) (= (first args) "--kill"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu :kill (second args) session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu :kill (second args) session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :session) (or (= (first args) "--shell") (= (first args) "-s"))) (and (= mode :session) (or (= (first args) "--shell") (= (first args) "-s")))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id (second args) session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id (second args) session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :session) (= (first args) "-f")) (and (= mode :session) (= (first args) "-f"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell (conj session-input-files (second args)) (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell (conj session-input-files (second args))
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
;; Service options ;; Service options
(and (= mode :service) (= (first args) "--list")) (and (= mode :service) (= (first args) "--list"))
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:list service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :list service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--info")) (and (= mode :service) (= (first args) "--info"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:info (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :info (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--logs")) (and (= mode :service) (= (first args) "--logs"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:logs (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :logs (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--freeze")) (and (= mode :service) (= (first args) "--freeze"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:sleep (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :sleep (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--unfreeze")) (and (= mode :service) (= (first args) "--unfreeze"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:wake (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :wake (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--destroy")) (and (= mode :service) (= (first args) "--destroy"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:destroy (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :destroy (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--execute")) (and (= mode :service) (= (first args) "--execute"))
(recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:execute (second args) service-name service-ports (nth args 2) service-bootstrap-file service-type service-input-files key-extend mode) :execute (second args) service-name service-ports (nth args 2) service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--dump-bootstrap") (>= (count args) 3) (not (.startsWith (nth args 2) "-"))) (and (= mode :service) (= (first args) "--dump-bootstrap") (>= (count args) 3) (not (.startsWith (nth args 2) "-")))
(recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:dump-bootstrap (second args) service-name service-ports service-bootstrap (nth args 2) service-type service-input-files key-extend mode) :dump-bootstrap (second args) service-name service-ports service-bootstrap (nth args 2) service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--dump-bootstrap")) (and (= mode :service) (= (first args) "--dump-bootstrap"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:dump-bootstrap (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :dump-bootstrap (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--name")) (and (= mode :service) (= (first args) "--name"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:create service-id (second args) service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :create service-id (second args) service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--ports")) (and (= mode :service) (= (first args) "--ports"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name (second args) service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name (second args) service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--bootstrap")) (and (= mode :service) (= (first args) "--bootstrap"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports (second args) service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports (second args) service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--bootstrap-file")) (and (= mode :service) (= (first args) "--bootstrap-file"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap (second args) service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap (second args) service-type service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--type")) (and (= mode :service) (= (first args) "--type"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file (second args) service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file (second args) service-input-files service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "-f")) (and (= mode :service) (= (first args) "-f"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type (conj service-input-files (second args)) key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type (conj service-input-files (second args)) service-envs service-env-file key-extend mode)
(and (= mode :service) (= (first args) "env") (>= (count args) 2))
(let [env-action (second args)
env-target (when (and (>= (count args) 3) (not (.startsWith (nth args 2) "-"))) (nth args 2))
rest-args (if env-target (drop 3 args) (drop 2 args))]
(recur rest-args file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
:env env-action env-target service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode))
(and (= mode :service) (= (first args) "-e"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files (conj service-envs (second args)) service-env-file key-extend mode)
(and (= mode :service) (= (first args) "--env-file"))
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs (second args) key-extend mode)
;; Execute options ;; Execute options
(= (first args) "-e") (= (first args) "-e")
(let [[k v] (str/split (second args) #"=" 2)] (let [[k v] (str/split (second args) #"=" 2)]
(recur (rest (rest args)) file (conj env-vars [k v]) artifacts out-dir network vcpu (recur (rest (rest args)) file (conj env-vars [k v]) artifacts out-dir network vcpu
session-action session-id session-shell session-input-files service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)) session-action session-id session-shell session-input-files service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode))
(= (first args) "-a") (= (first args) "-a")
(recur (rest args) file env-vars true out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) file env-vars true out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(= (first args) "-o") (= (first args) "-o")
(recur (rest (rest args)) file env-vars artifacts (second args) network vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts (second args) network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(= (first args) "-n") (= (first args) "-n")
(recur (rest (rest args)) file env-vars artifacts out-dir (second args) vcpu session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir (second args) vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
(= (first args) "-v") (= (first args) "-v")
(recur (rest (rest args)) file env-vars artifacts out-dir network (Integer/parseInt (second args)) session-action session-id session-shell session-input-files (recur (rest (rest args)) file env-vars artifacts out-dir network (Integer/parseInt (second args)) session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
;; Source file ;; Source file
(and (= mode :execute) (not (.startsWith (first args) "-")) (nil? file)) (and (= mode :execute) (not (.startsWith (first args) "-")) (nil? file))
(recur (rest args) (first args) env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) (first args) env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
;; Unknown option check ;; Unknown option check
(and (= mode :session) (.startsWith (first args) "-")) (and (= mode :session) (.startsWith (first args) "-"))
@ -536,6 +679,6 @@
:else :else
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)))) service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode))))
(parse-args *command-line-args*) (parse-args *command-line-args*)

305
un.cob
View file

@ -80,6 +80,10 @@
01 WS-PORTAL-BASE PIC X(256) VALUE 01 WS-PORTAL-BASE PIC X(256) VALUE
"https://unsandbox.com". "https://unsandbox.com".
01 WS-EXTEND-FLAG PIC X(8). 01 WS-EXTEND-FLAG PIC X(8).
01 WS-SVC-ENVS PIC X(2048).
01 WS-SVC-ENV-FILE PIC X(256).
01 WS-ENV-ACTION PIC X(32).
01 WS-ENV-TARGET PIC X(256).
PROCEDURE DIVISION. PROCEDURE DIVISION.
MAIN-PROCEDURE. MAIN-PROCEDURE.
@ -177,12 +181,26 @@
END-IF. END-IF.
HANDLE-SERVICE. HANDLE-SERVICE.
* Get API key * Get API keys (try new format first, fall back to old)
ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY". ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY".
IF WS-API-KEY = SPACES IF WS-PUBLIC-KEY NOT = SPACES
DISPLAY "Error: UNSANDBOX_API_KEY not set" UPON SYSERR ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY"
MOVE 1 TO RETURN-CODE IF WS-SECRET-KEY = SPACES
STOP RUN DISPLAY "Error: UNSANDBOX_SECRET_KEY not set"
UPON SYSERR
MOVE 1 TO RETURN-CODE
STOP RUN
END-IF
ELSE
ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY"
IF WS-API-KEY = SPACES
DISPLAY "Error: UNSANDBOX_PUBLIC_KEY/SECRET_KEY or "
"UNSANDBOX_API_KEY not set" UPON SYSERR
MOVE 1 TO RETURN-CODE
STOP RUN
END-IF
MOVE WS-API-KEY TO WS-PUBLIC-KEY
MOVE WS-API-KEY TO WS-SECRET-KEY
END-IF. END-IF.
* Initialize service parameters * Initialize service parameters
@ -193,12 +211,21 @@
MOVE SPACES TO WS-BOOTSTRAP. MOVE SPACES TO WS-BOOTSTRAP.
MOVE SPACES TO WS-BOOTSTRAP-FILE. MOVE SPACES TO WS-BOOTSTRAP-FILE.
MOVE SPACES TO WS-INPUT-FILES. MOVE SPACES TO WS-INPUT-FILES.
MOVE SPACES TO WS-SVC-ENVS.
MOVE SPACES TO WS-SVC-ENV-FILE.
MOVE SPACES TO WS-ENV-ACTION.
MOVE SPACES TO WS-ENV-TARGET.
* Parse service arguments * Parse service arguments
ACCEPT WS-ARG2 FROM ARGUMENT-VALUE. ACCEPT WS-ARG2 FROM ARGUMENT-VALUE.
IF WS-ARG2 = "-l" OR WS-ARG2 = "--list" IF WS-ARG2 = "-l" OR WS-ARG2 = "--list"
PERFORM SERVICE-LIST PERFORM SERVICE-LIST
ELSE IF WS-ARG2 = "env"
ACCEPT WS-ENV-ACTION FROM ARGUMENT-VALUE
ACCEPT WS-ENV-TARGET FROM ARGUMENT-VALUE
PERFORM PARSE-SERVICE-ENV-ARGS
PERFORM SERVICE-ENV
ELSE IF WS-ARG2 = "--info" ELSE IF WS-ARG2 = "--info"
ACCEPT WS-ID FROM ARGUMENT-VALUE ACCEPT WS-ID FROM ARGUMENT-VALUE
PERFORM SERVICE-INFO PERFORM SERVICE-INFO
@ -223,7 +250,8 @@
PERFORM SERVICE-CREATE PERFORM SERVICE-CREATE
ELSE ELSE
DISPLAY "Error: Use --list, --info, --logs, " DISPLAY "Error: Use --list, --info, --logs, "
"--freeze, --unfreeze, --destroy, --dump-bootstrap, or --name" UPON SYSERR "--freeze, --unfreeze, --destroy, --dump-bootstrap, "
"--name, or env" UPON SYSERR
MOVE 1 TO RETURN-CODE MOVE 1 TO RETURN-CODE
END-IF. END-IF.
@ -558,6 +586,18 @@
ACCEPT WS-BOOTSTRAP FROM ARGUMENT-VALUE ACCEPT WS-BOOTSTRAP FROM ARGUMENT-VALUE
ELSE IF WS-ARG3 = "--bootstrap-file" ELSE IF WS-ARG3 = "--bootstrap-file"
ACCEPT WS-BOOTSTRAP-FILE FROM ARGUMENT-VALUE ACCEPT WS-BOOTSTRAP-FILE FROM ARGUMENT-VALUE
ELSE IF WS-ARG3 = "-e"
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
IF WS-SVC-ENVS NOT = SPACES
STRING FUNCTION TRIM(WS-SVC-ENVS) X"0A"
FUNCTION TRIM(WS-ARG3)
DELIMITED BY SIZE INTO WS-SVC-ENVS
END-STRING
ELSE
MOVE WS-ARG3 TO WS-SVC-ENVS
END-IF
ELSE IF WS-ARG3 = "--env-file"
ACCEPT WS-SVC-ENV-FILE FROM ARGUMENT-VALUE
ELSE IF WS-ARG3 = "-f" ELSE IF WS-ARG3 = "-f"
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
IF WS-INPUT-FILES NOT = SPACES IF WS-INPUT-FILES NOT = SPACES
@ -572,23 +612,160 @@
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
END-PERFORM. END-PERFORM.
PARSE-SERVICE-ENV-ARGS.
* Parse -e and --env-file for env set command
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE.
PERFORM UNTIL WS-ARG3 = SPACES
IF WS-ARG3 = "-e"
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
IF WS-SVC-ENVS NOT = SPACES
STRING FUNCTION TRIM(WS-SVC-ENVS) X"0A"
FUNCTION TRIM(WS-ARG3)
DELIMITED BY SIZE INTO WS-SVC-ENVS
END-STRING
ELSE
MOVE WS-ARG3 TO WS-SVC-ENVS
END-IF
ELSE IF WS-ARG3 = "--env-file"
ACCEPT WS-SVC-ENV-FILE FROM ARGUMENT-VALUE
END-IF
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
END-PERFORM.
SERVICE-ENV.
* Handle env subcommand (status/set/export/delete)
IF WS-ENV-ACTION = "status"
PERFORM SERVICE-ENV-STATUS
ELSE IF WS-ENV-ACTION = "set"
PERFORM SERVICE-ENV-SET
ELSE IF WS-ENV-ACTION = "export"
PERFORM SERVICE-ENV-EXPORT
ELSE IF WS-ENV-ACTION = "delete"
PERFORM SERVICE-ENV-DELETE
ELSE
DISPLAY "Error: Unknown env action: "
FUNCTION TRIM(WS-ENV-ACTION) UPON SYSERR
DISPLAY "Usage: un.cob service env "
"<status|set|export|delete> <service_id>" UPON SYSERR
MOVE 1 TO RETURN-CODE
END-IF.
SERVICE-ENV-STATUS.
STRING "TS=$(date +%s); "
"SIG=$(echo -n \"$TS:GET:/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env:\" | openssl dgst -sha256 -hmac '"
FUNCTION TRIM(WS-SECRET-KEY)
"' | cut -d' ' -f2); "
"curl -s -X GET 'https://api.unsandbox.com/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env' "
"-H 'Authorization: Bearer "
FUNCTION TRIM(WS-PUBLIC-KEY)
"' "
"-H 'X-Timestamp: '$TS "
"-H 'X-Signature: '$SIG | jq ."
DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING.
CALL "SYSTEM" USING WS-CURL-CMD.
SERVICE-ENV-SET.
STRING "ENV_CONTENT=''; "
"ENV_LINES='"
FUNCTION TRIM(WS-SVC-ENVS)
"'; "
"if [ -n \"$ENV_LINES\" ]; then "
"ENV_CONTENT=\"$ENV_LINES\"; fi; "
"ENV_FILE='"
FUNCTION TRIM(WS-SVC-ENV-FILE)
"'; "
"if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then "
"while IFS= read -r line || [ -n \"$line\" ]; do "
"case \"$line\" in \"#\"*|\"\") continue ;; esac; "
"if [ -n \"$ENV_CONTENT\" ]; then "
"ENV_CONTENT=\"$ENV_CONTENT"
X"0A"
"\"; fi; "
"ENV_CONTENT=\"$ENV_CONTENT$line\"; "
"done < \"$ENV_FILE\"; fi; "
"if [ -z \"$ENV_CONTENT\" ]; then "
"echo -e '\x1b[31mError: No environment variables "
"to set\x1b[0m' >&2; exit 1; fi; "
"TS=$(date +%s); "
"SIG=$(echo -n \"$TS:PUT:/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env:$ENV_CONTENT\" | openssl dgst -sha256 -hmac '"
FUNCTION TRIM(WS-SECRET-KEY)
"' | cut -d' ' -f2); "
"curl -s -X PUT 'https://api.unsandbox.com/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env' "
"-H 'Authorization: Bearer "
FUNCTION TRIM(WS-PUBLIC-KEY)
"' "
"-H 'X-Timestamp: '$TS "
"-H 'X-Signature: '$SIG "
"-H 'Content-Type: text/plain' "
"--data-binary \"$ENV_CONTENT\" | jq ."
DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING.
CALL "SYSTEM" USING WS-CURL-CMD.
SERVICE-ENV-EXPORT.
STRING "TS=$(date +%s); "
"SIG=$(echo -n \"$TS:POST:/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env/export:\" | openssl dgst -sha256 -hmac '"
FUNCTION TRIM(WS-SECRET-KEY)
"' | cut -d' ' -f2); "
"curl -s -X POST 'https://api.unsandbox.com/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env/export' "
"-H 'Authorization: Bearer "
FUNCTION TRIM(WS-PUBLIC-KEY)
"' "
"-H 'X-Timestamp: '$TS "
"-H 'X-Signature: '$SIG | jq -r '.content // empty'"
DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING.
CALL "SYSTEM" USING WS-CURL-CMD.
SERVICE-ENV-DELETE.
STRING "TS=$(date +%s); "
"SIG=$(echo -n \"$TS:DELETE:/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env:\" | openssl dgst -sha256 -hmac '"
FUNCTION TRIM(WS-SECRET-KEY)
"' | cut -d' ' -f2); "
"curl -s -X DELETE 'https://api.unsandbox.com/services/"
FUNCTION TRIM(WS-ENV-TARGET)
"/env' "
"-H 'Authorization: Bearer "
FUNCTION TRIM(WS-PUBLIC-KEY)
"' "
"-H 'X-Timestamp: '$TS "
"-H 'X-Signature: '$SIG >/dev/null && "
"echo -e '\x1b[32mVault deleted for: "
FUNCTION TRIM(WS-ENV-TARGET)
"\x1b[0m'"
DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING.
CALL "SYSTEM" USING WS-CURL-CMD.
SERVICE-CREATE. SERVICE-CREATE.
* Build JSON payload for service creation * Build service creation with HMAC auth and auto-vault
* Start with base payload containing name STRING "BODY='{\"name\":\"" FUNCTION TRIM(WS-NAME) "\""
STRING "curl -s -X POST "
"https://api.unsandbox.com/services "
"-H 'Content-Type: application/json' "
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY)
"' -d '{""name"":"""
FUNCTION TRIM(WS-NAME)
""""
DELIMITED BY SIZE INTO WS-CURL-CMD DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING. END-STRING.
* Add ports if provided * Add ports if provided
IF WS-PORTS NOT = SPACES IF WS-PORTS NOT = SPACES
STRING FUNCTION TRIM(WS-CURL-CMD) STRING FUNCTION TRIM(WS-CURL-CMD)
",""ports"":[" FUNCTION TRIM(WS-PORTS) "]" ",\"ports\":[" FUNCTION TRIM(WS-PORTS) "]"
DELIMITED BY SIZE INTO WS-CURL-CMD DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING END-STRING
END-IF. END-IF.
@ -596,9 +773,7 @@
* Add domains if provided * Add domains if provided
IF WS-DOMAINS NOT = SPACES IF WS-DOMAINS NOT = SPACES
STRING FUNCTION TRIM(WS-CURL-CMD) STRING FUNCTION TRIM(WS-CURL-CMD)
",""domains"":[""" ",\"domains\":[\"" FUNCTION TRIM(WS-DOMAINS) "\"]"
FUNCTION TRIM(WS-DOMAINS)
"""]"
DELIMITED BY SIZE INTO WS-CURL-CMD DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING END-STRING
END-IF. END-IF.
@ -606,9 +781,7 @@
* Add service_type if provided * Add service_type if provided
IF WS-SERVICE-TYPE NOT = SPACES IF WS-SERVICE-TYPE NOT = SPACES
STRING FUNCTION TRIM(WS-CURL-CMD) STRING FUNCTION TRIM(WS-CURL-CMD)
",""service_type"":""" ",\"service_type\":\"" FUNCTION TRIM(WS-SERVICE-TYPE) "\""
FUNCTION TRIM(WS-SERVICE-TYPE)
""""
DELIMITED BY SIZE INTO WS-CURL-CMD DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING END-STRING
END-IF. END-IF.
@ -616,53 +789,57 @@
* Add bootstrap if provided * Add bootstrap if provided
IF WS-BOOTSTRAP NOT = SPACES IF WS-BOOTSTRAP NOT = SPACES
STRING FUNCTION TRIM(WS-CURL-CMD) STRING FUNCTION TRIM(WS-CURL-CMD)
",""bootstrap"":""" ",\"bootstrap\":\"" FUNCTION TRIM(WS-BOOTSTRAP) "\""
FUNCTION TRIM(WS-BOOTSTRAP)
""""
DELIMITED BY SIZE INTO WS-CURL-CMD DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING END-STRING
END-IF. END-IF.
* Add bootstrap_content from file if provided * Close JSON body
IF WS-BOOTSTRAP-FILE NOT = SPACES STRING FUNCTION TRIM(WS-CURL-CMD) "}'; "
STRING FUNCTION TRIM(WS-CURL-CMD) "TS=$(date +%s); "
"' | jq --rawfile b '" "SIG=$(echo -n \"$TS:POST:/services:$BODY\" | "
FUNCTION TRIM(WS-BOOTSTRAP-FILE) "openssl dgst -sha256 -hmac '"
"' '. + {bootstrap_content: $b}' | tr -d '\\n' | curl " FUNCTION TRIM(WS-SECRET-KEY)
"-s -X POST https://api.unsandbox.com/services " "' | cut -d' ' -f2); "
"-H 'Content-Type: application/json' " "RESP=$(curl -s -X POST https://api.unsandbox.com/services "
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) "-H 'Content-Type: application/json' "
"' -d @- | jq -r '.id + "" created""'" "-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' "
DELIMITED BY SIZE INTO WS-CURL-CMD "-H 'X-Timestamp: '$TS "
END-STRING "-H 'X-Signature: '$SIG "
CALL "SYSTEM" USING WS-CURL-CMD "-d \"$BODY\"); "
EXIT PARAGRAPH "SVC_ID=$(echo \"$RESP\" | jq -r '.id // empty'); "
END-IF. "if [ -n \"$SVC_ID\" ]; then "
"echo -e '\x1b[32m'\"$SVC_ID\"' created\x1b[0m'; "
DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING.
* Add input_files if provided (use shell script for base64) * Add auto-vault logic
IF WS-INPUT-FILES NOT = SPACES
STRING "INPUT_FILES=''; "
"IFS=',' read -ra FILES <<< '"
FUNCTION TRIM(WS-INPUT-FILES)
"'; "
"for f in \"${FILES[@]}\"; do "
"b64=$(base64 -w0 \"$f\" 2>/dev/null || base64 \"$f\"); "
"name=$(basename \"$f\"); "
"if [ -n \"$INPUT_FILES\" ]; then INPUT_FILES=\"$INPUT_FILES,\"; fi; "
"INPUT_FILES=\"$INPUT_FILES{\\\"filename\\\":\\\"$name\\\",\\\"content\\\":\\\"$b64\\\"}\"; "
"done; "
FUNCTION TRIM(WS-CURL-CMD)
",\"input_files\":['\"$INPUT_FILES\"']}' | "
"jq -r '.id + "" created""'"
DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING
CALL "SYSTEM" USING WS-CURL-CMD
EXIT PARAGRAPH
END-IF.
* Close JSON and add output formatting
STRING FUNCTION TRIM(WS-CURL-CMD) STRING FUNCTION TRIM(WS-CURL-CMD)
"}' | jq -r '.id + "" created""'" "ENV_CONTENT=''; "
"ENV_LINES='" FUNCTION TRIM(WS-SVC-ENVS) "'; "
"if [ -n \"$ENV_LINES\" ]; then ENV_CONTENT=\"$ENV_LINES\"; fi; "
"ENV_FILE='" FUNCTION TRIM(WS-SVC-ENV-FILE) "'; "
"if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then "
"while IFS= read -r line || [ -n \"$line\" ]; do "
"case \"$line\" in \"#\"*|\"\") continue ;; esac; "
"if [ -n \"$ENV_CONTENT\" ]; then "
"ENV_CONTENT=\"$ENV_CONTENT" X"0A" "\"; fi; "
"ENV_CONTENT=\"$ENV_CONTENT$line\"; "
"done < \"$ENV_FILE\"; fi; "
"if [ -n \"$ENV_CONTENT\" ]; then "
"TS2=$(date +%s); "
"SIG2=$(echo -n \"$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT\" | "
"openssl dgst -sha256 -hmac '"
FUNCTION TRIM(WS-SECRET-KEY)
"' | cut -d' ' -f2); "
"curl -s -X PUT \"https://api.unsandbox.com/services/$SVC_ID/env\" "
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' "
"-H 'X-Timestamp: '$TS2 "
"-H 'X-Signature: '$SIG2 "
"-H 'Content-Type: text/plain' "
"--data-binary \"$ENV_CONTENT\" >/dev/null && "
"echo -e '\x1b[32mVault configured\x1b[0m'; fi; "
"else echo \"$RESP\" | jq .; fi"
DELIMITED BY SIZE INTO WS-CURL-CMD DELIMITED BY SIZE INTO WS-CURL-CMD
END-STRING. END-STRING.

178
un.cpp
View file

@ -174,6 +174,140 @@ string build_auth_headers(const string& method, const string& path, const string
"-H 'X-Signature: " + signature + "'"; "-H 'X-Signature: " + signature + "'";
} }
string build_env_content(const vector<string>& envs, const string& env_file) {
ostringstream parts;
if (!env_file.empty()) {
string content = read_file(env_file);
// Trim trailing whitespace
while (!content.empty() && (content.back() == '\n' || content.back() == '\r' || content.back() == ' ')) {
content.pop_back();
}
parts << content;
}
for (const auto& e : envs) {
if (e.find('=') != string::npos) {
if (parts.str().length() > 0) parts << "\n";
parts << e;
}
}
return parts.str();
}
string service_env_status(const string& service_id, const string& public_key, const string& secret_key) {
string path = "/services/" + service_id + "/env";
string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key);
string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers;
return exec_curl(cmd);
}
bool service_env_set(const string& service_id, const string& env_content, const string& public_key, const string& secret_key) {
string path = "/services/" + service_id + "/env";
string timestamp = get_timestamp();
string message = timestamp + ":PUT:" + path + ":" + env_content;
string signature = compute_hmac(secret_key, message);
string cmd = "curl -s -X PUT '" + API_BASE + path + "' "
"-H 'Content-Type: text/plain' "
"-H 'Authorization: Bearer " + public_key + "' "
"-H 'X-Timestamp: " + timestamp + "' "
"-H 'X-Signature: " + signature + "' "
"-d '" + env_content + "'";
exec_curl(cmd);
return true;
}
string service_env_export(const string& service_id, const string& public_key, const string& secret_key) {
string path = "/services/" + service_id + "/env/export";
string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key);
string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers;
return exec_curl(cmd);
}
bool service_env_delete(const string& service_id, const string& public_key, const string& secret_key) {
string path = "/services/" + service_id + "/env";
string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key);
string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers;
exec_curl(cmd);
return true;
}
void cmd_service_env(const string& action, const string& target, const vector<string>& envs, const string& env_file, const string& public_key, const string& secret_key) {
if (action == "status") {
if (target.empty()) {
cerr << RED << "Error: Usage: service env status <service_id>" << RESET << endl;
exit(1);
}
string result = service_env_status(target, public_key, secret_key);
bool has_env = result.find("\"has_env\":true") != string::npos;
cout << "Service: " << target << endl;
cout << "Has Vault: " << (has_env ? "Yes" : "No") << endl;
if (has_env) {
size_t size_pos = result.find("\"size\":");
if (size_pos != string::npos) {
size_pos += 7;
size_t end = result.find_first_not_of("0123456789", size_pos);
cout << "Size: " << result.substr(size_pos, end - size_pos) << " bytes" << endl;
}
size_t updated_pos = result.find("\"updated_at\":\"");
if (updated_pos != string::npos) {
updated_pos += 14;
size_t end = result.find("\"", updated_pos);
cout << "Updated: " << result.substr(updated_pos, end - updated_pos) << endl;
}
}
} else if (action == "set") {
if (target.empty()) {
cerr << RED << "Error: Usage: service env set <service_id> [-e KEY=VAL] [--env-file FILE]" << RESET << endl;
exit(1);
}
string env_content = build_env_content(envs, env_file);
if (env_content.empty()) {
cerr << RED << "Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE" << RESET << endl;
exit(1);
}
if (env_content.length() > 65536) {
cerr << RED << "Error: Environment content exceeds 64KB limit" << RESET << endl;
exit(1);
}
service_env_set(target, env_content, public_key, secret_key);
cout << GREEN << "Vault updated for service: " << target << RESET << endl;
} else if (action == "export") {
if (target.empty()) {
cerr << RED << "Error: Usage: service env export <service_id>" << RESET << endl;
exit(1);
}
string result = service_env_export(target, public_key, secret_key);
size_t content_pos = result.find("\"content\":\"");
if (content_pos != string::npos) {
content_pos += 11;
size_t end = result.find("\"", content_pos);
while (end > 0 && result[end-1] == '\\') end = result.find("\"", end+1);
if (end != string::npos) {
string content = result.substr(content_pos, end - content_pos);
// Unescape
size_t pos = 0;
while ((pos = content.find("\\n", pos)) != string::npos) {
content.replace(pos, 2, "\n");
}
cout << content;
if (!content.empty() && content.back() != '\n') cout << endl;
}
} else {
cerr << YELLOW << "Vault is empty" << RESET << endl;
}
} else if (action == "delete") {
if (target.empty()) {
cerr << RED << "Error: Usage: service env delete <service_id>" << RESET << endl;
exit(1);
}
service_env_delete(target, public_key, secret_key);
cout << GREEN << "Vault deleted for service: " << target << RESET << endl;
} else {
cerr << RED << "Error: Unknown env action: " << action << ". Use status, set, export, or delete" << RESET << endl;
exit(1);
}
}
void cmd_execute(const string& source_file, const vector<string>& envs, const vector<string>& files, bool artifacts, const string& network, int vcpu, const string& public_key, const string& secret_key) { void cmd_execute(const string& source_file, const vector<string>& envs, const vector<string>& files, bool artifacts, const string& network, int vcpu, const string& public_key, const string& secret_key) {
string lang = detect_language(source_file); string lang = detect_language(source_file);
if (lang.empty()) { if (lang.empty()) {
@ -305,7 +439,13 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin
cout << exec_curl(cmd) << endl; cout << exec_curl(cmd) << endl;
} }
void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector<string>& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const string& public_key, const string& secret_key) { void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector<string>& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const vector<string>& envs, const string& env_file, const string& env_action, const string& env_target, const string& public_key, const string& secret_key) {
// Handle service env subcommand
if (!env_action.empty()) {
cmd_service_env(env_action, env_target, envs, env_file, public_key, secret_key);
return;
}
if (list) { if (list) {
string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key); string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key);
string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers; string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers;
@ -494,7 +634,26 @@ void cmd_service(const string& name, const string& ports, const string& type, co
"-H 'Content-Type: application/json' " "-H 'Content-Type: application/json' "
+ auth_headers + " " + auth_headers + " "
"-d '" + json.str() + "'"; "-d '" + json.str() + "'";
cout << exec_curl(cmd) << endl; string result = exec_curl(cmd);
cout << result << endl;
// Auto-set vault if env vars provided
if (!envs.empty() || !env_file.empty()) {
// Extract service ID from result
size_t id_pos = result.find("\"id\":\"");
if (id_pos != string::npos) {
id_pos += 6;
size_t id_end = result.find("\"", id_pos);
if (id_end != string::npos) {
string service_id = result.substr(id_pos, id_end - id_pos);
string env_content = build_env_content(envs, env_file);
if (!env_content.empty() && env_content.length() <= 65536) {
service_env_set(service_id, env_content, public_key, secret_key);
cout << GREEN << "Vault configured with environment variables" << RESET << endl;
}
}
}
}
return; return;
} }
@ -633,6 +792,8 @@ int main(int argc, char* argv[]) {
string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network; string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network;
int vcpu = 0; int vcpu = 0;
vector<string> files; vector<string> files;
vector<string> envs;
string env_file, env_action, env_target;
for (int i = 2; i < argc; i++) { for (int i = 2; i < argc; i++) {
string arg = argv[i]; string arg = argv[i];
@ -642,6 +803,17 @@ int main(int argc, char* argv[]) {
else if (arg == "--bootstrap" && i+1 < argc) bootstrap = argv[++i]; else if (arg == "--bootstrap" && i+1 < argc) bootstrap = argv[++i];
else if (arg == "--bootstrap-file" && i+1 < argc) bootstrap_file = argv[++i]; else if (arg == "--bootstrap-file" && i+1 < argc) bootstrap_file = argv[++i];
else if (arg == "-f" && i+1 < argc) files.push_back(argv[++i]); else if (arg == "-f" && i+1 < argc) files.push_back(argv[++i]);
else if (arg == "-e" && i+1 < argc) envs.push_back(argv[++i]);
else if (arg == "--env-file" && i+1 < argc) env_file = argv[++i];
else if (arg == "env" && env_action.empty()) {
// service env <action> <target>
if (i+1 < argc && string(argv[i+1])[0] != '-') {
env_action = argv[++i];
if (i+1 < argc && string(argv[i+1])[0] != '-') {
env_target = argv[++i];
}
}
}
else if (arg == "--list") list = true; else if (arg == "--list") list = true;
else if (arg == "--info" && i+1 < argc) info = argv[++i]; else if (arg == "--info" && i+1 < argc) info = argv[++i];
else if (arg == "--logs" && i+1 < argc) logs = argv[++i]; else if (arg == "--logs" && i+1 < argc) logs = argv[++i];
@ -658,7 +830,7 @@ int main(int argc, char* argv[]) {
else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; else if (arg == "-k" && i+1 < argc) public_key = argv[++i];
} }
cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, public_key, secret_key); cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, envs, env_file, env_action, env_target, public_key, secret_key);
return 0; return 0;
} }

173
un.cr
View file

@ -69,6 +69,7 @@ RESET = "\033[0m"
API_BASE = "https://api.unsandbox.com" API_BASE = "https://api.unsandbox.com"
PORTAL_BASE = "https://unsandbox.com" PORTAL_BASE = "https://unsandbox.com"
MAX_ENV_CONTENT_SIZE = 65536
def detect_language(filename : String) : String def detect_language(filename : String) : String
ext = File.extname(filename).downcase ext = File.extname(filename).downcase
@ -145,6 +146,123 @@ def api_request(endpoint : String, public_key : String, secret_key : String?, me
end end
end end
def api_request_text(endpoint : String, public_key : String, secret_key : String?, body : String) : Bool
url = URI.parse(API_BASE + endpoint)
headers = HTTP::Headers{
"Content-Type" => "text/plain"
}
# Add HMAC authentication headers if secret_key is provided
if secret_key && !secret_key.empty?
timestamp = Time.utc.to_unix.to_s
message = "#{timestamp}:PUT:#{endpoint}:#{body}"
signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message)
headers["Authorization"] = "Bearer #{public_key}"
headers["X-Timestamp"] = timestamp
headers["X-Signature"] = signature
else
headers["Authorization"] = "Bearer #{public_key}"
end
begin
response = HTTP::Client.put(url, headers: headers, body: body)
return response.status_code >= 200 && response.status_code < 300
rescue
return false
end
end
def read_env_file(path : String) : String
unless File.exists?(path)
STDERR.puts "#{RED}Error: Env file not found: #{path}#{RESET}"
exit 1
end
File.read(path)
end
def build_env_content(envs : Array(String), env_file : String?) : String
lines = envs.dup
if env_file && !env_file.empty?
content = read_env_file(env_file)
content.split('\n').each do |line|
trimmed = line.strip
if !trimmed.empty? && !trimmed.starts_with?('#')
lines << trimmed
end
end
end
lines.join('\n')
end
def cmd_service_env(args)
public_key, secret_key = get_api_keys(args[:api_key]?.as?(String))
action = args[:env_action]?.as?(String) || ""
target = args[:env_target]?.as?(String) || ""
case action
when "status"
if target.empty?
STDERR.puts "#{RED}Error: service env status requires service ID#{RESET}"
exit 1
end
result = api_request("/services/#{target}/env", public_key, secret_key)
if result["has_vault"]?.try(&.as_bool?) == true
puts "#{GREEN}Vault: configured#{RESET}"
if env_count = result["env_count"]?
puts "Variables: #{env_count}"
end
if updated_at = result["updated_at"]?.try(&.as_s?)
puts "Updated: #{updated_at}"
end
else
puts "#{YELLOW}Vault: not configured#{RESET}"
end
when "set"
if target.empty?
STDERR.puts "#{RED}Error: service env set requires service ID#{RESET}"
exit 1
end
svc_envs = args[:svc_envs]?.as?(Array(String)) || [] of String
svc_env_file = args[:svc_env_file]?.as?(String)
if svc_envs.empty? && (svc_env_file.nil? || svc_env_file.empty?)
STDERR.puts "#{RED}Error: service env set requires -e or --env-file#{RESET}"
exit 1
end
env_content = build_env_content(svc_envs, svc_env_file)
if env_content.size > MAX_ENV_CONTENT_SIZE
STDERR.puts "#{RED}Error: Env content exceeds maximum size of 64KB#{RESET}"
exit 1
end
if api_request_text("/services/#{target}/env", public_key, secret_key, env_content)
puts "#{GREEN}Vault updated for service #{target}#{RESET}"
else
STDERR.puts "#{RED}Error: Failed to update vault#{RESET}"
exit 1
end
when "export"
if target.empty?
STDERR.puts "#{RED}Error: service env export requires service ID#{RESET}"
exit 1
end
result = api_request("/services/#{target}/env/export", public_key, secret_key, method: "POST", data: JSON.parse("{}"))
if content = result["content"]?.try(&.as_s?)
print content
end
when "delete"
if target.empty?
STDERR.puts "#{RED}Error: service env delete requires service ID#{RESET}"
exit 1
end
api_request("/services/#{target}/env", public_key, secret_key, method: "DELETE")
puts "#{GREEN}Vault deleted for service #{target}#{RESET}"
else
STDERR.puts "#{RED}Error: Unknown env action: #{action}#{RESET}"
STDERR.puts "Usage: un.cr service env <status|set|export|delete> <service_id>"
exit 1
end
end
def cmd_execute(args) def cmd_execute(args)
public_key, secret_key = get_api_keys(args[:api_key]?) public_key, secret_key = get_api_keys(args[:api_key]?)
@ -389,6 +507,14 @@ def cmd_key(args)
end end
def cmd_service(args) def cmd_service(args)
# Handle env subcommand
if env_action = args[:env_action]?.as?(String)
if !env_action.empty?
cmd_service_env(args)
return
end
end
public_key, secret_key = get_api_keys(args[:api_key]?) public_key, secret_key = get_api_keys(args[:api_key]?)
if args[:list]?.as?(Bool) if args[:list]?.as?(Bool)
@ -541,6 +667,20 @@ def cmd_service(args)
if url = result["url"]?.try(&.as_s?) if url = result["url"]?.try(&.as_s?)
puts "URL: #{url}" puts "URL: #{url}"
end end
# Auto-set vault if -e or --env-file provided
svc_envs = args[:svc_envs]?.as?(Array(String)) || [] of String
svc_env_file = args[:svc_env_file]?.as?(String)
if !svc_envs.empty? || (svc_env_file && !svc_env_file.empty?)
if service_id = result["id"]?.try(&.as_s?)
env_content = build_env_content(svc_envs, svc_env_file)
if api_request_text("/services/#{service_id}/env", public_key, secret_key, env_content)
puts "#{GREEN}Vault configured for service #{service_id}#{RESET}"
else
STDERR.puts "#{YELLOW}Warning: Failed to set vault#{RESET}"
end
end
end
return return
end end
@ -574,15 +714,22 @@ def main
service_type: nil, service_type: nil,
bootstrap: nil, bootstrap: nil,
bootstrap_file: nil, bootstrap_file: nil,
extend: false extend: false,
svc_envs: [] of String,
svc_env_file: nil,
env_action: nil,
env_target: nil
} of Symbol => (String | Array(String) | Bool | Nil) } of Symbol => (String | Array(String) | Bool | Nil)
parser = OptionParser.new do |opts| parser = OptionParser.new do |opts|
opts.banner = "Usage: un.cr [options] <source_file>\n un.cr session [options]\n un.cr service [options]\n un.cr key [options]" opts.banner = "Usage: un.cr [options] <source_file>\n un.cr session [options]\n un.cr service [options]\n un.cr service env <action> <service_id> [options]\n un.cr key [options]\n\nService env commands:\n env status <id> Show vault status\n env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n env export <id> Export vault contents\n env delete <id> Delete vault"
opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k } opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k }
opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n } opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n }
opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e| args[:env].as(Array(String)) << e } opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e|
args[:env].as(Array(String)) << e
args[:svc_envs].as(Array(String)) << e
}
opts.on("-f FILE", "--files=FILE", "Input file") { |f| args[:files].as(Array(String)) << f } opts.on("-f FILE", "--files=FILE", "Input file") { |f| args[:files].as(Array(String)) << f }
opts.on("-a", "--artifacts", "Return artifacts") { args[:artifacts] = true } opts.on("-a", "--artifacts", "Return artifacts") { args[:artifacts] = true }
opts.on("-o DIR", "--output-dir=DIR", "Output directory") { |d| args[:output_dir] = d } opts.on("-o DIR", "--output-dir=DIR", "Output directory") { |d| args[:output_dir] = d }
@ -603,6 +750,7 @@ def main
opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t } opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t }
opts.on("--bootstrap=CMD", "Bootstrap command or URI") { |b| args[:bootstrap] = b } opts.on("--bootstrap=CMD", "Bootstrap command or URI") { |b| args[:bootstrap] = b }
opts.on("--bootstrap-file=FILE", "Upload local file as bootstrap script") { |f| args[:bootstrap_file] = f } opts.on("--bootstrap-file=FILE", "Upload local file as bootstrap script") { |f| args[:bootstrap_file] = f }
opts.on("--env-file=FILE", "Load env vars from file (for vault)") { |f| args[:svc_env_file] = f }
opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true } opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true }
opts.unknown_args do |before, after| opts.unknown_args do |before, after|
@ -612,6 +760,25 @@ def main
args[:command] = "session" args[:command] = "session"
when "service" when "service"
args[:command] = "service" args[:command] = "service"
# Check for env subcommand
if before.size > 1 && before[1] == "env"
if before.size > 2
args[:env_action] = before[2]
end
if before.size > 3 && !before[3].starts_with?("-")
args[:env_target] = before[3]
end
# Parse remaining args for -e
i = 4
while i < before.size
if before[i] == "-e" && i + 1 < before.size
args[:svc_envs].as(Array(String)) << before[i + 1]
i += 2
else
i += 1
end
end
end
when "key" when "key"
args[:command] = "key" args[:command] = "key"
else else

198
un.d
View file

@ -60,6 +60,7 @@ immutable string RED = "\033[31m";
immutable string GREEN = "\033[32m"; immutable string GREEN = "\033[32m";
immutable string YELLOW = "\033[33m"; immutable string YELLOW = "\033[33m";
immutable string RESET = "\033[0m"; immutable string RESET = "\033[0m";
immutable size_t MAX_ENV_CONTENT_SIZE = 65536;
string detectLanguage(string filename) { string detectLanguage(string filename) {
string[string] langMap = [ string[string] langMap = [
@ -164,6 +165,151 @@ string execCurl(string cmd) {
return output; return output;
} }
bool execCurlPut(string endpoint, string body, string publicKey, string secretKey) {
import std.file : write, remove;
import std.random : uniform;
string tmpFile = format("/tmp/un_d_%d.txt", uniform(0, 999999));
write(tmpFile, body);
string authHeaders = buildAuthHeaders("PUT", endpoint, body, publicKey, secretKey);
string cmd = format(`curl -s -o /dev/null -w '%%{http_code}' -X PUT '%s%s' -H 'Content-Type: text/plain' %s -d @%s`, API_BASE, endpoint, authHeaders, tmpFile);
auto result = executeShell(cmd);
remove(tmpFile);
try {
int status = to!int(result.output.strip());
return status >= 200 && status < 300;
} catch (Exception e) {
return false;
}
}
string readEnvFile(string path) {
if (!exists(path)) {
stderr.writefln("%sError: Env file not found: %s%s", RED, path, RESET);
exit(1);
}
return readText(path);
}
string buildEnvContent(string[] envs, string envFile) {
string[] lines = envs.dup;
if (!envFile.empty) {
string content = readEnvFile(envFile);
foreach (line; content.split("\n")) {
string trimmed = line.strip();
if (!trimmed.empty && !trimmed.startsWith("#")) {
lines ~= trimmed;
}
}
}
import std.array : join;
return lines.join("\n");
}
string extractJsonField(string response, string field) {
import std.algorithm : findSplitAfter;
auto search = response.findSplitAfter(format(`"%s":"`, field));
if (search[0].length > 0 && search[1].length > 0) {
auto endSearch = search[1].findSplitAfter(`"`);
if (endSearch[0].length > 1) {
return endSearch[0][0..$-1];
}
}
return "";
}
void cmdServiceEnv(string action, string target, string[] svcEnvs, string svcEnvFile, string publicKey, string secretKey) {
if (action == "status") {
if (target.empty) {
stderr.writefln("%sError: service env status requires service ID%s", RED, RESET);
exit(1);
}
string path = format("/services/%s/env", target);
string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey);
string cmd = format(`curl -s -X GET '%s/services/%s/env' %s`, API_BASE, target, authHeaders);
string response = execCurl(cmd);
import std.algorithm : canFind;
if (response.canFind(`"has_vault":true`)) {
writefln("%sVault: configured%s", GREEN, RESET);
string envCount = extractJsonField(response, "env_count");
if (!envCount.empty) writefln("Variables: %s", envCount);
string updatedAt = extractJsonField(response, "updated_at");
if (!updatedAt.empty) writefln("Updated: %s", updatedAt);
} else {
writefln("%sVault: not configured%s", YELLOW, RESET);
}
return;
}
if (action == "set") {
if (target.empty) {
stderr.writefln("%sError: service env set requires service ID%s", RED, RESET);
exit(1);
}
if (svcEnvs.length == 0 && svcEnvFile.empty) {
stderr.writefln("%sError: service env set requires -e or --env-file%s", RED, RESET);
exit(1);
}
string envContent = buildEnvContent(svcEnvs, svcEnvFile);
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
stderr.writefln("%sError: Env content exceeds maximum size of 64KB%s", RED, RESET);
exit(1);
}
if (execCurlPut(format("/services/%s/env", target), envContent, publicKey, secretKey)) {
writefln("%sVault updated for service %s%s", GREEN, target, RESET);
} else {
stderr.writefln("%sError: Failed to update vault%s", RED, RESET);
exit(1);
}
return;
}
if (action == "export") {
if (target.empty) {
stderr.writefln("%sError: service env export requires service ID%s", RED, RESET);
exit(1);
}
string path = format("/services/%s/env/export", target);
string authHeaders = buildAuthHeaders("POST", path, "{}", publicKey, secretKey);
string cmd = format(`curl -s -X POST '%s/services/%s/env/export' -H 'Content-Type: application/json' %s -d '{}'`, API_BASE, target, authHeaders);
string response = execCurl(cmd);
string content = extractJsonField(response, "content");
if (!content.empty) {
content = content.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\"", "\"").replace("\\\\", "\\");
write(content);
}
return;
}
if (action == "delete") {
if (target.empty) {
stderr.writefln("%sError: service env delete requires service ID%s", RED, RESET);
exit(1);
}
string path = format("/services/%s/env", target);
string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey);
string cmd = format(`curl -s -o /dev/null -w '%%{http_code}' -X DELETE '%s/services/%s/env' %s`, API_BASE, target, authHeaders);
auto result = executeShell(cmd);
try {
int status = to!int(result.output.strip());
if (status >= 200 && status < 300) {
writefln("%sVault deleted for service %s%s", GREEN, target, RESET);
} else {
stderr.writefln("%sError: Failed to delete vault%s", RED, RESET);
exit(1);
}
} catch (Exception e) {
stderr.writefln("%sError: Failed to delete vault%s", RED, RESET);
exit(1);
}
return;
}
stderr.writefln("%sError: Unknown env action: %s%s", RED, action, RESET);
stderr.writeln("Usage: un.d service env <status|set|export|delete> <service_id>");
exit(1);
}
void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string publicKey, string secretKey) { void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string publicKey, string secretKey) {
string lang = detectLanguage(sourceFile); string lang = detectLanguage(sourceFile);
if (lang.empty) { if (lang.empty) {
@ -229,7 +375,13 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu,
writeln(execCurl(cmd)); writeln(execCurl(cmd));
} }
void cmdService(string name, string ports, string bootstrap, string bootstrapFile, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string[] inputFiles, string publicKey, string secretKey) { void cmdService(string name, string ports, string bootstrap, string bootstrapFile, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string[] inputFiles, string[] svcEnvs, string svcEnvFile, string envAction, string envTarget, string publicKey, string secretKey) {
// Handle env subcommand
if (!envAction.empty) {
cmdServiceEnv(envAction, envTarget, svcEnvs, svcEnvFile, publicKey, secretKey);
return;
}
if (list) { if (list) {
string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey); string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey);
string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders); string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders);
@ -385,7 +537,22 @@ void cmdService(string name, string ports, string bootstrap, string bootstrapFil
writefln("%sCreating service...%s", YELLOW, RESET); writefln("%sCreating service...%s", YELLOW, RESET);
string authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey); string authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey);
string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json); string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json);
writeln(execCurl(cmd)); string response = execCurl(cmd);
writeln(response);
// Auto-set vault if -e or --env-file provided
if (svcEnvs.length > 0 || !svcEnvFile.empty) {
string serviceId = extractJsonField(response, "service_id");
if (serviceId.empty) serviceId = extractJsonField(response, "id");
if (!serviceId.empty) {
string envContent = buildEnvContent(svcEnvs, svcEnvFile);
if (execCurlPut(format("/services/%s/env", serviceId), envContent, publicKey, secretKey)) {
writefln("%sVault configured for service %s%s", GREEN, serviceId, RESET);
} else {
stderr.writefln("%sWarning: Failed to set vault%s", YELLOW, RESET);
}
}
}
return; return;
} }
@ -527,7 +694,14 @@ int main(string[] args) {
stderr.writefln("Usage: %s [options] <source_file>", args[0]); stderr.writefln("Usage: %s [options] <source_file>", args[0]);
stderr.writefln(" %s session [options]", args[0]); stderr.writefln(" %s session [options]", args[0]);
stderr.writefln(" %s service [options]", args[0]); stderr.writefln(" %s service [options]", args[0]);
stderr.writefln(" %s service env <action> <service_id> [options]", args[0]);
stderr.writefln(" %s key [options]", args[0]); stderr.writefln(" %s key [options]", args[0]);
stderr.writeln("");
stderr.writeln("Service env commands:");
stderr.writeln(" env status <id> Show vault status");
stderr.writeln(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)");
stderr.writeln(" env export <id> Export vault contents");
stderr.writeln(" env delete <id> Delete vault");
return 1; return 1;
} }
@ -560,6 +734,22 @@ int main(string[] args) {
string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network; string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network;
int vcpu = 0; int vcpu = 0;
string[] inputFiles; string[] inputFiles;
string[] svcEnvs;
string svcEnvFile;
string envAction, envTarget;
// Check for env subcommand
if (args.length > 2 && args[2] == "env") {
if (args.length > 3) envAction = args[3];
if (args.length > 4 && !args[4].startsWith("-")) envTarget = args[4];
for (size_t i = 5; i < args.length; i++) {
if (args[i] == "-e" && i+1 < args.length) svcEnvs ~= args[++i];
else if (args[i] == "--env-file" && i+1 < args.length) svcEnvFile = args[++i];
else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i];
}
cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey);
return 0;
}
for (size_t i = 2; i < args.length; i++) { for (size_t i = 2; i < args.length; i++) {
if (args[i] == "--name" && i+1 < args.length) name = args[++i]; if (args[i] == "--name" && i+1 < args.length) name = args[++i];
@ -581,10 +771,12 @@ int main(string[] args) {
else if (args[i] == "-n" && i+1 < args.length) network = args[++i]; else if (args[i] == "-n" && i+1 < args.length) network = args[++i];
else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]); else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]);
else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i]; else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i];
else if (args[i] == "-e" && i+1 < args.length) svcEnvs ~= args[++i];
else if (args[i] == "--env-file" && i+1 < args.length) svcEnvFile = args[++i];
else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i];
} }
cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, publicKey, secretKey); cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey);
return 0; return 0;
} }

221
un.dart
View file

@ -98,6 +98,9 @@ class Args {
String? serviceDumpBootstrap; String? serviceDumpBootstrap;
String? serviceDumpFile; String? serviceDumpFile;
bool keyExtend = false; bool keyExtend = false;
String? envFile;
String? envAction;
String? envTarget;
} }
List<String?> getApiKeys(String? argsKey) { List<String?> getApiKeys(String? argsKey) {
@ -192,6 +195,180 @@ Future<Map<String, dynamic>> apiRequestCurl(String endpoint, String method, Stri
} }
} }
Future<Map<String, dynamic>?> apiRequestTextCurl(String endpoint, String method, String body, String publicKey, String? secretKey) async {
final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.txt').create();
try {
await tempFile.writeAsString(body);
final args = ['curl', '-s', '-X', method, '$apiBase$endpoint',
'-H', 'Content-Type: text/plain'];
if (secretKey != null && secretKey.isNotEmpty) {
final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
final message = '$timestamp:$method:$endpoint:$body';
final key = utf8.encode(secretKey);
final bytes = utf8.encode(message);
final hmacSha256 = Hmac(sha256, key);
final digest = hmacSha256.convert(bytes);
final signature = digest.toString();
args.addAll(['-H', 'Authorization: Bearer $publicKey']);
args.addAll(['-H', 'X-Timestamp: $timestamp']);
args.addAll(['-H', 'X-Signature: $signature']);
} else {
args.addAll(['-H', 'Authorization: Bearer $publicKey']);
}
args.addAll(['-d', '@${tempFile.path}', '-w', '%{http_code}']);
final result = await Process.run(args[0], args.sublist(1));
final output = result.stdout as String;
// Last 3 characters are the status code
if (output.length >= 3) {
final statusCode = int.tryParse(output.substring(output.length - 3)) ?? 0;
final responseBody = output.substring(0, output.length - 3);
if (statusCode >= 200 && statusCode < 300) {
if (responseBody.isNotEmpty) {
try {
return jsonDecode(responseBody) as Map<String, dynamic>;
} catch (e) {
return {'success': true};
}
}
return {'success': true};
}
}
return null;
} finally {
await tempFile.delete();
}
}
const int maxEnvContentSize = 65536;
Future<String> readEnvFile(String path) async {
final file = File(path);
if (!await file.exists()) {
stderr.writeln('${red}Error: Env file not found: $path$reset');
exit(1);
}
return await file.readAsString();
}
Future<String> buildEnvContent(List<String> envs, String? envFile) async {
final lines = <String>[];
lines.addAll(envs);
if (envFile != null) {
final content = await readEnvFile(envFile);
for (final line in content.split('\n')) {
final trimmed = line.trim();
if (trimmed.isNotEmpty && !trimmed.startsWith('#')) {
lines.add(trimmed);
}
}
}
return lines.join('\n');
}
Future<Map<String, dynamic>> serviceEnvStatus(String serviceId, String publicKey, String? secretKey) async {
return await apiRequestCurl('/services/$serviceId/env', 'GET', null, publicKey, secretKey);
}
Future<bool> serviceEnvSet(String serviceId, String envContent, String publicKey, String? secretKey) async {
if (envContent.length > maxEnvContentSize) {
stderr.writeln('${red}Error: Env content exceeds maximum size of 64KB$reset');
return false;
}
final result = await apiRequestTextCurl('/services/$serviceId/env', 'PUT', envContent, publicKey, secretKey);
return result != null;
}
Future<Map<String, dynamic>> serviceEnvExport(String serviceId, String publicKey, String? secretKey) async {
return await apiRequestCurl('/services/$serviceId/env/export', 'POST', '{}', publicKey, secretKey);
}
Future<bool> serviceEnvDelete(String serviceId, String publicKey, String? secretKey) async {
try {
await apiRequestCurl('/services/$serviceId/env', 'DELETE', null, publicKey, secretKey);
return true;
} catch (e) {
return false;
}
}
Future<void> cmdServiceEnv(Args args) async {
final keys = getApiKeys(args.apiKey);
final publicKey = keys[0]!;
final secretKey = keys[1];
final action = args.envAction;
final target = args.envTarget;
switch (action) {
case 'status':
if (target == null) {
stderr.writeln('${red}Error: service env status requires service ID$reset');
exit(1);
}
final result = await serviceEnvStatus(target, publicKey, secretKey);
final hasVault = result['has_vault'] as bool? ?? false;
if (hasVault) {
print('${green}Vault: configured$reset');
final envCount = result['env_count'];
if (envCount != null) print('Variables: $envCount');
final updatedAt = result['updated_at'];
if (updatedAt != null) print('Updated: $updatedAt');
} else {
print('${yellow}Vault: not configured$reset');
}
break;
case 'set':
if (target == null) {
stderr.writeln('${red}Error: service env set requires service ID$reset');
exit(1);
}
if (args.env.isEmpty && args.envFile == null) {
stderr.writeln('${red}Error: service env set requires -e or --env-file$reset');
exit(1);
}
final envContent = await buildEnvContent(args.env, args.envFile);
if (await serviceEnvSet(target, envContent, publicKey, secretKey)) {
print('${green}Vault updated for service $target$reset');
} else {
stderr.writeln('${red}Error: Failed to update vault$reset');
exit(1);
}
break;
case 'export':
if (target == null) {
stderr.writeln('${red}Error: service env export requires service ID$reset');
exit(1);
}
final result = await serviceEnvExport(target, publicKey, secretKey);
final content = result['content'] as String?;
if (content != null) stdout.write(content);
break;
case 'delete':
if (target == null) {
stderr.writeln('${red}Error: service env delete requires service ID$reset');
exit(1);
}
if (await serviceEnvDelete(target, publicKey, secretKey)) {
print('${green}Vault deleted for service $target$reset');
} else {
stderr.writeln('${red}Error: Failed to delete vault$reset');
exit(1);
}
break;
default:
stderr.writeln('${red}Error: Unknown env action: $action$reset');
stderr.writeln('Usage: dart un.dart service env <status|set|export|delete> <service_id>');
exit(1);
}
}
Future<void> cmdExecute(Args args) async { Future<void> cmdExecute(Args args) async {
final keys = getApiKeys(args.apiKey); final keys = getApiKeys(args.apiKey);
final publicKey = keys[0]!; final publicKey = keys[0]!;
@ -335,6 +512,12 @@ Future<void> cmdService(Args args) async {
final publicKey = keys[0]!; final publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
// Handle env subcommand
if (args.envAction != null) {
await cmdServiceEnv(args);
return;
}
if (args.serviceList) { if (args.serviceList) {
final result = await apiRequestCurl('/services', 'GET', null, publicKey, secretKey); final result = await apiRequestCurl('/services', 'GET', null, publicKey, secretKey);
final services = result['services'] as List? ?? []; final services = result['services'] as List? ?? [];
@ -480,11 +663,24 @@ Future<void> cmdService(Args args) async {
} }
final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), publicKey, secretKey); final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), publicKey, secretKey);
print('${green}Service created: ${result['id'] ?? 'N/A'}$reset'); final serviceId = result['id'] as String?;
print('${green}Service created: ${serviceId ?? 'N/A'}$reset');
print('Name: ${result['name'] ?? 'N/A'}'); print('Name: ${result['name'] ?? 'N/A'}');
if (result.containsKey('url')) { if (result.containsKey('url')) {
print('URL: ${result['url']}'); print('URL: ${result['url']}');
} }
// Auto-set vault if env vars were provided
if (serviceId != null && (args.env.isNotEmpty || args.envFile != null)) {
final envContent = await buildEnvContent(args.env, args.envFile);
if (envContent.isNotEmpty) {
if (await serviceEnvSet(serviceId, envContent, publicKey, secretKey)) {
print('${green}Vault configured with environment variables$reset');
} else {
print('${yellow}Warning: Failed to set vault$reset');
}
}
}
return; return;
} }
@ -655,6 +851,17 @@ Args parseArgs(List<String> argv) {
case '--extend': case '--extend':
args.keyExtend = true; args.keyExtend = true;
break; break;
case '--env-file':
args.envFile = argv[++i];
break;
case 'env':
if (args.command == 'service' && i + 1 < argv.length) {
args.envAction = argv[++i];
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
args.envTarget = argv[++i];
}
}
break;
default: default:
if (argv[i].startsWith('-')) { if (argv[i].startsWith('-')) {
stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}'); stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}');
@ -695,17 +902,25 @@ Service options:
--ports PORTS Comma-separated ports --ports PORTS Comma-separated ports
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
--bootstrap CMD Bootstrap command --bootstrap CMD Bootstrap command
-e KEY=VALUE Environment variable for vault
--env-file FILE Load vault variables from file
--info ID Get service details --info ID Get service details
--logs ID Get all logs --logs ID Get all logs
--tail ID Get last 9000 lines --tail ID Get last 9000 lines
--freeze ID Freeze service --freeze ID Freeze service
--unfreeze ID Unfreeze service --unfreeze ID Unfreeze service
--destroy ID Destroy service --destroy ID Destroy service
--execute ID Execute command in service --execute ID Execute command in service
--command CMD Command to execute (with --execute) --command CMD Command to execute (with --execute)
--dump-bootstrap ID Dump bootstrap script --dump-bootstrap ID Dump bootstrap script
--dump-file FILE File to save bootstrap (with --dump-bootstrap) --dump-file FILE File to save bootstrap (with --dump-bootstrap)
Service env commands:
env status ID Show vault status
env set ID Set vault (-e KEY=VALUE or --env-file FILE)
env export ID Export vault contents
env delete ID Delete vault
Key options: Key options:
--extend Open browser to extend key --extend Open browser to extend key
'''); ''');

111
un.erl
View file

@ -277,6 +277,32 @@ service_command(["--restore", SnapshotId | _Rest]) ->
io:format("\033[32mService restored from snapshot\033[0m~n"), io:format("\033[32mService restored from snapshot\033[0m~n"),
io:format("~s~n", [Response]); io:format("~s~n", [Response]);
%% Service env vault subcommand: service env <action> <id> [options]
service_command(["env", "status", ServiceId | _]) ->
service_env_status(ServiceId);
service_command(["env", "set", ServiceId | Rest]) ->
EnvVars = get_env_vars(Rest),
EnvFile = get_env_file(Rest),
Content = build_env_content(EnvVars, EnvFile),
case Content of
"" ->
io:format(standard_error, "Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE~n", []),
halt(1);
_ ->
service_env_set(ServiceId, Content)
end;
service_command(["env", "export", ServiceId | _]) ->
service_env_export(ServiceId);
service_command(["env", "delete", ServiceId | _]) ->
service_env_delete(ServiceId);
service_command(["env" | _]) ->
io:format(standard_error, "Usage: un.erl service env <status|set|export|delete> <service_id> [options]~n", []),
halt(1);
service_command(Args) -> service_command(Args) ->
case get_service_name(Args) of case get_service_name(Args) of
undefined -> undefined ->
@ -289,6 +315,8 @@ service_command(Args) ->
BootstrapFile = get_service_bootstrap_file(Args), BootstrapFile = get_service_bootstrap_file(Args),
Type = get_service_type(Args), Type = get_service_type(Args),
InputFiles = get_input_files(Args), InputFiles = get_input_files(Args),
EnvVars = get_env_vars(Args),
EnvFile = get_env_file(Args),
PortsJson = case Ports of PortsJson = case Ports of
undefined -> ""; undefined -> "";
P -> ",\"ports\":[" ++ P ++ "]" P -> ",\"ports\":[" ++ P ++ "]"
@ -319,7 +347,20 @@ service_command(Args) ->
Response = curl_post(ApiKey, "/services", TmpFile), Response = curl_post(ApiKey, "/services", TmpFile),
file:delete(TmpFile), file:delete(TmpFile),
io:format("\033[32mService created\033[0m~n"), io:format("\033[32mService created\033[0m~n"),
io:format("~s~n", [Response]) io:format("~s~n", [Response]),
%% Auto-vault: set env vars if provided
EnvContent = build_env_content(EnvVars, EnvFile),
case EnvContent of
"" -> ok;
_ ->
ServiceId = extract_json_field(Response, "id"),
case ServiceId of
"" -> ok;
_ ->
io:format("Setting vault for ~s...~n", [ServiceId]),
service_env_set(ServiceId, EnvContent)
end
end
end. end.
%% Snapshot command %% Snapshot command
@ -647,6 +688,64 @@ curl_delete(ApiKey, Endpoint) ->
check_clock_drift_error(Result), check_clock_drift_error(Result),
Result. Result.
curl_put_text(Endpoint, Content) ->
{PublicKey, SecretKey} = get_api_keys(),
TmpFile = write_temp_file(Content),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PUT", Endpoint, Content),
Cmd = "curl -s -X PUT https://api.unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: text/plain'" ++
AuthHeaders ++
" --data-binary @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
check_clock_drift_error(Result),
Result.
build_env_content(EnvVars, EnvFile) ->
% Build env content from list of env vars and env file
VarLines = EnvVars,
FileLines = case EnvFile of
undefined -> [];
"" -> [];
_ ->
case file:read_file(EnvFile) of
{ok, Bin} ->
Lines = string:split(binary_to_list(Bin), "\n", all),
[L || L <- Lines,
length(string:trim(L)) > 0,
not lists:prefix("#", string:trim(L))];
{error, _} -> []
end
end,
string:join(VarLines ++ FileLines, "\n").
service_env_status(ServiceId) ->
ApiKey = get_api_key(),
Endpoint = "/services/" ++ ServiceId ++ "/env",
Response = curl_get(ApiKey, Endpoint),
io:format("~s~n", [Response]).
service_env_set(ServiceId, Content) ->
Endpoint = "/services/" ++ ServiceId ++ "/env",
Response = curl_put_text(Endpoint, Content),
io:format("~s~n", [Response]).
service_env_export(ServiceId) ->
ApiKey = get_api_key(),
Endpoint = "/services/" ++ ServiceId ++ "/env/export",
TmpFile = write_temp_file("{}"),
Response = curl_post(ApiKey, Endpoint, TmpFile),
file:delete(TmpFile),
case extract_json_field(Response, "content") of
"" -> io:format("~s~n", [Response]);
ContentStr -> io:format("~s", [ContentStr])
end.
service_env_delete(ServiceId) ->
ApiKey = get_api_key(),
_ = curl_delete(ApiKey, "/services/" ++ ServiceId ++ "/env"),
io:format("\033[32mVault deleted: ~s\033[0m~n", [ServiceId]).
%% Argument parsing %% Argument parsing
parse_exec_args([], Opts) -> parse_exec_args([], Opts) ->
{maps:get(file, Opts), Opts}; {maps:get(file, Opts), Opts};
@ -691,6 +790,16 @@ has_extend_flag([]) -> false;
has_extend_flag(["--extend" | _]) -> true; has_extend_flag(["--extend" | _]) -> true;
has_extend_flag([_ | Rest]) -> has_extend_flag(Rest). has_extend_flag([_ | Rest]) -> has_extend_flag(Rest).
get_env_vars(Args) -> get_env_vars(Args, []).
get_env_vars([], Acc) -> lists:reverse(Acc);
get_env_vars(["-e", EnvVar | Rest], Acc) -> get_env_vars(Rest, [EnvVar | Acc]);
get_env_vars([_ | Rest], Acc) -> get_env_vars(Rest, Acc).
get_env_file([]) -> undefined;
get_env_file(["--env-file", EnvFile | _]) -> EnvFile;
get_env_file([_ | Rest]) -> get_env_file(Rest).
%% Simple JSON field extraction (works for simple string fields) %% Simple JSON field extraction (works for simple string fields)
extract_json_field(Json, Field) -> extract_json_field(Json, Field) ->
Pattern = "\"" ++ Field ++ "\":\"", Pattern = "\"" ++ Field ++ "\":\"",

141
un.ex
View file

@ -86,8 +86,12 @@ defmodule Un do
IO.puts("Usage: un.ex [options] <source_file>") IO.puts("Usage: un.ex [options] <source_file>")
IO.puts(" un.ex session [options]") IO.puts(" un.ex session [options]")
IO.puts(" un.ex service [options]") IO.puts(" un.ex service [options]")
IO.puts(" un.ex service env <action> <service_id>")
IO.puts(" un.ex snapshot [options]") IO.puts(" un.ex snapshot [options]")
IO.puts(" un.ex key [--extend]") IO.puts(" un.ex key [--extend]")
IO.puts("")
IO.puts("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE")
IO.puts("Service env commands: status, set, export, delete")
System.halt(1) System.halt(1)
end end
@ -293,6 +297,56 @@ defmodule Un do
end end
end end
defp service_command(["env", "status", service_id | _]) do
response = service_env_status(service_id)
has_vault = extract_json_value(response, "has_vault") == "true"
if has_vault do
IO.puts("#{@green}Vault: configured#{@reset}")
env_count = extract_json_value(response, "env_count")
if env_count, do: IO.puts("Variables: #{env_count}")
updated_at = extract_json_value(response, "updated_at")
if updated_at, do: IO.puts("Updated: #{updated_at}")
else
IO.puts("#{@yellow}Vault: not configured#{@reset}")
end
end
defp service_command(["env", "set", service_id | rest]) do
envs = get_all_opts(rest, "-e")
env_file = get_opt(rest, "--env-file", nil, nil)
if Enum.empty?(envs) and is_nil(env_file) do
IO.puts(:stderr, "#{@red}Error: service env set requires -e or --env-file#{@reset}")
System.halt(1)
end
env_content = build_env_content(envs, env_file)
if service_env_set(service_id, env_content) do
IO.puts("#{@green}Vault updated for service #{service_id}#{@reset}")
else
IO.puts(:stderr, "#{@red}Error: Failed to update vault#{@reset}")
System.halt(1)
end
end
defp service_command(["env", "export", service_id | _]) do
response = service_env_export(service_id)
content = extract_json_value(response, "content")
if content, do: IO.write(content)
end
defp service_command(["env", "delete", service_id | _]) do
if service_env_delete(service_id) do
IO.puts("#{@green}Vault deleted for service #{service_id}#{@reset}")
else
IO.puts(:stderr, "#{@red}Error: Failed to delete vault#{@reset}")
System.halt(1)
end
end
defp service_command(["env" | _]) do
IO.puts(:stderr, "#{@red}Error: Usage: un.ex service env <status|set|export|delete> <service_id>#{@reset}")
System.halt(1)
end
defp service_command(args) do defp service_command(args) do
name = get_opt(args, "--name", nil, nil) name = get_opt(args, "--name", nil, nil)
@ -309,6 +363,8 @@ defmodule Un do
vcpu = get_opt(args, "-v", nil, nil) vcpu = get_opt(args, "-v", nil, nil)
service_type = get_opt(args, "--type", nil, nil) service_type = get_opt(args, "--type", nil, nil)
input_files = get_all_opts(args, "-f") input_files = get_all_opts(args, "-f")
envs = get_all_opts(args, "-e")
env_file = get_opt(args, "--env-file", nil, nil)
ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" ports_json = if ports, do: ",\"ports\":[#{ports}]", else: ""
bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: ""
@ -331,6 +387,19 @@ defmodule Un do
response = curl_post(api_key, "/services", json) response = curl_post(api_key, "/services", json)
IO.puts("#{@green}Service created#{@reset}") IO.puts("#{@green}Service created#{@reset}")
IO.puts(response) IO.puts(response)
# Auto-set vault if env vars were provided
service_id = extract_json_value(response, "id")
if service_id and (not Enum.empty?(envs) or env_file) do
env_content = build_env_content(envs, env_file)
if String.length(env_content) > 0 do
if service_env_set(service_id, env_content) do
IO.puts("#{@green}Vault configured with environment variables#{@reset}")
else
IO.puts("#{@yellow}Warning: Failed to set vault#{@reset}")
end
end
end
end end
# Snapshot command # Snapshot command
@ -681,6 +750,78 @@ defmodule Un do
output output
end end
defp curl_put_text(endpoint, body) do
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt"
File.write!(tmp_file, body)
{public_key, secret_key} = get_api_keys()
headers = build_auth_headers(public_key, secret_key, "PUT", endpoint, body)
args = [
"-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "PUT",
"https://api.unsandbox.com#{endpoint}",
"-H", "Content-Type: text/plain"
] ++ headers ++ ["-d", "@#{tmp_file}"]
{output, _exit} = System.cmd("curl", args, stderr_to_stdout: true)
File.rm(tmp_file)
status_code = String.trim(output) |> String.to_integer()
status_code >= 200 and status_code < 300
end
@max_env_content_size 65536
defp read_env_file(path) do
case File.read(path) do
{:ok, content} -> content
{:error, _} ->
IO.puts(:stderr, "#{@red}Error: Env file not found: #{path}#{@reset}")
System.halt(1)
end
end
defp build_env_content(envs, env_file) do
file_lines = if env_file do
content = read_env_file(env_file)
content
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(fn line ->
String.length(line) > 0 and not String.starts_with?(line, "#")
end)
else
[]
end
(envs ++ file_lines) |> Enum.join("\n")
end
defp service_env_status(service_id) do
api_key = get_api_key()
curl_get(api_key, "/services/#{service_id}/env")
end
defp service_env_set(service_id, env_content) do
if String.length(env_content) > @max_env_content_size do
IO.puts(:stderr, "#{@red}Error: Env content exceeds maximum size of 64KB#{@reset}")
false
else
curl_put_text("/services/#{service_id}/env", env_content)
end
end
defp service_env_export(service_id) do
api_key = get_api_key()
curl_post(api_key, "/services/#{service_id}/env/export", "{}")
end
defp service_env_delete(service_id) do
api_key = get_api_key()
curl_delete(api_key, "/services/#{service_id}/env")
true
end
defp parse_exec_args(args) do defp parse_exec_args(args) do
parse_exec_args(args, nil, %{}) parse_exec_args(args, nil, %{})
end end

276
un.f90
View file

@ -317,7 +317,8 @@ contains
subroutine handle_service() subroutine handle_service()
character(len=8192) :: full_cmd character(len=8192) :: full_cmd
character(len=256) :: arg, service_id, operation, service_type, service_name character(len=256) :: arg, service_id, operation, service_type, service_name
character(len=1024) :: input_files character(len=1024) :: input_files, public_key, secret_key
character(len=2048) :: svc_envs, svc_env_file, env_action, env_target
integer :: i, stat integer :: i, stat
logical :: list_mode logical :: list_mode
@ -327,20 +328,49 @@ contains
service_type = '' service_type = ''
service_name = '' service_name = ''
input_files = '' input_files = ''
svc_envs = ''
svc_env_file = ''
env_action = ''
env_target = ''
! Parse service arguments ! Parse service arguments
do i = 2, command_argument_count() i = 2
do while (i <= command_argument_count())
call get_command_argument(i, arg) call get_command_argument(i, arg)
if (trim(arg) == '-l' .or. trim(arg) == '--list') then if (trim(arg) == '-l' .or. trim(arg) == '--list') then
list_mode = .true. list_mode = .true.
else if (trim(arg) == 'env') then
! service env <action> <service_id>
if (i+2 <= command_argument_count()) then
call get_command_argument(i+1, env_action)
call get_command_argument(i+2, env_target)
i = i + 2
end if
else if (trim(arg) == '--name') then else if (trim(arg) == '--name') then
operation = 'create' operation = 'create'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_name) call get_command_argument(i+1, service_name)
i = i + 1
end if end if
else if (trim(arg) == '--type') then else if (trim(arg) == '--type') then
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_type) call get_command_argument(i+1, service_type)
i = i + 1
end if
else if (trim(arg) == '-e') then
if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, arg)
if (len_trim(svc_envs) > 0) then
svc_envs = trim(svc_envs) // char(10) // trim(arg)
else
svc_envs = trim(arg)
end if
i = i + 1
end if
else if (trim(arg) == '--env-file') then
if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, svc_env_file)
i = i + 1
end if end if
else if (trim(arg) == '-f') then else if (trim(arg) == '-f') then
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
@ -350,101 +380,221 @@ contains
else else
input_files = trim(arg) input_files = trim(arg)
end if end if
i = i + 1
end if end if
else if (trim(arg) == '--info') then else if (trim(arg) == '--info') then
operation = 'info' operation = 'info'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_id) call get_command_argument(i+1, service_id)
i = i + 1
end if end if
else if (trim(arg) == '--logs') then else if (trim(arg) == '--logs') then
operation = 'logs' operation = 'logs'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_id) call get_command_argument(i+1, service_id)
i = i + 1
end if end if
else if (trim(arg) == '--freeze') then else if (trim(arg) == '--freeze') then
operation = 'sleep' operation = 'sleep'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_id) call get_command_argument(i+1, service_id)
i = i + 1
end if end if
else if (trim(arg) == '--unfreeze') then else if (trim(arg) == '--unfreeze') then
operation = 'wake' operation = 'wake'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_id) call get_command_argument(i+1, service_id)
i = i + 1
end if end if
else if (trim(arg) == '--destroy') then else if (trim(arg) == '--destroy') then
operation = 'destroy' operation = 'destroy'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_id) call get_command_argument(i+1, service_id)
i = i + 1
end if end if
else if (trim(arg) == '--dump-bootstrap') then else if (trim(arg) == '--dump-bootstrap') then
operation = 'dump-bootstrap' operation = 'dump-bootstrap'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_id) call get_command_argument(i+1, service_id)
i = i + 1
end if end if
else if (trim(arg) == '--dump-file') then else if (trim(arg) == '--dump-file') then
operation = 'dump-file' operation = 'dump-file'
if (i+1 <= command_argument_count()) then if (i+1 <= command_argument_count()) then
call get_command_argument(i+1, service_type) call get_command_argument(i+1, service_type)
i = i + 1
end if end if
end if end if
i = i + 1
end do end do
! Get API key ! Get API keys (try new format first, fall back to old)
call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat)
if (stat /= 0 .or. len_trim(api_key) == 0) then if (stat == 0 .and. len_trim(public_key) > 0) then
write(0, '(A)') 'Error: UNSANDBOX_API_KEY not set' call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat)
stop 1 if (stat /= 0 .or. len_trim(secret_key) == 0) then
write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set'
stop 1
end if
else
call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat)
if (stat /= 0 .or. len_trim(api_key) == 0) then
write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set'
stop 1
end if
public_key = api_key
secret_key = api_key
end if
! Handle env subcommand
if (len_trim(env_action) > 0) then
if (trim(env_action) == 'status') then
write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:GET:/services/', trim(env_target), '/env:" | ', &
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X GET "https://api.unsandbox.com/services/', trim(env_target), '/env" ', &
'-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" | jq .'
call execute_command_line(trim(full_cmd), wait=.true.)
return
else if (trim(env_action) == 'set') then
! Build env content from -e flags and --env-file
write(full_cmd, '(50A)') &
'ENV_CONTENT=""; ', &
'ENV_LINES="', trim(svc_envs), '"; ', &
'if [ -n "$ENV_LINES" ]; then ', &
'ENV_CONTENT="$ENV_LINES"; ', &
'fi; ', &
'ENV_FILE="', trim(svc_env_file), '"; ', &
'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', &
'while IFS= read -r line || [ -n "$line" ]; do ', &
'case "$line" in "#"*|"") continue ;; esac; ', &
'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', &
'ENV_CONTENT="$ENV_CONTENT$line"; ', &
'done < "$ENV_FILE"; fi; ', &
'if [ -z "$ENV_CONTENT" ]; then ', &
'echo -e "\x1b[31mError: No environment variables to set\x1b[0m" >&2; exit 1; fi; ', &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:PUT:/services/', trim(env_target), '/env:$ENV_CONTENT" | ', &
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X PUT "https://api.unsandbox.com/services/', trim(env_target), '/env" ', &
'-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" ', &
'-H "Content-Type: text/plain" ', &
'--data-binary "$ENV_CONTENT" | jq .'
call execute_command_line(trim(full_cmd), wait=.true.)
return
else if (trim(env_action) == 'export') then
write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:POST:/services/', trim(env_target), '/env/export:" | ', &
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X POST "https://api.unsandbox.com/services/', trim(env_target), '/env/export" ', &
'-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" | jq -r ".content // empty"'
call execute_command_line(trim(full_cmd), wait=.true.)
return
else if (trim(env_action) == 'delete') then
write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:DELETE:/services/', trim(env_target), '/env:" | ', &
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X DELETE "https://api.unsandbox.com/services/', trim(env_target), '/env" ', &
'-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" >/dev/null && ', &
'echo -e "\x1b[32mVault deleted for: ', trim(env_target), '\x1b[0m"'
call execute_command_line(trim(full_cmd), wait=.true.)
return
else
write(0, '(A,A)') 'Error: Unknown env action: ', trim(env_action)
write(0, '(A)') 'Usage: un.f90 service env <status|set|export|delete> <service_id>'
stop 1
end if
end if end if
if (list_mode) then if (list_mode) then
! List services ! List services
write(full_cmd, '(10A)') & write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:GET:/services:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X GET https://api.unsandbox.com/services ', & 'curl -s -X GET https://api.unsandbox.com/services ', &
'-H "Authorization: Bearer ', trim(api_key), '" | ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" | ', &
'jq -r ''.services[] | "\(.id) \(.name) \(.status)"'' ', & 'jq -r ''.services[] | "\(.id) \(.name) \(.status)"'' ', &
'2>/dev/null || echo "No services"' '2>/dev/null || echo "No services"'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'info' .and. len_trim(service_id) > 0) then else if (trim(operation) == 'info' .and. len_trim(service_id) > 0) then
write(full_cmd, '(10A)') & write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:GET:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X GET https://api.unsandbox.com/services/', & 'curl -s -X GET https://api.unsandbox.com/services/', &
trim(service_id), ' ', & trim(service_id), ' ', &
'-H "Authorization: Bearer ', trim(api_key), '" | jq .' '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" | jq .'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'logs' .and. len_trim(service_id) > 0) then else if (trim(operation) == 'logs' .and. len_trim(service_id) > 0) then
write(full_cmd, '(10A)') & write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:GET:/services/', trim(service_id), '/logs:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X GET https://api.unsandbox.com/services/', & 'curl -s -X GET https://api.unsandbox.com/services/', &
trim(service_id), '/logs ', & trim(service_id), '/logs ', &
'-H "Authorization: Bearer ', trim(api_key), '" | jq -r ".logs"' '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" | jq -r ".logs"'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'sleep' .and. len_trim(service_id) > 0) then else if (trim(operation) == 'sleep' .and. len_trim(service_id) > 0) then
write(full_cmd, '(10A)') & write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:POST:/services/', trim(service_id), '/sleep:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X POST https://api.unsandbox.com/services/', & 'curl -s -X POST https://api.unsandbox.com/services/', &
trim(service_id), '/sleep ', & trim(service_id), '/sleep ', &
'-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" >/dev/null && ', &
'echo -e "\x1b[32mService sleeping: ', trim(service_id), '\x1b[0m"' 'echo -e "\x1b[32mService sleeping: ', trim(service_id), '\x1b[0m"'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'wake' .and. len_trim(service_id) > 0) then else if (trim(operation) == 'wake' .and. len_trim(service_id) > 0) then
write(full_cmd, '(10A)') & write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:POST:/services/', trim(service_id), '/wake:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X POST https://api.unsandbox.com/services/', & 'curl -s -X POST https://api.unsandbox.com/services/', &
trim(service_id), '/wake ', & trim(service_id), '/wake ', &
'-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" >/dev/null && ', &
'echo -e "\x1b[32mService waking: ', trim(service_id), '\x1b[0m"' 'echo -e "\x1b[32mService waking: ', trim(service_id), '\x1b[0m"'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'destroy' .and. len_trim(service_id) > 0) then else if (trim(operation) == 'destroy' .and. len_trim(service_id) > 0) then
write(full_cmd, '(10A)') & write(full_cmd, '(20A)') &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:DELETE:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X DELETE https://api.unsandbox.com/services/', & 'curl -s -X DELETE https://api.unsandbox.com/services/', &
trim(service_id), ' ', & trim(service_id), ' ', &
'-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" >/dev/null && ', &
'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"' 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'dump-bootstrap' .and. len_trim(service_id) > 0) then else if (trim(operation) == 'dump-bootstrap' .and. len_trim(service_id) > 0) then
write(full_cmd, '(20A)') & write(full_cmd, '(30A)') &
'echo "Fetching bootstrap script from ', trim(service_id), '..." >&2; ', & 'echo "Fetching bootstrap script from ', trim(service_id), '..." >&2; ', &
'BODY=''{"command":"cat /tmp/bootstrap.sh"}''; ', &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:POST:/services/', trim(service_id), '/execute:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'RESP=$(curl -s -X POST https://api.unsandbox.com/services/', & 'RESP=$(curl -s -X POST https://api.unsandbox.com/services/', &
trim(service_id), '/execute ', & trim(service_id), '/execute ', &
'-H "Content-Type: application/json" ', & '-H "Content-Type: application/json" ', &
'-H "Authorization: Bearer ', trim(api_key), '" ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-d ''{"command":"cat /tmp/bootstrap.sh"}''); ', & '-H "X-Timestamp: $TS" ', &
'-H "X-Signature: $SIG" ', &
'-d "$BODY"); ', &
'STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); ', & 'STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); ', &
'if [ -n "$STDOUT" ]; then ', & 'if [ -n "$STDOUT" ]; then ', &
'if [ -n "', trim(service_type), '" ]; then ', & 'if [ -n "', trim(service_type), '" ]; then ', &
@ -454,9 +604,9 @@ contains
'else echo -e "\x1b[31mError: Failed to fetch bootstrap\x1b[0m" >&2; exit 1; fi' 'else echo -e "\x1b[31mError: Failed to fetch bootstrap\x1b[0m" >&2; exit 1; fi'
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else if (trim(operation) == 'create' .and. len_trim(service_name) > 0) then else if (trim(operation) == 'create' .and. len_trim(service_name) > 0) then
! Create service with optional input_files ! Create service with optional input_files and auto-vault
if (len_trim(input_files) > 0) then if (len_trim(input_files) > 0) then
write(full_cmd, '(30A)') & write(full_cmd, '(60A)') &
'INPUT_FILES=""; ', & 'INPUT_FILES=""; ', &
'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', & 'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', &
'for f in "${FILES[@]}"; do ', & 'for f in "${FILES[@]}"; do ', &
@ -465,24 +615,78 @@ contains
'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', & 'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', &
'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', & 'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', &
'done; ', & 'done; ', &
'curl -s -X POST https://api.unsandbox.com/services ', & 'BODY=''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}''; ', &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', &
'-H "Content-Type: application/json" ', & '-H "Content-Type: application/json" ', &
'-H "Authorization: Bearer ', trim(api_key), '" ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-d ''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}'' | ', & '-H "X-Timestamp: $TS" ', &
'jq -r ''.id + " created"'' && ', & '-H "X-Signature: $SIG" ', &
'echo -e "\x1b[32mService created\x1b[0m"' '-d "$BODY"); ', &
'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', &
'if [ -n "$SVC_ID" ]; then ', &
'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', &
'ENV_CONTENT=""; ', &
'ENV_LINES="', trim(svc_envs), '"; ', &
'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', &
'ENV_FILE="', trim(svc_env_file), '"; ', &
'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', &
'while IFS= read -r line || [ -n "$line" ]; do ', &
'case "$line" in "#"*|"") continue ;; esac; ', &
'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', &
'ENV_CONTENT="$ENV_CONTENT$line"; ', &
'done < "$ENV_FILE"; fi; ', &
'if [ -n "$ENV_CONTENT" ]; then ', &
'TS2=$(date +%s); ', &
'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', &
'-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS2" ', &
'-H "X-Signature: $SIG2" ', &
'-H "Content-Type: text/plain" ', &
'--data-binary "$ENV_CONTENT" >/dev/null && ', &
'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', &
'else echo "$RESP" | jq .; fi'
else else
write(full_cmd, '(15A)') & write(full_cmd, '(60A)') &
'curl -s -X POST https://api.unsandbox.com/services ', & 'BODY=''{"name":"', trim(service_name), '"}''; ', &
'TS=$(date +%s); ', &
'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', &
'-H "Content-Type: application/json" ', & '-H "Content-Type: application/json" ', &
'-H "Authorization: Bearer ', trim(api_key), '" ', & '-H "Authorization: Bearer ', trim(public_key), '" ', &
'-d ''{"name":"', trim(service_name), '"}'' | ', & '-H "X-Timestamp: $TS" ', &
'jq -r ''.id + " created"'' && ', & '-H "X-Signature: $SIG" ', &
'echo -e "\x1b[32mService created\x1b[0m"' '-d "$BODY"); ', &
'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', &
'if [ -n "$SVC_ID" ]; then ', &
'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', &
'ENV_CONTENT=""; ', &
'ENV_LINES="', trim(svc_envs), '"; ', &
'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', &
'ENV_FILE="', trim(svc_env_file), '"; ', &
'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', &
'while IFS= read -r line || [ -n "$line" ]; do ', &
'case "$line" in "#"*|"") continue ;; esac; ', &
'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', &
'ENV_CONTENT="$ENV_CONTENT$line"; ', &
'done < "$ENV_FILE"; fi; ', &
'if [ -n "$ENV_CONTENT" ]; then ', &
'TS2=$(date +%s); ', &
'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', &
'-H "Authorization: Bearer ', trim(public_key), '" ', &
'-H "X-Timestamp: $TS2" ', &
'-H "X-Signature: $SIG2" ', &
'-H "Content-Type: text/plain" ', &
'--data-binary "$ENV_CONTENT" >/dev/null && ', &
'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', &
'else echo "$RESP" | jq .; fi'
end if end if
call execute_command_line(trim(full_cmd), wait=.true.) call execute_command_line(trim(full_cmd), wait=.true.)
else else
write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, or --name NAME' write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, --name, or env'
stop 1 stop 1
end if end if
end subroutine handle_service end subroutine handle_service

183
un.forth
View file

@ -356,6 +356,112 @@
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
; ;
\ Service env status
: service-env-status ( addr len -- )
get-api-key
s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r
s" #!/bin/bash" r@ write-line throw
s" SERVICE_ID='" r@ write-file throw
2dup r@ write-file throw
s" '" r@ write-line throw
s" PUBLIC_KEY='" r@ write-file throw
get-public-key r@ write-file throw
s" '" r@ write-line throw
s" SECRET_KEY='" r@ write-file throw
get-secret-key r@ write-file throw
s" '" r@ write-line throw
s" TIMESTAMP=$(date +%s)" r@ write-line throw
s" MESSAGE=\"$TIMESTAMP:GET:/services/$SERVICE_ID/env:\"" r@ write-line throw
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
s" curl -s -X GET \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq ." r@ write-line throw
r> close-file throw
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
;
\ Service env set (with -e and --env-file support via shell script)
: service-env-set ( -- )
get-api-key
s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r
s" #!/bin/bash" r@ write-line throw
s" PUBLIC_KEY='" r@ write-file throw
get-public-key r@ write-file throw
s" '" r@ write-line throw
s" SECRET_KEY='" r@ write-file throw
get-secret-key r@ write-file throw
s" '" r@ write-line throw
s" SERVICE_ID=''; ENV_CONTENT=''; ENV_FILE=''" r@ write-line throw
s" i=4" r@ write-line throw
s" SERVICE_ID=$3" r@ write-line throw
s" while [ $i -le $# ]; do" r@ write-line throw
s" arg=${!i}" r@ write-line throw
s" case \"$arg\" in" r@ write-line throw
s" -e) ((i++)); VAL=${!i}" r@ write-line throw
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$VAL\"; else ENV_CONTENT=\"$VAL\"; fi ;;" r@ write-line throw
s" --env-file) ((i++)); ENV_FILE=${!i} ;;" r@ write-line throw
s" esac" r@ write-line throw
s" ((i++))" r@ write-line throw
s" done" r@ write-line throw
s" if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then" r@ write-line throw
s" while IFS= read -r line || [ -n \"$line\" ]; do" r@ write-line throw
s" case \"$line\" in \"#\"*|\"\") continue ;; esac" r@ write-line throw
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$line\"; else ENV_CONTENT=\"$line\"; fi" r@ write-line throw
s" done < \"$ENV_FILE\"" r@ write-line throw
s" fi" r@ write-line throw
s" if [ -z \"$ENV_CONTENT\" ]; then echo -e '\\x1b[31mError: No environment variables to set\\x1b[0m' >&2; exit 1; fi" r@ write-line throw
s" TIMESTAMP=$(date +%s)" r@ write-line throw
s" MESSAGE=\"$TIMESTAMP:PUT:/services/$SERVICE_ID/env:$ENV_CONTENT\"" r@ write-line throw
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
s" echo -e \"$ENV_CONTENT\" | curl -s -X PUT \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: text/plain' --data-binary @- | jq ." r@ write-line throw
r> close-file throw
s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh \"$@\" && rm -f /tmp/unsandbox_cmd.sh" system
;
\ Service env export
: service-env-export ( addr len -- )
get-api-key
s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r
s" #!/bin/bash" r@ write-line throw
s" SERVICE_ID='" r@ write-file throw
2dup r@ write-file throw
s" '" r@ write-line throw
s" PUBLIC_KEY='" r@ write-file throw
get-public-key r@ write-file throw
s" '" r@ write-line throw
s" SECRET_KEY='" r@ write-file throw
get-secret-key r@ write-file throw
s" '" r@ write-line throw
s" TIMESTAMP=$(date +%s)" r@ write-line throw
s" MESSAGE=\"$TIMESTAMP:POST:/services/$SERVICE_ID/env/export:\"" r@ write-line throw
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
s" curl -s -X POST \"https://api.unsandbox.com/services/$SERVICE_ID/env/export\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -r '.content // empty'" r@ write-line throw
r> close-file throw
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
;
\ Service env delete
: service-env-delete ( addr len -- )
get-api-key
s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r
s" #!/bin/bash" r@ write-line throw
s" SERVICE_ID='" r@ write-file throw
2dup r@ write-file throw
s" '" r@ write-line throw
s" PUBLIC_KEY='" r@ write-file throw
get-public-key r@ write-file throw
s" '" r@ write-line throw
s" SECRET_KEY='" r@ write-file throw
get-secret-key r@ write-file throw
s" '" r@ write-line throw
s" TIMESTAMP=$(date +%s)" r@ write-line throw
s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID/env:\"" r@ write-line throw
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
s" curl -s -X DELETE \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mVault deleted for: " r@ write-file throw
r@ write-file throw
s" \\x1b[0m'" r@ write-line throw
r> close-file throw
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
;
\ Service dump bootstrap \ Service dump bootstrap
: service-dump-bootstrap ( service-id-addr service-id-len file-addr file-len -- ) : service-dump-bootstrap ( service-id-addr service-id-len file-addr file-len -- )
get-api-key get-api-key
@ -400,7 +506,7 @@
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
; ;
\ Service create (requires --name, optional --ports, --domains, --type, --bootstrap, -f) \ Service create (requires --name, optional --ports, --domains, --type, --bootstrap, -f, -e, --env-file)
: service-create ( -- ) : service-create ( -- )
get-api-key get-api-key
\ Parse arguments (simplified - in real implementation would iterate through args) \ Parse arguments (simplified - in real implementation would iterate through args)
@ -414,6 +520,7 @@
get-secret-key r@ write-file throw get-secret-key r@ write-file throw
s" '" r@ write-line throw s" '" r@ write-line throw
s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''; BOOTSTRAP_FILE=''; INPUT_FILES=''" r@ write-line throw s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''; BOOTSTRAP_FILE=''; INPUT_FILES=''" r@ write-line throw
s" ENV_CONTENT=''; ENV_FILE=''" r@ write-line throw
s" i=3" r@ write-line throw s" i=3" r@ write-line throw
s" while [ $i -lt $# ]; do" r@ write-line throw s" while [ $i -lt $# ]; do" r@ write-line throw
s" arg=${!i}" r@ write-line throw s" arg=${!i}" r@ write-line throw
@ -424,6 +531,9 @@
s" --type) ((i++)); TYPE=${!i} ;;" r@ write-line throw s" --type) ((i++)); TYPE=${!i} ;;" r@ write-line throw
s" --bootstrap) ((i++)); BOOTSTRAP=${!i} ;;" r@ write-line throw s" --bootstrap) ((i++)); BOOTSTRAP=${!i} ;;" r@ write-line throw
s" --bootstrap-file) ((i++)); BOOTSTRAP_FILE=${!i} ;;" r@ write-line throw s" --bootstrap-file) ((i++)); BOOTSTRAP_FILE=${!i} ;;" r@ write-line throw
s" -e) ((i++)); VAL=${!i}" r@ write-line throw
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$VAL\"; else ENV_CONTENT=\"$VAL\"; fi ;;" r@ write-line throw
s" --env-file) ((i++)); ENV_FILE=${!i} ;;" r@ write-line throw
s" -f) ((i++)); FILE=${!i}" r@ write-line throw s" -f) ((i++)); FILE=${!i}" r@ write-line throw
s" if [ -f \"$FILE\" ]; then" r@ write-line throw s" if [ -f \"$FILE\" ]; then" r@ write-line throw
s" BASENAME=$(basename \"$FILE\")" r@ write-line throw s" BASENAME=$(basename \"$FILE\")" r@ write-line throw
@ -440,6 +550,13 @@
s" esac" r@ write-line throw s" esac" r@ write-line throw
s" ((i++))" r@ write-line throw s" ((i++))" r@ write-line throw
s" done" r@ write-line throw s" done" r@ write-line throw
s" # Parse env file if specified" r@ write-line throw
s" if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then" r@ write-line throw
s" while IFS= read -r line || [ -n \"$line\" ]; do" r@ write-line throw
s" case \"$line\" in \"#\"*|\"\") continue ;; esac" r@ write-line throw
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$line\"; else ENV_CONTENT=\"$line\"; fi" r@ write-line throw
s" done < \"$ENV_FILE\"" r@ write-line throw
s" fi" r@ write-line throw
s" [ -z \"$NAME\" ] && echo 'Error: --name required' && exit 1" r@ write-line throw s" [ -z \"$NAME\" ] && echo 'Error: --name required' && exit 1" r@ write-line throw
s" PAYLOAD='{\"name\":\"'\"$NAME\"'\"}'" r@ write-line throw s" PAYLOAD='{\"name\":\"'\"$NAME\"'\"}'" r@ write-line throw
s" [ -n \"$PORTS\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg p \"$PORTS\" '. + {ports: ($p | split(\",\") | map(tonumber))}')" r@ write-line throw s" [ -n \"$PORTS\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg p \"$PORTS\" '. + {ports: ($p | split(\",\") | map(tonumber))}')" r@ write-line throw
@ -456,7 +573,19 @@
s" TIMESTAMP=$(date +%s)" r@ write-line throw s" TIMESTAMP=$(date +%s)" r@ write-line throw
s" MESSAGE=\"$TIMESTAMP:POST:/services:$PAYLOAD\"" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:POST:/services:$PAYLOAD\"" r@ write-line throw
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
s" curl -s -X POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$PAYLOAD\" | jq ." r@ write-line throw s" RESP=$(curl -s -X POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$PAYLOAD\")" r@ write-line throw
s" echo \"$RESP\" | jq ." r@ write-line throw
s" # Auto-set vault if env vars were provided" r@ write-line throw
s" if [ -n \"$ENV_CONTENT\" ]; then" r@ write-line throw
s" SERVICE_ID=$(echo \"$RESP\" | jq -r '.id // empty')" r@ write-line throw
s" if [ -n \"$SERVICE_ID\" ]; then" r@ write-line throw
s" echo -e '\\x1b[33mSetting vault for service...\\x1b[0m'" r@ write-line throw
s" TIMESTAMP=$(date +%s)" r@ write-line throw
s" MESSAGE=\"$TIMESTAMP:PUT:/services/$SERVICE_ID/env:$ENV_CONTENT\"" r@ write-line throw
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
s" echo -e \"$ENV_CONTENT\" | curl -s -X PUT \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: text/plain' --data-binary @- | jq ." r@ write-line throw
s" fi" r@ write-line throw
s" fi" r@ write-line throw
r> close-file throw r> close-file throw
s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
; ;
@ -738,8 +867,56 @@
0 (bye) 0 (bye)
then then
\ Handle env subcommand: service env <action> <service_id> [options]
2dup s" env" compare 0= if
2drop
argc @ 4 < if
s" Usage: un.forth service env <status|set|export|delete> <service_id> [options]" type cr
1 (bye)
then
3 arg 2dup s" status" compare 0= if
2drop
argc @ 5 < if
s" Error: status requires service ID" type cr
1 (bye)
then
4 arg service-env-status
0 (bye)
then
2dup s" set" compare 0= if
2drop
argc @ 5 < if
s" Error: set requires service ID" type cr
1 (bye)
then
service-env-set
0 (bye)
then
2dup s" export" compare 0= if
2drop
argc @ 5 < if
s" Error: export requires service ID" type cr
1 (bye)
then
4 arg service-env-export
0 (bye)
then
2dup s" delete" compare 0= if
2drop
argc @ 5 < if
s" Error: delete requires service ID" type cr
1 (bye)
then
4 arg service-env-delete
0 (bye)
then
2drop
s" Error: Unknown env action. Use status, set, export, or delete" type cr
1 (bye)
then
2drop 2drop
s" Error: Use --name (create), --list, --info, --logs, --freeze, --unfreeze, --destroy, or --dump-bootstrap" type cr s" Error: Use --name (create), --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, or env" type cr
1 (bye) 1 (bye)
; ;

207
un.fs
View file

@ -118,6 +118,9 @@ type Args = {
mutable SnapshotName: string option mutable SnapshotName: string option
mutable SnapshotShell: string option mutable SnapshotShell: string option
mutable SnapshotPorts: string option mutable SnapshotPorts: string option
mutable EnvFile: string option
mutable EnvAction: string option
mutable EnvTarget: string option
mutable KeyExtend: bool mutable KeyExtend: bool
} }
@ -311,6 +314,164 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op
failwithf "HTTP error - %s" errorMsg failwithf "HTTP error - %s" errorMsg
let apiRequestText (endpoint: string) (method: string) (body: string) (publicKey: string) (secretKey: string) =
ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls
let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest
request.Method <- method
request.ContentType <- "text/plain"
request.Timeout <- 300000
let bodyContent = if body = null then "" else body
// Add HMAC authentication headers if secretKey is provided
if not (String.IsNullOrEmpty(secretKey)) then
let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
let message = sprintf "%d:%s:%s:%s" timestamp method endpoint bodyContent
use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))
let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message))
let signature = BitConverter.ToString(hash).Replace("-", "").ToLower()
request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)
request.Headers.Add("X-Timestamp", timestamp.ToString())
request.Headers.Add("X-Signature", signature)
else
request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)
if not (String.IsNullOrEmpty(bodyContent)) then
let bytes = Encoding.UTF8.GetBytes(bodyContent)
request.ContentLength <- int64 bytes.Length
use stream = request.GetRequestStream()
stream.Write(bytes, 0, bytes.Length)
try
use response = request.GetResponse() :?> HttpWebResponse
use reader = new StreamReader(response.GetResponseStream())
reader.ReadToEnd()
with
| :? WebException as ex ->
let errorMsg =
if ex.Response <> null then
use reader = new StreamReader(ex.Response.GetResponseStream())
reader.ReadToEnd()
else
ex.Message
failwithf "HTTP error - %s" errorMsg
let readEnvFile (path: string) =
if not (File.Exists(path)) then
failwithf "Env file not found: %s" path
File.ReadAllText(path)
let buildEnvContent (envs: ResizeArray<string>) (envFile: string option) =
let lines = ResizeArray<string>()
// Add from -e flags
for env in envs do
lines.Add(env)
// Add from --env-file
match envFile with
| Some path ->
let content = readEnvFile path
for line in content.Split('\n') do
let trimmed = line.Trim()
if not (String.IsNullOrEmpty(trimmed)) && not (trimmed.StartsWith("#")) then
lines.Add(trimmed)
| None -> ()
String.Join("\n", lines)
let serviceEnvStatus (serviceId: string) (publicKey: string) (secretKey: string) =
apiRequest (sprintf "/services/%s/env" serviceId) "GET" None publicKey secretKey
let serviceEnvSet (serviceId: string) (envContent: string) (publicKey: string) (secretKey: string) =
let maxEnvContentSize = 65536
if envContent.Length > maxEnvContentSize then
eprintfn "%sError: Env content exceeds maximum size of 64KB%s" red reset
false
else
try
apiRequestText (sprintf "/services/%s/env" serviceId) "PUT" envContent publicKey secretKey |> ignore
true
with _ ->
false
let serviceEnvExport (serviceId: string) (publicKey: string) (secretKey: string) =
apiRequest (sprintf "/services/%s/env/export" serviceId) "POST" None publicKey secretKey
let serviceEnvDelete (serviceId: string) (publicKey: string) (secretKey: string) =
try
apiRequest (sprintf "/services/%s/env" serviceId) "DELETE" None publicKey secretKey |> ignore
true
with _ ->
false
let cmdServiceEnv (args: Args) (publicKey: string) (secretKey: string) =
match args.EnvAction with
| Some "status" ->
match args.EnvTarget with
| Some target ->
let result = serviceEnvStatus target publicKey secretKey
match result.TryFind "has_vault" with
| Some hasVault when hasVault.ToString() = "True" ->
printfn "%sVault: configured%s" green reset
match result.TryFind "env_count" with
| Some count -> printfn "Variables: %s" (count.ToString())
| None -> ()
match result.TryFind "updated_at" with
| Some updated -> printfn "Updated: %s" (updated.ToString())
| None -> ()
| _ ->
printfn "%sVault: not configured%s" yellow reset
| None ->
eprintfn "%sError: service env status requires service ID%s" red reset
exit 1
| Some "set" ->
match args.EnvTarget with
| Some target ->
if args.Env.Count = 0 && args.EnvFile.IsNone then
eprintfn "%sError: service env set requires -e or --env-file%s" red reset
exit 1
let envContent = buildEnvContent args.Env args.EnvFile
if serviceEnvSet target envContent publicKey secretKey then
printfn "%sVault updated for service %s%s" green target reset
else
eprintfn "%sError: Failed to update vault%s" red reset
exit 1
| None ->
eprintfn "%sError: service env set requires service ID%s" red reset
exit 1
| Some "export" ->
match args.EnvTarget with
| Some target ->
let result = serviceEnvExport target publicKey secretKey
match result.TryFind "content" with
| Some content -> printf "%s" (content.ToString())
| None -> ()
| None ->
eprintfn "%sError: service env export requires service ID%s" red reset
exit 1
| Some "delete" ->
match args.EnvTarget with
| Some target ->
if serviceEnvDelete target publicKey secretKey then
printfn "%sVault deleted for service %s%s" green target reset
else
eprintfn "%sError: Failed to delete vault%s" red reset
exit 1
| None ->
eprintfn "%sError: service env delete requires service ID%s" red reset
exit 1
| Some action ->
eprintfn "%sError: Unknown env action: %s%s" red action reset
eprintfn "Usage: un.fs service env <status|set|export|delete> <service_id>"
exit 1
| None ->
eprintfn "%sError: env action required%s" red reset
exit 1
let cmdExecute (args: Args) = let cmdExecute (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey
let code = File.ReadAllText(args.SourceFile.Value) let code = File.ReadAllText(args.SourceFile.Value)
@ -531,7 +692,10 @@ let cmdSnapshot (args: Args) =
let cmdService (args: Args) = let cmdService (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey
if args.ServiceSnapshot.IsSome then // Handle env subcommand
if args.EnvAction.IsSome then
cmdServiceEnv args publicKey secretKey
elif args.ServiceSnapshot.IsSome then
let mutable payload = [] let mutable payload = []
if args.ServiceSnapshotName.IsSome then if args.ServiceSnapshotName.IsSome then
payload <- payload @ [("name", box args.ServiceSnapshotName.Value)] payload <- payload @ [("name", box args.ServiceSnapshotName.Value)]
@ -630,8 +794,9 @@ let cmdService (args: Args) =
payload <- payload @ [("vcpu", box args.Vcpu)] payload <- payload @ [("vcpu", box args.Vcpu)]
let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey
match result.TryFind "id" with let serviceId = match result.TryFind "id" with | Some id -> Some (id.ToString()) | None -> None
| Some id -> printfn "%sService created: %s%s" green (id.ToString()) reset match serviceId with
| Some id -> printfn "%sService created: %s%s" green id reset
| None -> printfn "%sService created%s" green reset | None -> printfn "%sService created%s" green reset
match result.TryFind "name" with match result.TryFind "name" with
| Some name -> printfn "Name: %s" (name.ToString()) | Some name -> printfn "Name: %s" (name.ToString())
@ -639,6 +804,17 @@ let cmdService (args: Args) =
match result.TryFind "url" with match result.TryFind "url" with
| Some url -> printfn "URL: %s" (url.ToString()) | Some url -> printfn "URL: %s" (url.ToString())
| None -> () | None -> ()
// Auto-set vault if env vars were provided
match serviceId with
| Some id when args.Env.Count > 0 || args.EnvFile.IsSome ->
let envContent = buildEnvContent args.Env args.EnvFile
if not (String.IsNullOrEmpty(envContent)) then
if serviceEnvSet id envContent publicKey secretKey then
printfn "%sVault configured with environment variables%s" green reset
else
eprintfn "%sWarning: Failed to set vault%s" yellow reset
| _ -> ()
else else
eprintfn "%sError: Specify --name to create a service, or use --list, --info, etc.%s" red reset eprintfn "%sError: Specify --name to create a service, or use --list, --info, etc.%s" red reset
exit 1 exit 1
@ -691,6 +867,9 @@ let parseArgs (argv: string[]) =
SnapshotName = None SnapshotName = None
SnapshotShell = None SnapshotShell = None
SnapshotPorts = None SnapshotPorts = None
EnvFile = None
EnvAction = None
EnvTarget = None
KeyExtend = false KeyExtend = false
} }
@ -701,10 +880,19 @@ let parseArgs (argv: string[]) =
| "service" -> args.Command <- Some "service" | "service" -> args.Command <- Some "service"
| "snapshot" -> args.Command <- Some "snapshot" | "snapshot" -> args.Command <- Some "snapshot"
| "key" -> args.Command <- Some "key" | "key" -> args.Command <- Some "key"
| "env" when args.Command = Some "service" ->
// Parse: service env <action> <target>
if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
i <- i + 1
args.EnvAction <- Some argv.[i]
if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
i <- i + 1
args.EnvTarget <- Some argv.[i]
| "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i] | "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i]
| "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i] | "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i]
| "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i] | "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i]
| "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i]) | "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i])
| "--env-file" -> i <- i + 1; args.EnvFile <- Some argv.[i]
| "-f" | "--files" -> i <- i + 1; args.Files.Add(argv.[i]) | "-f" | "--files" -> i <- i + 1; args.Files.Add(argv.[i])
| "-a" | "--artifacts" -> args.Artifacts <- true | "-a" | "--artifacts" -> args.Artifacts <- true
| "-o" | "--output-dir" -> i <- i + 1; args.OutputDir <- Some argv.[i] | "-o" | "--output-dir" -> i <- i + 1; args.OutputDir <- Some argv.[i]
@ -800,6 +988,7 @@ let printHelp () =
printfn "Usage: un [options] <source_file>" printfn "Usage: un [options] <source_file>"
printfn " un session [options]" printfn " un session [options]"
printfn " un service [options]" printfn " un service [options]"
printfn " un service env <action> <service_id> [options]"
printfn " un key [options]" printfn " un key [options]"
printfn "" printfn ""
printfn "Execute options:" printfn "Execute options:"
@ -825,13 +1014,21 @@ let printHelp () =
printfn " --info ID Get service details" printfn " --info ID Get service details"
printfn " --logs ID Get all logs" printfn " --logs ID Get all logs"
printfn " --tail ID Get last 9000 lines" printfn " --tail ID Get last 9000 lines"
printfn " --freeze ID Freeze service" printfn " --freeze ID Freeze service"
printfn " --unfreeze ID Unfreeze service" printfn " --unfreeze ID Unfreeze service"
printfn " --destroy ID Destroy service" printfn " --destroy ID Destroy service"
printfn " --execute ID Execute command in service" printfn " --execute ID Execute command in service"
printfn " --command CMD Command to execute (with --execute)" printfn " --command CMD Command to execute (with --execute)"
printfn " --dump-bootstrap ID Dump bootstrap script" printfn " --dump-bootstrap ID Dump bootstrap script"
printfn " --dump-file FILE File to save bootstrap (with --dump-bootstrap)" printfn " --dump-file FILE File to save bootstrap (with --dump-bootstrap)"
printfn " -e KEY=VALUE Set vault env var (with --name or env set)"
printfn " --env-file FILE Load vault vars from file"
printfn ""
printfn "Service env commands:"
printfn " env status ID Check vault status"
printfn " env set ID Set vault (use -e or --env-file)"
printfn " env export ID Export vault contents"
printfn " env delete ID Delete vault"
printfn "" printfn ""
printfn "Key options:" printfn "Key options:"
printfn " --extend Open browser to extend key" printfn " --extend Open browser to extend key"

220
un.go
View file

@ -231,6 +231,183 @@ func apiRequest(endpoint, method string, data map[string]interface{}, publicKey,
return result return result
} }
func apiRequestText(endpoint, method, body, publicKey, secretKey string) (map[string]interface{}, error) {
url := APIBase + endpoint
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
return nil, err
}
// HMAC authentication
timestamp := fmt.Sprintf("%d", time.Now().Unix())
signature := computeHMAC(secretKey, timestamp, method, endpoint, body)
req.Header.Set("Authorization", "Bearer "+publicKey)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Signature", signature)
req.Header.Set("Content-Type", "text/plain")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d - %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
return result, nil
}
// ============================================================================
// Environment Secrets Vault Functions
// ============================================================================
const MaxEnvContentSize = 64 * 1024 // 64KB max env vault size
func serviceEnvStatus(serviceID, publicKey, secretKey string) {
result := apiRequest("/services/"+serviceID+"/env", "GET", nil, publicKey, secretKey)
hasVault, _ := result["has_vault"].(bool)
if !hasVault {
fmt.Println("Vault exists: no")
fmt.Println("Variable count: 0")
} else {
fmt.Println("Vault exists: yes")
if count, ok := result["count"].(float64); ok {
fmt.Printf("Variable count: %d\n", int(count))
}
if updatedAt, ok := result["updated_at"].(float64); ok {
t := time.Unix(int64(updatedAt), 0)
fmt.Printf("Last updated: %s\n", t.Format("2006-01-02 15:04:05"))
}
}
}
func serviceEnvSet(serviceID, envContent, publicKey, secretKey string) bool {
if envContent == "" {
fmt.Fprintf(os.Stderr, "%sError: No environment content provided%s\n", Red, Reset)
return false
}
if len(envContent) > MaxEnvContentSize {
fmt.Fprintf(os.Stderr, "%sError: Environment content too large (max %d bytes)%s\n", Red, MaxEnvContentSize, Reset)
return false
}
result, err := apiRequestText("/services/"+serviceID+"/env", "PUT", envContent, publicKey, secretKey)
if err != nil {
fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset)
return false
}
if count, ok := result["count"].(float64); ok {
plural := "s"
if int(count) == 1 {
plural = ""
}
fmt.Printf("%sEnvironment vault updated: %d variable%s%s\n", Green, int(count), plural, Reset)
} else {
fmt.Printf("%sEnvironment vault updated%s\n", Green, Reset)
}
if message, ok := result["message"].(string); ok && message != "" {
fmt.Println(message)
}
return true
}
func serviceEnvExport(serviceID, publicKey, secretKey string) {
result := apiRequest("/services/"+serviceID+"/env/export", "POST", map[string]interface{}{}, publicKey, secretKey)
if envContent, ok := result["env"].(string); ok && envContent != "" {
fmt.Print(envContent)
if !strings.HasSuffix(envContent, "\n") {
fmt.Println()
}
}
}
func serviceEnvDelete(serviceID, publicKey, secretKey string) {
apiRequest("/services/"+serviceID+"/env", "DELETE", nil, publicKey, secretKey)
fmt.Printf("%sEnvironment vault deleted%s\n", Green, Reset)
}
func readEnvFile(filepath string) (string, error) {
content, err := os.ReadFile(filepath)
if err != nil {
return "", err
}
return string(content), nil
}
func buildEnvContent(envs envVars, envFile string) string {
var parts []string
// Read from env file first
if envFile != "" {
content, err := readEnvFile(envFile)
if err != nil {
fmt.Fprintf(os.Stderr, "%sError: Env file not found: %s%s\n", Red, envFile, Reset)
os.Exit(1)
}
parts = append(parts, content)
}
// Add -e flags
for _, e := range envs {
if strings.Contains(e, "=") {
parts = append(parts, e)
}
}
return strings.Join(parts, "\n")
}
func cmdServiceEnv(action, target string, envs envVars, envFile, publicKey, secretKey string) {
if action == "" {
fmt.Fprintf(os.Stderr, "%sError: env action required (status, set, export, delete)%s\n", Red, Reset)
os.Exit(1)
}
if target == "" {
fmt.Fprintf(os.Stderr, "%sError: Service ID required for env command%s\n", Red, Reset)
os.Exit(1)
}
switch action {
case "status":
serviceEnvStatus(target, publicKey, secretKey)
case "set":
envContent := buildEnvContent(envs, envFile)
if envContent == "" {
fmt.Fprintf(os.Stderr, "%sError: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin%s\n", Red, Reset)
os.Exit(1)
}
serviceEnvSet(target, envContent, publicKey, secretKey)
case "export":
serviceEnvExport(target, publicKey, secretKey)
case "delete":
serviceEnvDelete(target, publicKey, secretKey)
default:
fmt.Fprintf(os.Stderr, "%sError: Unknown env action '%s'. Use: status, set, export, delete%s\n", Red, action, Reset)
os.Exit(1)
}
}
func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) { func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) {
code, err := os.ReadFile(sourceFile) code, err := os.ReadFile(sourceFile)
if err != nil { if err != nil {
@ -419,7 +596,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session
fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset) fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset)
} }
func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, publicKey, secretKey string) { func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, envs envVars, envFile, publicKey, secretKey string) {
if serviceSnapshot != "" { if serviceSnapshot != "" {
payload := map[string]interface{}{} payload := map[string]interface{}{}
if serviceSnapshotName != "" { if serviceSnapshotName != "" {
@ -602,11 +779,18 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB
} }
result := apiRequest("/services", "POST", payload, publicKey, secretKey) result := apiRequest("/services", "POST", payload, publicKey, secretKey)
fmt.Printf("%sService created: %s%s\n", Green, result["id"], Reset) serviceID := result["id"].(string)
fmt.Printf("%sService created: %s%s\n", Green, serviceID, Reset)
fmt.Printf("Name: %s\n", result["name"]) fmt.Printf("Name: %s\n", result["name"])
if url, ok := result["url"]; ok { if url, ok := result["url"]; ok {
fmt.Printf("URL: %s\n", url) fmt.Printf("URL: %s\n", url)
} }
// Auto-set vault if -e or --env-file provided
envContent := buildEnvContent(envs, envFile)
if envContent != "" {
serviceEnvSet(serviceID, envContent, publicKey, secretKey)
}
return return
} }
@ -863,6 +1047,9 @@ func main() {
serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script") serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script")
var serviceFiles inputFiles var serviceFiles inputFiles
serviceCmd.Var(&serviceFiles, "f", "Input file") serviceCmd.Var(&serviceFiles, "f", "Input file")
var serviceEnvs envVars
serviceCmd.Var(&serviceEnvs, "e", "Environment variable (KEY=VALUE)")
serviceEnvFile := serviceCmd.String("env-file", "", "Environment file (.env format)")
serviceList := serviceCmd.String("list", "", "List services") serviceList := serviceCmd.String("list", "", "List services")
serviceInfo := serviceCmd.String("info", "", "Get service info") serviceInfo := serviceCmd.String("info", "", "Get service info")
serviceLogs := serviceCmd.String("logs", "", "Get service logs") serviceLogs := serviceCmd.String("logs", "", "Get service logs")
@ -919,6 +1106,33 @@ func main() {
return return
case "service": case "service":
// Check for "service env" subcommand
if len(os.Args) > 2 && os.Args[2] == "env" {
// Parse env subcommand: service env <action> <service_id> [options]
envCmd := flag.NewFlagSet("service env", flag.ExitOnError)
var envFlags envVars
envCmd.Var(&envFlags, "e", "Environment variable (KEY=VALUE)")
envFile := envCmd.String("env-file", "", "Environment file")
envKey := envCmd.String("k", "", "API key")
if len(os.Args) < 4 {
fmt.Fprintf(os.Stderr, "%sError: env action required (status, set, export, delete)%s\n", Red, Reset)
os.Exit(1)
}
action := os.Args[3]
target := ""
argsStart := 4
if len(os.Args) > 4 && !strings.HasPrefix(os.Args[4], "-") {
target = os.Args[4]
argsStart = 5
}
envCmd.Parse(os.Args[argsStart:])
publicKey, secretKey := getAPIKeys(*envKey)
cmdServiceEnv(action, target, envFlags, *envFile, publicKey, secretKey)
return
}
serviceCmd.Parse(os.Args[2:]) serviceCmd.Parse(os.Args[2:])
publicKey, secretKey := getAPIKeys(*serviceKey) publicKey, secretKey := getAPIKeys(*serviceKey)
net := *serviceNetwork net := *serviceNetwork
@ -929,7 +1143,7 @@ func main() {
if vc == 0 { if vc == 0 {
vc = *vcpu vc = *vcpu
} }
cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, publicKey, secretKey) cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, serviceEnvs, *serviceEnvFile, publicKey, secretKey)
return return
case "snapshot": case "snapshot":

162
un.groovy
View file

@ -55,6 +55,7 @@ def EXT_MAP = [
def API_BASE = 'https://api.unsandbox.com' def API_BASE = 'https://api.unsandbox.com'
def PORTAL_BASE = 'https://unsandbox.com' def PORTAL_BASE = 'https://unsandbox.com'
def MAX_ENV_CONTENT_SIZE = 65536
def BLUE = '\033[34m' def BLUE = '\033[34m'
def RED = '\033[31m' def RED = '\033[31m'
def GREEN = '\033[32m' def GREEN = '\033[32m'
@ -109,6 +110,10 @@ class Args {
String snapshotShell = null String snapshotShell = null
String snapshotPorts = null String snapshotPorts = null
Boolean keyExtend = false Boolean keyExtend = false
List<String> svcEnvs = []
String svcEnvFile = null
String envAction = null
String envTarget = null
} }
import javax.crypto.Mac import javax.crypto.Mac
@ -205,6 +210,117 @@ def apiRequest(endpoint, method, data, publicKey, secretKey) {
} }
} }
def readEnvFile(filename) {
def file = new File(filename)
if (!file.exists()) {
System.err.println("${RED}Error: Cannot read env file: ${filename}${RESET}")
return ''
}
return file.text
}
def buildEnvContent(envs, envFile) {
def result = new StringBuilder()
// Add -e flags
envs.each { env ->
result.append(env).append('\n')
}
// Add content from env file
if (envFile) {
def content = readEnvFile(envFile)
content.split('\n').each { line ->
def trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
result.append(trimmed).append('\n')
}
}
}
return result.toString()
}
def apiRequestText(endpoint, method, body, publicKey, secretKey) {
def tempFile = File.createTempFile('un_env_', '.txt')
try {
if (body) {
tempFile.text = body
}
def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}",
'-H', 'Content-Type: text/plain']
// Add HMAC authentication headers if secretKey is provided
if (secretKey) {
def timestamp = (System.currentTimeMillis() / 1000) as long
def message = "${timestamp}:${method}:${endpoint}:${body ?: ''}"
def mac = Mac.getInstance("HmacSHA256")
mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256"))
def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString()
curlCmd += ['-H', "Authorization: Bearer ${publicKey}"]
curlCmd += ['-H', "X-Timestamp: ${timestamp}"]
curlCmd += ['-H', "X-Signature: ${signature}"]
} else {
curlCmd += ['-H', "Authorization: Bearer ${publicKey}"]
}
if (body) {
curlCmd += ['--data-binary', "@${tempFile.absolutePath}"]
}
def proc = curlCmd.execute()
def output = proc.text
proc.waitFor()
return proc.exitValue() == 0
} finally {
tempFile.delete()
}
}
def serviceEnvSet(serviceId, content, publicKey, secretKey) {
return apiRequestText("/services/${serviceId}/env", 'PUT', content, publicKey, secretKey)
}
def cmdServiceEnv(args) {
def (publicKey, secretKey) = getApiKeys(args.apiKey)
switch (args.envAction) {
case 'status':
def output = apiRequest("/services/${args.envTarget}/env", 'GET', null, publicKey, secretKey)
println(output)
break
case 'set':
if (!args.svcEnvs && !args.svcEnvFile) {
System.err.println("${RED}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE${RESET}")
return
}
def content = buildEnvContent(args.svcEnvs, args.svcEnvFile)
if (content.length() > MAX_ENV_CONTENT_SIZE) {
System.err.println("${RED}Error: Environment content exceeds 64KB limit${RESET}")
return
}
if (serviceEnvSet(args.envTarget, content, publicKey, secretKey)) {
println("${GREEN}Vault updated for service ${args.envTarget}${RESET}")
}
break
case 'export':
def output = apiRequest("/services/${args.envTarget}/env/export", 'POST', null, publicKey, secretKey)
println(output)
break
case 'delete':
apiRequest("/services/${args.envTarget}/env", 'DELETE', null, publicKey, secretKey)
println("${GREEN}Vault deleted for service ${args.envTarget}${RESET}")
break
default:
System.err.println("${RED}Error: Unknown env action: ${args.envAction}${RESET}")
System.err.println("Usage: un service env <status|set|export|delete> <service_id>")
}
}
def cmdExecute(args) { def cmdExecute(args) {
def (publicKey, secretKey) = getApiKeys(args.apiKey) def (publicKey, secretKey) = getApiKeys(args.apiKey)
def file = new File(args.sourceFile) def file = new File(args.sourceFile)
@ -703,8 +819,10 @@ def cmdService(args) {
def output = apiRequest('/services', 'POST', json, publicKey, secretKey) def output = apiRequest('/services', 'POST', json, publicKey, secretKey)
def idMatch = output =~ /"id":"([^"]+)"/ def idMatch = output =~ /"id":"([^"]+)"/
def serviceId = null
if (idMatch.find()) { if (idMatch.find()) {
println("${GREEN}Service created: ${idMatch.group(1)}${RESET}") serviceId = idMatch.group(1)
println("${GREEN}Service created: ${serviceId}${RESET}")
} }
def nameMatch = output =~ /"name":"([^"]+)"/ def nameMatch = output =~ /"name":"([^"]+)"/
if (nameMatch.find()) { if (nameMatch.find()) {
@ -714,6 +832,16 @@ def cmdService(args) {
if (urlMatch.find()) { if (urlMatch.find()) {
println("URL: ${urlMatch.group(1)}") println("URL: ${urlMatch.group(1)}")
} }
// Auto-set vault if -e or --env-file provided
if (serviceId && (args.svcEnvs || args.svcEnvFile)) {
def envContent = buildEnvContent(args.svcEnvs, args.svcEnvFile)
if (envContent) {
if (serviceEnvSet(serviceId, envContent, publicKey, secretKey)) {
println("${GREEN}Vault configured for service ${serviceId}${RESET}")
}
}
}
return return
} }
@ -732,6 +860,13 @@ def parseArgs(argv) {
case 'service': case 'service':
args.command = 'service' args.command = 'service'
break break
case 'env':
// service env <action> <service_id>
if (args.command == 'service' && i + 2 < argv.size()) {
args.envAction = argv[++i]
args.envTarget = argv[++i]
}
break
case 'snapshot': case 'snapshot':
args.command = 'snapshot' args.command = 'snapshot'
break break
@ -752,7 +887,14 @@ def parseArgs(argv) {
break break
case '-e': case '-e':
case '--env': case '--env':
args.env << argv[++i] def envVal = argv[++i]
args.env << envVal
if (args.command == 'service') {
args.svcEnvs << envVal
}
break
case '--env-file':
args.svcEnvFile = argv[++i]
break break
case '-f': case '-f':
case '--files': case '--files':
@ -877,6 +1019,7 @@ def printHelp() {
println '''Usage: groovy un.groovy [options] <source_file> println '''Usage: groovy un.groovy [options] <source_file>
groovy un.groovy session [options] groovy un.groovy session [options]
groovy un.groovy service [options] groovy un.groovy service [options]
groovy un.groovy service env <action> <service_id> [options]
groovy un.groovy key [options] groovy un.groovy key [options]
Execute options: Execute options:
@ -899,6 +1042,8 @@ Service options:
--ports PORTS Comma-separated ports --ports PORTS Comma-separated ports
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
--bootstrap CMD Bootstrap command --bootstrap CMD Bootstrap command
-e KEY=VALUE Set env var in vault (when creating service)
--env-file FILE Load env vars from file
--info ID Get service details --info ID Get service details
--logs ID Get all logs --logs ID Get all logs
--tail ID Get last 9000 lines --tail ID Get last 9000 lines
@ -910,6 +1055,12 @@ Service options:
--dump-bootstrap ID Dump bootstrap script --dump-bootstrap ID Dump bootstrap script
--dump-file FILE File to save bootstrap (with --dump-bootstrap) --dump-file FILE File to save bootstrap (with --dump-bootstrap)
Vault commands:
service env status <id> Check vault status
service env set <id> Set vault (-e KEY=VAL or --env-file FILE)
service env export <id> Export vault contents
service env delete <id> Delete vault
Key options: Key options:
--extend Open browser to extend key --extend Open browser to extend key
-k KEY API key to validate -k KEY API key to validate
@ -923,7 +1074,12 @@ try {
if (args.command == 'session') { if (args.command == 'session') {
cmdSession(args) cmdSession(args)
} else if (args.command == 'service') { } else if (args.command == 'service') {
cmdService(args) // Check for env subcommand
if (args.envAction && args.envTarget) {
cmdServiceEnv(args)
} else {
cmdService(args)
}
} else if (args.command == 'snapshot') { } else if (args.command == 'snapshot') {
cmdSnapshot(args) cmdSnapshot(args)
} else if (args.command == 'key') { } else if (args.command == 'key') {

159
un.hs
View file

@ -155,12 +155,15 @@ data ServiceOpts = ServiceOpts
, svcSnapshotName :: Maybe String , svcSnapshotName :: Maybe String
, svcSnapshotFrom :: Maybe String , svcSnapshotFrom :: Maybe String
, svcHot :: Bool , svcHot :: Bool
, svcEnvs :: [(String, String)]
, svcEnvFile :: Maybe String
} }
data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String
| ServiceSleep String | ServiceWake String | ServiceDestroy String | ServiceSleep String | ServiceWake String | ServiceDestroy String
| ServiceExecute String String | ServiceDumpBootstrap String (Maybe String) | ServiceExecute String String | ServiceDumpBootstrap String (Maybe String)
| ServiceCreate | ServiceSnapshot String | ServiceRestore String | ServiceCreate | ServiceSnapshot String | ServiceRestore String
| ServiceEnv String (Maybe String) -- action, target
data SnapshotOpts = SnapshotOpts data SnapshotOpts = SnapshotOpts
{ snapAction :: SnapshotAction { snapAction :: SnapshotAction
@ -229,7 +232,7 @@ parseSession args = return $ parseSessionArgs args defaultSessionOpts
parseService :: [String] -> IO ServiceOpts parseService :: [String] -> IO ServiceOpts
parseService args = return $ parseServiceArgs args defaultServiceOpts parseService args = return $ parseServiceArgs args defaultServiceOpts
where where
defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing [] Nothing Nothing False defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing [] Nothing Nothing False [] Nothing
parseServiceArgs [] opts = opts parseServiceArgs [] opts = opts
parseServiceArgs ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList } parseServiceArgs ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList }
parseServiceArgs ("--info":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceInfo id } parseServiceArgs ("--info":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceInfo id }
@ -242,6 +245,8 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts
parseServiceArgs ("--execute":id:"--command":cmd:rest) opts = parseServiceArgs rest opts { svcAction = ServiceExecute id cmd } parseServiceArgs ("--execute":id:"--command":cmd:rest) opts = parseServiceArgs rest opts { svcAction = ServiceExecute id cmd }
parseServiceArgs ("--dump-bootstrap":id:file:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id (Just file) } parseServiceArgs ("--dump-bootstrap":id:file:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id (Just file) }
parseServiceArgs ("--dump-bootstrap":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id Nothing } parseServiceArgs ("--dump-bootstrap":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id Nothing }
parseServiceArgs ("env":action:target:rest) opts = parseServiceArgs rest opts { svcAction = ServiceEnv action (Just target) }
parseServiceArgs ("env":action:rest) opts = parseServiceArgs rest opts { svcAction = ServiceEnv action Nothing }
parseServiceArgs ("--name":n:rest) opts = parseServiceArgs rest opts { svcName = Just n } parseServiceArgs ("--name":n:rest) opts = parseServiceArgs rest opts { svcName = Just n }
parseServiceArgs ("--ports":p:rest) opts = parseServiceArgs rest opts { svcPorts = Just p } parseServiceArgs ("--ports":p:rest) opts = parseServiceArgs rest opts { svcPorts = Just p }
parseServiceArgs ("--type":t:rest) opts = parseServiceArgs rest opts { svcType = Just t } parseServiceArgs ("--type":t:rest) opts = parseServiceArgs rest opts { svcType = Just t }
@ -253,6 +258,10 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts
parseServiceArgs ("-n":net:rest) opts = parseServiceArgs rest opts { svcNetwork = Just net } parseServiceArgs ("-n":net:rest) opts = parseServiceArgs rest opts { svcNetwork = Just net }
parseServiceArgs ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) } parseServiceArgs ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) }
parseServiceArgs ("-f":f:rest) opts = parseServiceArgs rest opts { svcFiles = svcFiles opts ++ [f] } parseServiceArgs ("-f":f:rest) opts = parseServiceArgs rest opts { svcFiles = svcFiles opts ++ [f] }
parseServiceArgs ("-e":kv:rest) opts =
let (k, v) = span (/= '=') kv
in parseServiceArgs rest opts { svcEnvs = svcEnvs opts ++ [(k, drop 1 v)] }
parseServiceArgs ("--env-file":f:rest) opts = parseServiceArgs rest opts { svcEnvFile = Just f }
parseServiceArgs (_:rest) opts = parseServiceArgs rest opts parseServiceArgs (_:rest) opts = parseServiceArgs rest opts
parseExecute :: [String] -> IO Command parseExecute :: [String] -> IO Command
@ -298,6 +307,7 @@ printHelp = do
putStrLn " un.hs [options] <source_file> Execute code" putStrLn " un.hs [options] <source_file> Execute code"
putStrLn " un.hs session [options] Manage sessions" putStrLn " un.hs session [options] Manage sessions"
putStrLn " un.hs service [options] Manage services" putStrLn " un.hs service [options] Manage services"
putStrLn " un.hs service env <action> <id> Manage service vault"
putStrLn " un.hs snapshot [options] Manage snapshots" putStrLn " un.hs snapshot [options] Manage snapshots"
putStrLn " un.hs key [options] Validate/extend API key" putStrLn " un.hs key [options] Validate/extend API key"
putStrLn "" putStrLn ""
@ -309,6 +319,16 @@ printHelp = do
putStrLn " -n MODE Network mode (zerotrust|semitrusted)" putStrLn " -n MODE Network mode (zerotrust|semitrusted)"
putStrLn " -v N vCPU count (1-8)" putStrLn " -v N vCPU count (1-8)"
putStrLn "" putStrLn ""
putStrLn "Service options:"
putStrLn " -e KEY=VALUE Set vault env var (with --name or env set)"
putStrLn " --env-file FILE Load vault vars from file"
putStrLn ""
putStrLn "Service env commands:"
putStrLn " env status ID Check vault status"
putStrLn " env set ID Set vault (use -e or --env-file)"
putStrLn " env export ID Export vault contents"
putStrLn " env delete ID Delete vault"
putStrLn ""
putStrLn "Session snapshot options:" putStrLn "Session snapshot options:"
putStrLn " --snapshot ID Create snapshot of session" putStrLn " --snapshot ID Create snapshot of session"
putStrLn " --restore ID Restore session from snapshot" putStrLn " --restore ID Restore session from snapshot"
@ -483,6 +503,66 @@ serviceCommand opts = do
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}" (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}"
putStrLn $ green ++ "Service restored from snapshot" ++ reset putStrLn $ green ++ "Service restored from snapshot" ++ reset
putStrLn stdout putStrLn stdout
ServiceEnv action maybeTarget -> do
case action of
"status" -> case maybeTarget of
Just target -> do
result <- serviceEnvStatus target
let hasVault = "\"has_vault\":true" `isPrefixOf` dropWhile (/= 'h') result
if hasVault
then do
putStrLn $ green ++ "Vault: configured" ++ reset
case extractJsonString result "env_count" of
Just count -> putStrLn $ "Variables: " ++ count
Nothing -> return ()
case extractJsonString result "updated_at" of
Just updated -> putStrLn $ "Updated: " ++ updated
Nothing -> return ()
else putStrLn $ yellow ++ "Vault: not configured" ++ reset
Nothing -> do
hPutStrLn stderr $ red ++ "Error: service env status requires service ID" ++ reset
exitFailure
"set" -> case maybeTarget of
Just target -> do
if null (svcEnvs opts) && svcEnvFile opts == Nothing
then do
hPutStrLn stderr $ red ++ "Error: service env set requires -e or --env-file" ++ reset
exitFailure
else do
envContent <- buildEnvContent (svcEnvs opts) (svcEnvFile opts)
success <- serviceEnvSet target envContent
if success
then putStrLn $ green ++ "Vault updated for service " ++ target ++ reset
else do
hPutStrLn stderr $ red ++ "Error: Failed to update vault" ++ reset
exitFailure
Nothing -> do
hPutStrLn stderr $ red ++ "Error: service env set requires service ID" ++ reset
exitFailure
"export" -> case maybeTarget of
Just target -> do
result <- serviceEnvExport target
case extractJsonString result "content" of
Just content -> putStr content
Nothing -> return ()
Nothing -> do
hPutStrLn stderr $ red ++ "Error: service env export requires service ID" ++ reset
exitFailure
"delete" -> case maybeTarget of
Just target -> do
success <- serviceEnvDelete target
if success
then putStrLn $ green ++ "Vault deleted for service " ++ target ++ reset
else do
hPutStrLn stderr $ red ++ "Error: Failed to delete vault" ++ reset
exitFailure
Nothing -> do
hPutStrLn stderr $ red ++ "Error: service env delete requires service ID" ++ reset
exitFailure
_ -> do
hPutStrLn stderr $ red ++ "Error: Unknown env action: " ++ action ++ reset
hPutStrLn stderr "Usage: un.hs service env <status|set|export|delete> <service_id>"
exitFailure
ServiceCreate -> do ServiceCreate -> do
case svcName opts of case svcName opts of
Nothing -> do Nothing -> do
@ -515,6 +595,18 @@ serviceCommand opts = do
putStrLn $ green ++ "Service created" ++ reset putStrLn $ green ++ "Service created" ++ reset
putStrLn stdout putStrLn stdout
-- Auto-set vault if env vars were provided
when (not (null (svcEnvs opts)) || svcEnvFile opts /= Nothing) $ do
case extractJsonString stdout "id" of
Just serviceId -> do
envContent <- buildEnvContent (svcEnvs opts) (svcEnvFile opts)
when (not (null envContent)) $ do
success <- serviceEnvSet serviceId envContent
if success
then putStrLn $ green ++ "Vault configured with environment variables" ++ reset
else hPutStrLn stderr $ yellow ++ "Warning: Failed to set vault" ++ reset
Nothing -> return ()
-- Check for clock drift error -- Check for clock drift error
checkClockDriftError :: String -> IO () checkClockDriftError :: String -> IO ()
checkClockDriftError response = do checkClockDriftError response = do
@ -575,6 +667,71 @@ curlDelete apiKey url = do
checkClockDriftError stdout checkClockDriftError stdout
return (exitCode, stdout, stderr) return (exitCode, stdout, stderr)
curlPut :: String -> String -> String -> IO (ExitCode, String, String)
curlPut apiKey url body = do
(publicKey, secretKey) <- getApiKeys
let path = drop (length "https://api.unsandbox.com") url
authHeaders <- buildAuthHeaders publicKey secretKey "PUT" path body
(exitCode, stdout, stderr) <- readProcessWithExitCode "curl"
([ "-s", "-X", "PUT"
, url
, "-H", "Content-Type: text/plain"
] ++ authHeaders ++ ["-d", body]) ""
checkClockDriftError stdout
return (exitCode, stdout, stderr)
-- Vault helper functions
maxEnvContentSize :: Int
maxEnvContentSize = 65536
readEnvFile :: String -> IO String
readEnvFile path = do
content <- readFile path
return content
buildEnvContent :: [(String, String)] -> Maybe String -> IO String
buildEnvContent envs maybeEnvFile = do
-- Add from -e flags
let envLines = map (\(k, v) -> k ++ "=" ++ v) envs
-- Add from --env-file
fileLines <- case maybeEnvFile of
Just path -> do
content <- readEnvFile path
return $ filter (not . null) $ filter (not . isPrefixOf "#") $ map (filter (/= '\r')) $ lines content
Nothing -> return []
return $ intercalate "\n" (envLines ++ fileLines)
serviceEnvStatus :: String -> IO String
serviceEnvStatus serviceId = do
apiKey <- getApiKey
(_, stdout, _) <- curlGet apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env")
return stdout
serviceEnvSet :: String -> String -> IO Bool
serviceEnvSet serviceId envContent = do
if length envContent > maxEnvContentSize
then do
hPutStrLn stderr $ red ++ "Error: Env content exceeds maximum size of 64KB" ++ reset
return False
else do
apiKey <- getApiKey
(exitCode, _, _) <- curlPut apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env") envContent
return (exitCode == ExitSuccess)
serviceEnvExport :: String -> IO String
serviceEnvExport serviceId = do
apiKey <- getApiKey
(_, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env/export") "{}"
return stdout
serviceEnvDelete :: String -> IO Bool
serviceEnvDelete serviceId = do
apiKey <- getApiKey
(exitCode, _, _) <- curlDelete apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env")
return (exitCode == ExitSuccess)
-- Get API keys from environment -- Get API keys from environment
getApiKeys :: IO (String, Maybe String) getApiKeys :: IO (String, Maybe String)
getApiKeys = do getApiKeys = do

206
un.jl
View file

@ -159,6 +159,147 @@ function api_request(endpoint::String, public_key::String, secret_key::String; m
end end
end end
function api_request_text(endpoint::String, public_key::String, secret_key::String, body::String)::Bool
url = API_BASE * endpoint
timestamp = Int64(floor(time()))
signature = compute_signature(secret_key, timestamp, "PUT", endpoint, body)
headers = [
"Authorization" => "Bearer $public_key",
"X-Timestamp" => string(timestamp),
"X-Signature" => signature,
"Content-Type" => "text/plain"
]
try
response = HTTP.put(url, headers, body, readtimeout=300)
return response.status >= 200 && response.status < 300
catch e
return false
end
end
const MAX_ENV_CONTENT_SIZE = 65536
function read_env_file(path::String)::String
if !isfile(path)
println(stderr, "$(RED)Error: Env file not found: $path$(RESET)")
exit(1)
end
return read(path, String)
end
function build_env_content(envs::Vector{String}, env_file::Union{String,Nothing})::String
lines = copy(envs)
if env_file !== nothing
content = read_env_file(env_file)
for line in split(content, '\n')
trimmed = strip(line)
if !isempty(trimmed) && !startswith(trimmed, "#")
push!(lines, trimmed)
end
end
end
return join(lines, "\n")
end
function service_env_status(service_id::String, public_key::String, secret_key::String)
return api_request("/services/$service_id/env", public_key, secret_key)
end
function service_env_set(service_id::String, env_content::String, public_key::String, secret_key::String)::Bool
if length(env_content) > MAX_ENV_CONTENT_SIZE
println(stderr, "$(RED)Error: Env content exceeds maximum size of 64KB$(RESET)")
return false
end
return api_request_text("/services/$service_id/env", public_key, secret_key, env_content)
end
function service_env_export(service_id::String, public_key::String, secret_key::String)
return api_request("/services/$service_id/env/export", public_key, secret_key, method="POST", data=Dict())
end
function service_env_delete(service_id::String, public_key::String, secret_key::String)::Bool
try
api_request("/services/$service_id/env", public_key, secret_key, method="DELETE")
return true
catch
return false
end
end
function cmd_service_env(args)
(public_key, secret_key) = get_api_keys(args["api-key"])
action = get(args, "env-action", nothing)
target = get(args, "env-target", nothing)
if action == "status"
if target === nothing
println(stderr, "$(RED)Error: service env status requires service ID$(RESET)")
exit(1)
end
result = service_env_status(target, public_key, secret_key)
has_vault = get(result, "has_vault", false)
if has_vault
println("$(GREEN)Vault: configured$(RESET)")
env_count = get(result, "env_count", nothing)
if env_count !== nothing
println("Variables: $env_count")
end
updated_at = get(result, "updated_at", nothing)
if updated_at !== nothing
println("Updated: $updated_at")
end
else
println("$(YELLOW)Vault: not configured$(RESET)")
end
elseif action == "set"
if target === nothing
println(stderr, "$(RED)Error: service env set requires service ID$(RESET)")
exit(1)
end
envs = something(args["vault-env"], String[])
env_file = get(args, "env-file", nothing)
if isempty(envs) && env_file === nothing
println(stderr, "$(RED)Error: service env set requires -e or --env-file$(RESET)")
exit(1)
end
env_content = build_env_content(envs, env_file)
if service_env_set(target, env_content, public_key, secret_key)
println("$(GREEN)Vault updated for service $target$(RESET)")
else
println(stderr, "$(RED)Error: Failed to update vault$(RESET)")
exit(1)
end
elseif action == "export"
if target === nothing
println(stderr, "$(RED)Error: service env export requires service ID$(RESET)")
exit(1)
end
result = service_env_export(target, public_key, secret_key)
content = get(result, "content", nothing)
if content !== nothing
print(content)
end
elseif action == "delete"
if target === nothing
println(stderr, "$(RED)Error: service env delete requires service ID$(RESET)")
exit(1)
end
if service_env_delete(target, public_key, secret_key)
println("$(GREEN)Vault deleted for service $target$(RESET)")
else
println(stderr, "$(RED)Error: Failed to delete vault$(RESET)")
exit(1)
end
else
println(stderr, "$(RED)Error: Unknown env action: $action$(RESET)")
println(stderr, "Usage: un.jl service env <status|set|export|delete> <service_id>")
exit(1)
end
end
function cmd_execute(args) function cmd_execute(args)
(public_key, secret_key) = get_api_keys(args["api-key"]) (public_key, secret_key) = get_api_keys(args["api-key"])
@ -311,6 +452,12 @@ end
function cmd_service(args) function cmd_service(args)
(public_key, secret_key) = get_api_keys(args["api-key"]) (public_key, secret_key) = get_api_keys(args["api-key"])
# Handle env subcommand
if get(args, "env-action", nothing) !== nothing
cmd_service_env(args)
return
end
if args["list"] if args["list"]
result = api_request("/services", public_key, secret_key) result = api_request("/services", public_key, secret_key)
services = get(result, "services", []) services = get(result, "services", [])
@ -449,11 +596,26 @@ function cmd_service(args)
end end
result = api_request("/services", public_key, secret_key, method="POST", data=payload) result = api_request("/services", public_key, secret_key, method="POST", data=payload)
println("$(GREEN)Service created: $(get(result, "id", "N/A"))$(RESET)") service_id = get(result, "id", nothing)
println("$(GREEN)Service created: $(something(service_id, "N/A"))$(RESET)")
println("Name: $(get(result, "name", "N/A"))") println("Name: $(get(result, "name", "N/A"))")
if haskey(result, "url") if haskey(result, "url")
println("URL: $(result["url"])") println("URL: $(result["url"])")
end end
# Auto-set vault if env vars were provided
vault_envs = something(args["vault-env"], String[])
vault_env_file = get(args, "env-file", nothing)
if service_id !== nothing && (!isempty(vault_envs) || vault_env_file !== nothing)
env_content = build_env_content(vault_envs, vault_env_file)
if !isempty(env_content)
if service_env_set(service_id, env_content, public_key, secret_key)
println("$(GREEN)Vault configured with environment variables$(RESET)")
else
println("$(YELLOW)Warning: Failed to set vault$(RESET)")
end
end
end
return return
end end
@ -674,6 +836,11 @@ function main()
"--files", "-f" "--files", "-f"
help = "Add input file" help = "Add input file"
action = :append_arg action = :append_arg
"--vault-env", "-e"
help = "Environment variable for vault (KEY=VALUE)"
action = :append_arg
"--env-file"
help = "Load vault variables from file"
"--network", "-n" "--network", "-n"
help = "Network mode" help = "Network mode"
arg_type = String arg_type = String
@ -699,6 +866,30 @@ function main()
help = "Dump bootstrap script from service" help = "Dump bootstrap script from service"
"--dump-file" "--dump-file"
help = "File to save bootstrap (with --dump-bootstrap)" help = "File to save bootstrap (with --dump-bootstrap)"
"--env-action"
help = "Env action (status, set, export, delete)"
"--env-target"
help = "Service ID for env commands"
"--api-key", "-k"
help = "API key"
"env"
help = "Manage service environment vault"
action = :command
end
@add_arg_table! s["service"]["env"] begin
"action"
help = "Env action: status, set, export, delete"
required = true
"service_id"
help = "Service ID"
required = false
"-e"
help = "Environment variable (KEY=VALUE)"
action = :append_arg
dest_name = "vault-env"
"--env-file"
help = "Load vault variables from file"
"--api-key", "-k" "--api-key", "-k"
help = "API key" help = "API key"
end end
@ -716,7 +907,18 @@ function main()
if args["%COMMAND%"] == "session" if args["%COMMAND%"] == "session"
cmd_session(args["session"]) cmd_session(args["session"])
elseif args["%COMMAND%"] == "service" elseif args["%COMMAND%"] == "service"
cmd_service(args["service"]) service_args = args["service"]
# Check if env subcommand was used
if get(service_args, "%COMMAND%", nothing) == "env"
env_args = service_args["env"]
# Copy env args to service args
service_args["env-action"] = get(env_args, "action", nothing)
service_args["env-target"] = get(env_args, "service_id", nothing)
service_args["vault-env"] = get(env_args, "vault-env", nothing)
service_args["env-file"] = get(env_args, "env-file", nothing)
service_args["api-key"] = get(env_args, "api-key", nothing)
end
cmd_service(service_args)
elseif args["%COMMAND%"] == "key" elseif args["%COMMAND%"] == "key"
cmd_key(args["key"]) cmd_key(args["key"])
elseif args["source_file"] !== nothing elseif args["source_file"] !== nothing

222
un.js
View file

@ -189,6 +189,157 @@ function apiRequest(endpoint, method = "GET", data = null, publicKey = null, sec
}); });
} }
function apiRequestText(endpoint, method = "PUT", body = "", publicKey = null, secretKey = null) {
return new Promise((resolve, reject) => {
const url = new URL(API_BASE + endpoint);
// Generate HMAC signature
const timestamp = Math.floor(Date.now() / 1000).toString();
const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`;
const signature = crypto.createHmac('sha256', secretKey)
.update(signatureInput)
.digest('hex');
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: method,
headers: {
'Authorization': `Bearer ${publicKey}`,
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'text/plain'
},
timeout: 300000
};
const req = https.request(options, (res) => {
let responseBody = '';
res.on('data', chunk => responseBody += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(responseBody));
} catch (e) {
resolve(responseBody);
}
} else {
console.error(`${RED}Error: HTTP ${res.statusCode} - ${responseBody}${RESET}`);
resolve(null);
}
});
});
req.on('error', (e) => {
console.error(`${RED}Error: ${e.message}${RESET}`);
resolve(null);
});
if (body) {
req.write(body);
}
req.end();
});
}
// ============================================================================
// Environment Secrets Vault Functions
// ============================================================================
const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max env vault size
async function serviceEnvStatus(publicKey, secretKey, serviceId) {
const result = await apiRequest(`/services/${serviceId}/env`, "GET", null, publicKey, secretKey);
const hasVault = result.has_vault || false;
if (!hasVault) {
console.log("Vault exists: no");
console.log("Variable count: 0");
} else {
console.log("Vault exists: yes");
console.log(`Variable count: ${result.count || 0}`);
if (result.updated_at) {
const dt = new Date(result.updated_at * 1000);
console.log(`Last updated: ${dt.toISOString().replace('T', ' ').substring(0, 19)}`);
}
}
}
async function serviceEnvSet(publicKey, secretKey, serviceId, envContent) {
if (!envContent || envContent.length === 0) {
console.error(`${RED}Error: No environment content provided${RESET}`);
return false;
}
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
console.error(`${RED}Error: Environment content too large (max ${MAX_ENV_CONTENT_SIZE} bytes)${RESET}`);
return false;
}
const result = await apiRequestText(`/services/${serviceId}/env`, "PUT", envContent, publicKey, secretKey);
if (result === null) {
return false;
}
const count = result.count !== undefined ? result.count : -1;
if (count >= 0) {
console.log(`${GREEN}Environment vault updated: ${count} variable${count !== 1 ? 's' : ''}${RESET}`);
} else {
console.log(`${GREEN}Environment vault updated${RESET}`);
}
if (result.message) {
console.log(result.message);
}
return true;
}
async function serviceEnvExport(publicKey, secretKey, serviceId) {
const result = await apiRequest(`/services/${serviceId}/env/export`, "POST", {}, publicKey, secretKey);
const envContent = result.env || "";
if (envContent) {
process.stdout.write(envContent);
if (!envContent.endsWith('\n')) {
console.log();
}
}
}
async function serviceEnvDelete(publicKey, secretKey, serviceId) {
await apiRequest(`/services/${serviceId}/env`, "DELETE", null, publicKey, secretKey);
console.log(`${GREEN}Environment vault deleted${RESET}`);
}
function readEnvFile(filepath) {
try {
return fs.readFileSync(filepath, 'utf-8');
} catch (e) {
console.error(`${RED}Error: Env file not found: ${filepath}${RESET}`);
process.exit(1);
}
}
function buildEnvContent(envVars, envFile) {
const parts = [];
// Read from env file first
if (envFile) {
parts.push(readEnvFile(envFile));
}
// Add -e flags (these override/append to file)
if (envVars && envVars.length > 0) {
envVars.forEach(e => {
if (e.includes('=')) {
parts.push(e);
}
});
}
return parts.length > 0 ? parts.join('\n') : null;
}
function portalRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) { function portalRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(PORTAL_BASE + endpoint); const url = new URL(PORTAL_BASE + endpoint);
@ -456,6 +607,43 @@ async function cmdSession(args) {
async function cmdService(args) { async function cmdService(args) {
const { publicKey, secretKey } = getApiKeys(args.apiKey); const { publicKey, secretKey } = getApiKeys(args.apiKey);
// Handle env subcommand: un.js service env <action> <id>
if (args.envSubcommand === 'env') {
const action = args.envAction;
const target = args.envTarget;
if (!action) {
console.error(`${RED}Error: env action required (status, set, export, delete)${RESET}`);
process.exit(1);
}
if (!target) {
console.error(`${RED}Error: Service ID required for env command${RESET}`);
process.exit(1);
}
if (action === 'status') {
await serviceEnvStatus(publicKey, secretKey, target);
return;
} else if (action === 'set') {
let envContent = buildEnvContent(args.env, args.envFile);
if (!envContent) {
console.error(`${RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin${RESET}`);
process.exit(1);
}
await serviceEnvSet(publicKey, secretKey, target, envContent);
return;
} else if (action === 'export') {
await serviceEnvExport(publicKey, secretKey, target);
return;
} else if (action === 'delete') {
await serviceEnvDelete(publicKey, secretKey, target);
return;
} else {
console.error(`${RED}Error: Unknown env action '${action}'. Use: status, set, export, delete${RESET}`);
process.exit(1);
}
}
if (args.list) { if (args.list) {
const result = await apiRequest("/services", "GET", null, publicKey, secretKey); const result = await apiRequest("/services", "GET", null, publicKey, secretKey);
const services = result.services || []; const services = result.services || [];
@ -578,9 +766,21 @@ async function cmdService(args) {
if (args.vcpu) payload.vcpu = args.vcpu; if (args.vcpu) payload.vcpu = args.vcpu;
const result = await apiRequest("/services", "POST", payload, publicKey, secretKey); const result = await apiRequest("/services", "POST", payload, publicKey, secretKey);
console.log(`${GREEN}Service created: ${result.id || 'N/A'}${RESET}`); const createdId = result.id;
console.log(`${GREEN}Service created: ${createdId || 'N/A'}${RESET}`);
console.log(`Name: ${result.name || 'N/A'}`); console.log(`Name: ${result.name || 'N/A'}`);
if (result.url) console.log(`URL: ${result.url}`); if (result.url) console.log(`URL: ${result.url}`);
// Set environment vault if -e or --env-file provided
if (createdId) {
const envContent = buildEnvContent(args.env, args.envFile);
if (envContent) {
console.error(`${YELLOW}Setting environment vault...${RESET}`);
if (!await serviceEnvSet(publicKey, secretKey, createdId, envContent)) {
console.error(`${YELLOW}Warning: Failed to set environment vault${RESET}`);
}
}
}
return; return;
} }
@ -622,6 +822,10 @@ function parseArgs(argv) {
dumpFile: null, dumpFile: null,
extend: false, extend: false,
execShell: null, execShell: null,
envFile: null,
envSubcommand: null,
envAction: null,
envTarget: null,
}; };
let i = 2; let i = 2;
@ -631,6 +835,19 @@ function parseArgs(argv) {
if (arg === 'session' || arg === 'service' || arg === 'key') { if (arg === 'session' || arg === 'service' || arg === 'key') {
args.command = arg; args.command = arg;
i++; i++;
// Check for env subcommand: service env <action> <target>
if (arg === 'service' && i < argv.length && argv[i] === 'env') {
args.envSubcommand = 'env';
i++;
if (i < argv.length && !argv[i].startsWith('-')) {
args.envAction = argv[i];
i++;
}
if (i < argv.length && !argv[i].startsWith('-')) {
args.envTarget = argv[i];
i++;
}
}
} else if (arg === '-e' && i + 1 < argv.length) { } else if (arg === '-e' && i + 1 < argv.length) {
args.env.push(argv[++i]); args.env.push(argv[++i]);
i++; i++;
@ -726,6 +943,9 @@ function parseArgs(argv) {
} else if (arg === '--dump-file' && i + 1 < argv.length) { } else if (arg === '--dump-file' && i + 1 < argv.length) {
args.dumpFile = argv[++i]; args.dumpFile = argv[++i];
i++; i++;
} else if (arg === '--env-file' && i + 1 < argv.length) {
args.envFile = argv[++i];
i++;
} else if (arg === '--extend') { } else if (arg === '--extend') {
args.extend = true; args.extend = true;
i++; i++;

194
un.kt
View file

@ -101,7 +101,10 @@ data class Args(
var serviceCommand: String? = null, var serviceCommand: String? = null,
var serviceDumpBootstrap: String? = null, var serviceDumpBootstrap: String? = null,
var serviceDumpFile: String? = null, var serviceDumpFile: String? = null,
var keyExtend: Boolean = false var keyExtend: Boolean = false,
var envFile: String? = null,
var envAction: String? = null,
var envTarget: String? = null
) )
fun main(args: Array<String>) { fun main(args: Array<String>) {
@ -264,6 +267,12 @@ fun cmdSession(args: Args) {
fun cmdService(args: Args) { fun cmdService(args: Args) {
val (publicKey, secretKey) = getApiKeys(args.apiKey) val (publicKey, secretKey) = getApiKeys(args.apiKey)
// Handle env subcommand
if (args.envAction != null) {
cmdServiceEnv(args)
return
}
if (args.serviceList) { if (args.serviceList) {
val result = apiRequest("/services", "GET", null, publicKey, secretKey) val result = apiRequest("/services", "GET", null, publicKey, secretKey)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -412,11 +421,24 @@ fun cmdService(args: Args) {
} }
val result = apiRequest("/services", "POST", payload, publicKey, secretKey) val result = apiRequest("/services", "POST", payload, publicKey, secretKey)
println("${GREEN}Service created: ${result["id"] ?: "N/A"}${RESET}") val serviceId = result["id"] as? String
println("${GREEN}Service created: ${serviceId ?: "N/A"}${RESET}")
println("Name: ${result["name"] ?: "N/A"}") println("Name: ${result["name"] ?: "N/A"}")
if (result.containsKey("url")) { if (result.containsKey("url")) {
println("URL: ${result["url"]}") println("URL: ${result["url"]}")
} }
// Auto-set vault if env vars were provided
if (serviceId != null && (args.env.isNotEmpty() || args.envFile != null)) {
val envContent = buildEnvContent(args.env, args.envFile)
if (envContent.isNotEmpty()) {
if (serviceEnvSet(serviceId, envContent, publicKey, secretKey)) {
println("${GREEN}Vault configured with environment variables${RESET}")
} else {
println("${YELLOW}Warning: Failed to set vault${RESET}")
}
}
}
return return
} }
@ -587,6 +609,153 @@ fun apiRequest(endpoint: String, method: String, data: Map<String, Any>?, public
return parseJson(response) return parseJson(response)
} }
fun apiRequestText(endpoint: String, method: String, body: String, publicKey: String?, secretKey: String): Pair<Boolean, String> {
val timestamp = System.currentTimeMillis() / 1000
val signatureData = "$timestamp:$method:$endpoint:$body"
val signature = hmacSha256(secretKey, signatureData)
val url = URL(API_BASE + endpoint)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = method
connection.setRequestProperty("Authorization", "Bearer ${publicKey ?: secretKey}")
connection.setRequestProperty("X-Timestamp", timestamp.toString())
connection.setRequestProperty("X-Signature", signature)
connection.setRequestProperty("Content-Type", "text/plain")
connection.connectTimeout = 30000
connection.readTimeout = 300000
connection.doOutput = true
connection.outputStream.use { it.write(body.toByteArray()) }
return if (connection.responseCode in 200..299) {
Pair(true, connection.inputStream.bufferedReader().readText())
} else {
Pair(false, connection.errorStream?.bufferedReader()?.readText() ?: "")
}
}
const val MAX_ENV_CONTENT_SIZE = 65536
fun readEnvFile(path: String): String {
val file = File(path)
if (!file.exists()) {
System.err.println("${RED}Error: Env file not found: $path${RESET}")
exitProcess(1)
}
return file.readText()
}
fun buildEnvContent(envs: List<String>, envFile: String?): String {
val lines = mutableListOf<String>()
lines.addAll(envs)
if (envFile != null) {
val content = readEnvFile(envFile)
for (line in content.lines()) {
val trimmed = line.trim()
if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) {
lines.add(trimmed)
}
}
}
return lines.joinToString("\n")
}
fun serviceEnvStatus(serviceId: String, publicKey: String?, secretKey: String): Map<String, Any> {
return apiRequest("/services/$serviceId/env", "GET", null, publicKey, secretKey)
}
fun serviceEnvSet(serviceId: String, envContent: String, publicKey: String?, secretKey: String): Boolean {
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
System.err.println("${RED}Error: Env content exceeds maximum size of 64KB${RESET}")
return false
}
val (success, _) = apiRequestText("/services/$serviceId/env", "PUT", envContent, publicKey, secretKey)
return success
}
fun serviceEnvExport(serviceId: String, publicKey: String?, secretKey: String): Map<String, Any> {
return apiRequest("/services/$serviceId/env/export", "POST", emptyMap(), publicKey, secretKey)
}
fun serviceEnvDelete(serviceId: String, publicKey: String?, secretKey: String): Boolean {
return try {
apiRequest("/services/$serviceId/env", "DELETE", null, publicKey, secretKey)
true
} catch (e: Exception) {
false
}
}
fun cmdServiceEnv(args: Args) {
val (publicKey, secretKey) = getApiKeys(args.apiKey)
val action = args.envAction
val target = args.envTarget
when (action) {
"status" -> {
if (target == null) {
System.err.println("${RED}Error: service env status requires service ID${RESET}")
exitProcess(1)
}
val result = serviceEnvStatus(target, publicKey, secretKey)
val hasVault = result["has_vault"] as? Boolean ?: false
if (hasVault) {
println("${GREEN}Vault: configured${RESET}")
val envCount = result["env_count"]
if (envCount != null) println("Variables: $envCount")
val updatedAt = result["updated_at"]
if (updatedAt != null) println("Updated: $updatedAt")
} else {
println("${YELLOW}Vault: not configured${RESET}")
}
}
"set" -> {
if (target == null) {
System.err.println("${RED}Error: service env set requires service ID${RESET}")
exitProcess(1)
}
if (args.env.isEmpty() && args.envFile == null) {
System.err.println("${RED}Error: service env set requires -e or --env-file${RESET}")
exitProcess(1)
}
val envContent = buildEnvContent(args.env, args.envFile)
if (serviceEnvSet(target, envContent, publicKey, secretKey)) {
println("${GREEN}Vault updated for service $target${RESET}")
} else {
System.err.println("${RED}Error: Failed to update vault${RESET}")
exitProcess(1)
}
}
"export" -> {
if (target == null) {
System.err.println("${RED}Error: service env export requires service ID${RESET}")
exitProcess(1)
}
val result = serviceEnvExport(target, publicKey, secretKey)
val content = result["content"] as? String
if (content != null) print(content)
}
"delete" -> {
if (target == null) {
System.err.println("${RED}Error: service env delete requires service ID${RESET}")
exitProcess(1)
}
if (serviceEnvDelete(target, publicKey, secretKey)) {
println("${GREEN}Vault deleted for service $target${RESET}")
} else {
System.err.println("${RED}Error: Failed to delete vault${RESET}")
exitProcess(1)
}
}
else -> {
System.err.println("${RED}Error: Unknown env action: $action${RESET}")
System.err.println("Usage: kotlin UnKt service env <status|set|export|delete> <service_id>")
exitProcess(1)
}
}
}
fun toJson(obj: Any?): String = when (obj) { fun toJson(obj: Any?): String = when (obj) {
null -> "null" null -> "null"
is String -> "\"${obj.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")}\"" is String -> "\"${obj.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")}\""
@ -748,6 +917,15 @@ fun parseArgs(args: Array<String>): Args {
"--dump-bootstrap" -> result.serviceDumpBootstrap = args[++i] "--dump-bootstrap" -> result.serviceDumpBootstrap = args[++i]
"--dump-file" -> result.serviceDumpFile = args[++i] "--dump-file" -> result.serviceDumpFile = args[++i]
"--extend" -> result.keyExtend = true "--extend" -> result.keyExtend = true
"--env-file" -> result.envFile = args[++i]
"env" -> {
if (result.command == "service" && i + 1 < args.size) {
result.envAction = args[++i]
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
result.envTarget = args[++i]
}
}
}
else -> { else -> {
if (args[i].startsWith("-")) { if (args[i].startsWith("-")) {
System.err.println("${RED}Unknown option: ${args[i]}${RESET}") System.err.println("${RED}Unknown option: ${args[i]}${RESET}")
@ -789,17 +967,25 @@ Service options:
--ports PORTS Comma-separated ports --ports PORTS Comma-separated ports
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
--bootstrap CMD Bootstrap command --bootstrap CMD Bootstrap command
-e KEY=VALUE Environment variable for vault
--env-file FILE Load vault variables from file
--info ID Get service details --info ID Get service details
--logs ID Get all logs --logs ID Get all logs
--tail ID Get last 9000 lines --tail ID Get last 9000 lines
--freeze ID Freeze service --freeze ID Freeze service
--unfreeze ID Unfreeze service --unfreeze ID Unfreeze service
--destroy ID Destroy service --destroy ID Destroy service
--execute ID Execute command in service --execute ID Execute command in service
--command CMD Command to execute (with --execute) --command CMD Command to execute (with --execute)
--dump-bootstrap ID Dump bootstrap script --dump-bootstrap ID Dump bootstrap script
--dump-file FILE File to save bootstrap (with --dump-bootstrap) --dump-file FILE File to save bootstrap (with --dump-bootstrap)
Service env commands:
env status ID Show vault status
env set ID Set vault (-e KEY=VALUE or --env-file FILE)
env export ID Export vault contents
env delete ID Delete vault
Key options: Key options:
--extend Open browser to extend key --extend Open browser to extend key
""".trimIndent()) """.trimIndent())

131
un.lisp
View file

@ -186,6 +186,52 @@
response)) response))
(delete-file tmp-file)))) (delete-file tmp-file))))
(defun curl-put-text (api-key endpoint content)
"PUT request with text/plain content type (for vault)"
(let ((tmp-file (write-temp-file content)))
(unwind-protect
(destructuring-bind (public-key secret-key) (get-api-keys)
(let* ((auth-headers (build-auth-headers public-key secret-key "PUT" endpoint content))
(base-args (list "curl" "-s" "-X" "PUT"
(format nil "https://api.unsandbox.com~a" endpoint)
"-H" "Content-Type: text/plain"))
(response (run-curl (append base-args auth-headers (list "--data-binary" (format nil "@~a" tmp-file))))))
(check-clock-drift response)
response))
(delete-file tmp-file))))
(defun build-env-content (env-vars env-file)
"Build env content from list of env vars and env file"
(let ((lines '()))
;; Add env vars
(dolist (var env-vars)
(push var lines))
;; Add env file contents
(when (and env-file (probe-file env-file))
(with-open-file (stream env-file)
(loop for line = (read-line stream nil)
while line
do (let ((trimmed (string-trim '(#\Space #\Tab) line)))
(when (and (> (length trimmed) 0)
(not (char= (char trimmed 0) #\#)))
(push line lines))))))
(format nil "~{~a~^~%~}" (nreverse lines))))
(defun service-env-status (api-key service-id)
(format t "~a~%" (curl-get api-key (format nil "/services/~a/env" service-id))))
(defun service-env-set (api-key service-id content)
(format t "~a~%" (curl-put-text api-key (format nil "/services/~a/env" service-id) content)))
(defun service-env-export (api-key service-id)
(let* ((response (curl-post api-key (format nil "/services/~a/env/export" service-id) "{}"))
(content (parse-json-field response "content")))
(when content (format t "~a" content))))
(defun service-env-delete (api-key service-id)
(curl-delete api-key (format nil "/services/~a/env" service-id))
(format t "~aVault deleted: ~a~a~%" *green* service-id *reset*))
(defun get-api-keys () (defun get-api-keys ()
(let ((public-key (uiop:getenv "UNSANDBOX_PUBLIC_KEY")) (let ((public-key (uiop:getenv "UNSANDBOX_PUBLIC_KEY"))
(secret-key (uiop:getenv "UNSANDBOX_SECRET_KEY")) (secret-key (uiop:getenv "UNSANDBOX_SECRET_KEY"))
@ -252,7 +298,7 @@
(format t "~aSession created (WebSocket required)~a~%" *yellow* *reset*) (format t "~aSession created (WebSocket required)~a~%" *yellow* *reset*)
(format t "~a~%" response)))))) (format t "~a~%" response))))))
(defun service-cmd (action id name ports bootstrap bootstrap-file service-type input-files) (defun service-cmd (action id name ports bootstrap bootstrap-file service-type input-files env-vars env-file)
(let ((api-key (get-api-key))) (let ((api-key (get-api-key)))
(cond (cond
((string= action "list") ((string= action "list")
@ -294,6 +340,21 @@
(progn (progn
(format *error-output* "~aError: Failed to fetch bootstrap (service not running or no bootstrap file)~a~%" *red* *reset*) (format *error-output* "~aError: Failed to fetch bootstrap (service not running or no bootstrap file)~a~%" *red* *reset*)
(uiop:quit 1)))))) (uiop:quit 1))))))
;; Vault commands
((string= action "env-status")
(service-env-status api-key id))
((string= action "env-set")
(let ((content (build-env-content env-vars env-file)))
(if (> (length content) 0)
(service-env-set api-key id content)
(progn
(format *error-output* "~aError: No environment variables to set~a~%" *red* *reset*)
(uiop:quit 1)))))
((string= action "env-export")
(service-env-export api-key id))
((string= action "env-delete")
(service-env-delete api-key id))
;; Create service
((and (string= action "create") name) ((and (string= action "create") name)
(let* ((ports-json (if ports (format nil ",\"ports\":[~a]" ports) "")) (let* ((ports-json (if ports (format nil ",\"ports\":[~a]" ports) ""))
(bootstrap-json (if bootstrap (format nil ",\"bootstrap\":\"~a\"" (escape-json bootstrap)) "")) (bootstrap-json (if bootstrap (format nil ",\"bootstrap\":\"~a\"" (escape-json bootstrap)) ""))
@ -303,11 +364,17 @@
(type-json (if service-type (format nil ",\"service_type\":\"~a\"" service-type) "")) (type-json (if service-type (format nil ",\"service_type\":\"~a\"" service-type) ""))
(input-files-json (build-input-files-json input-files)) (input-files-json (build-input-files-json input-files))
(json (format nil "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json)) (json (format nil "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json))
(response (curl-post api-key "/services" json))) (response (curl-post api-key "/services" json))
(service-id (parse-json-field response "id")))
(format t "~aService created~a~%" *green* *reset*) (format t "~aService created~a~%" *green* *reset*)
(format t "~a~%" response))) (format t "~a~%" response)
;; Auto-set vault if env vars were provided
(let ((env-content (build-env-content env-vars env-file)))
(when (and service-id (> (length env-content) 0))
(format t "~aSetting vault for service...~a~%" *yellow* *reset*)
(service-env-set api-key service-id env-content)))))
(t (t
(format t "Error: --name required to create service~%") (format t "Error: --name required to create service, or use env subcommand~%")
(uiop:quit 1))))) (uiop:quit 1)))))
(defun parse-json-field (json field) (defun parse-json-field (json field)
@ -444,23 +511,53 @@
((string= (first args) "service") ((string= (first args) "service")
(cond (cond
((and (> (length args) 1) (string= (second args) "--list")) ((and (> (length args) 1) (string= (second args) "--list"))
(service-cmd "list" nil nil nil nil nil nil nil)) (service-cmd "list" nil nil nil nil nil nil nil nil nil))
((and (> (length args) 2) (string= (second args) "--info")) ((and (> (length args) 2) (string= (second args) "--info"))
(service-cmd "info" (third args) nil nil nil nil nil nil)) (service-cmd "info" (third args) nil nil nil nil nil nil nil nil))
((and (> (length args) 2) (string= (second args) "--logs")) ((and (> (length args) 2) (string= (second args) "--logs"))
(service-cmd "logs" (third args) nil nil nil nil nil nil)) (service-cmd "logs" (third args) nil nil nil nil nil nil nil nil))
((and (> (length args) 2) (string= (second args) "--freeze")) ((and (> (length args) 2) (string= (second args) "--freeze"))
(service-cmd "sleep" (third args) nil nil nil nil nil nil)) (service-cmd "sleep" (third args) nil nil nil nil nil nil nil nil))
((and (> (length args) 2) (string= (second args) "--unfreeze")) ((and (> (length args) 2) (string= (second args) "--unfreeze"))
(service-cmd "wake" (third args) nil nil nil nil nil nil)) (service-cmd "wake" (third args) nil nil nil nil nil nil nil nil))
((and (> (length args) 2) (string= (second args) "--destroy")) ((and (> (length args) 2) (string= (second args) "--destroy"))
(service-cmd "destroy" (third args) nil nil nil nil nil nil)) (service-cmd "destroy" (third args) nil nil nil nil nil nil nil nil))
((and (> (length args) 3) (string= (second args) "--execute")) ((and (> (length args) 3) (string= (second args) "--execute"))
(service-cmd "execute" (third args) nil nil (fourth args) nil nil nil)) (service-cmd "execute" (third args) nil nil (fourth args) nil nil nil nil nil))
((and (> (length args) 3) (string= (second args) "--dump-bootstrap")) ((and (> (length args) 3) (string= (second args) "--dump-bootstrap"))
(service-cmd "dump-bootstrap" (third args) nil nil nil nil (fourth args) nil)) (service-cmd "dump-bootstrap" (third args) nil nil nil nil (fourth args) nil nil nil))
((and (> (length args) 2) (string= (second args) "--dump-bootstrap")) ((and (> (length args) 2) (string= (second args) "--dump-bootstrap"))
(service-cmd "dump-bootstrap" (third args) nil nil nil nil nil nil)) (service-cmd "dump-bootstrap" (third args) nil nil nil nil nil nil nil nil))
;; Service env subcommand: service env <action> <id> [options]
((and (> (length args) 1) (string= (second args) "env"))
(if (< (length args) 4)
(progn
(format *error-output* "Usage: un.lisp service env <status|set|export|delete> <service_id> [options]~%")
(uiop:quit 1))
(let* ((env-action (third args))
(service-id (fourth args))
(rest-args (if (> (length args) 4) (nthcdr 4 args) nil)))
(cond
((string= env-action "status")
(service-cmd "env-status" service-id nil nil nil nil nil nil nil nil))
((string= env-action "set")
;; Parse -e and --env-file from rest-args
(let ((env-vars nil)
(env-file nil))
(loop for i from 0 below (1- (length rest-args))
do (let ((opt (nth i rest-args))
(val (nth (1+ i) rest-args)))
(cond
((string= opt "-e") (push val env-vars))
((string= opt "--env-file") (setf env-file val)))))
(service-cmd "env-set" service-id nil nil nil nil nil nil (nreverse env-vars) env-file)))
((string= env-action "export")
(service-cmd "env-export" service-id nil nil nil nil nil nil nil nil))
((string= env-action "delete")
(service-cmd "env-delete" service-id nil nil nil nil nil nil nil nil))
(t
(format *error-output* "~aUnknown env action: ~a~a~%" *red* env-action *reset*)
(uiop:quit 1))))))
((and (> (length args) 2) (string= (second args) "--name")) ((and (> (length args) 2) (string= (second args) "--name"))
(let* ((name (third args)) (let* ((name (third args))
(rest-args (nthcdr 3 args)) (rest-args (nthcdr 3 args))
@ -468,6 +565,8 @@
(bootstrap nil) (bootstrap nil)
(bootstrap-file nil) (bootstrap-file nil)
(service-type nil) (service-type nil)
(env-vars nil)
(env-file nil)
(input-files (parse-input-files rest-args))) (input-files (parse-input-files rest-args)))
(loop for i from 0 below (1- (length rest-args)) (loop for i from 0 below (1- (length rest-args))
do (let ((opt (nth i rest-args)) do (let ((opt (nth i rest-args))
@ -476,8 +575,10 @@
((string= opt "--ports") (setf ports val)) ((string= opt "--ports") (setf ports val))
((string= opt "--bootstrap") (setf bootstrap val)) ((string= opt "--bootstrap") (setf bootstrap val))
((string= opt "--bootstrap-file") (setf bootstrap-file val)) ((string= opt "--bootstrap-file") (setf bootstrap-file val))
((string= opt "--type") (setf service-type val))))) ((string= opt "--type") (setf service-type val))
(service-cmd "create" nil name ports bootstrap bootstrap-file service-type input-files))) ((string= opt "-e") (push val env-vars))
((string= opt "--env-file") (setf env-file val)))))
(service-cmd "create" nil name ports bootstrap bootstrap-file service-type input-files (nreverse env-vars) env-file)))
(t (t
(format t "Error: Invalid service command~%") (format t "Error: Invalid service command~%")
(uiop:quit 1)))) (uiop:quit 1))))

211
un.lua
View file

@ -201,6 +201,178 @@ local function api_request(endpoint, method, data, keys)
return json.decode(response) return json.decode(response)
end end
local function api_request_text(endpoint, method, body, keys)
local url = API_BASE .. endpoint
local tmpfile = os.tmpname()
-- Generate timestamp and signature
local timestamp = tostring(os.time())
local path = endpoint
-- Create HMAC signature using openssl command
local message = timestamp .. ":" .. method .. ":" .. path .. ":" .. body
local msg_tmpfile = os.tmpname()
local f = io.open(msg_tmpfile, "w")
f:write(message)
f:close()
local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'"
local sig_handle = io.popen(hmac_cmd)
local signature = sig_handle:read("*a"):gsub("%s+$", "")
sig_handle:close()
os.remove(msg_tmpfile)
local data_file = os.tmpname()
local df = io.open(data_file, "w")
df:write(body)
df:close()
local cmd = "curl -s -X " .. method .. " " .. shell_escape(url) ..
" -H 'Authorization: Bearer " .. keys.public_key .. "'" ..
" -H 'X-Timestamp: " .. timestamp .. "'" ..
" -H 'X-Signature: " .. signature .. "'" ..
" -H 'Content-Type: text/plain'" ..
" -d @" .. shell_escape(data_file) ..
" -w '\\n%{http_code}' -o " .. shell_escape(tmpfile)
local handle = io.popen(cmd)
local http_code = handle:read("*a"):match("(%d+)$")
handle:close()
local file = io.open(tmpfile, "r")
local response = file:read("*all")
file:close()
os.remove(tmpfile)
os.remove(data_file)
if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then
return { error = "HTTP " .. (http_code or "000") .. " - " .. response }
end
return json.decode(response)
end
-- ============================================================================
-- Environment Secrets Vault Functions
-- ============================================================================
local MAX_ENV_CONTENT_SIZE = 64 * 1024 -- 64KB max
local function service_env_status(service_id, keys)
local result = api_request("/services/" .. service_id .. "/env", "GET", nil, keys)
local has_vault = result.has_vault
if not has_vault then
print("Vault exists: no")
print("Variable count: 0")
else
print("Vault exists: yes")
print("Variable count: " .. (result.count or 0))
if result.updated_at then
print("Last updated: " .. os.date("%Y-%m-%d %H:%M:%S", result.updated_at))
end
end
end
local function service_env_set(service_id, env_content, keys)
if not env_content or env_content == "" then
io.stderr:write(RED .. "Error: No environment content provided" .. RESET .. "\n")
return false
end
if #env_content > MAX_ENV_CONTENT_SIZE then
io.stderr:write(RED .. "Error: Environment content too large (max " .. MAX_ENV_CONTENT_SIZE .. " bytes)" .. RESET .. "\n")
return false
end
local result = api_request_text("/services/" .. service_id .. "/env", "PUT", env_content, keys)
if result.error then
io.stderr:write(RED .. "Error: " .. result.error .. RESET .. "\n")
return false
end
local count = result.count or 0
local plural = count == 1 and "" or "s"
print(GREEN .. "Environment vault updated: " .. count .. " variable" .. plural .. RESET)
if result.message then print(result.message) end
return true
end
local function service_env_export(service_id, keys)
local result = api_request("/services/" .. service_id .. "/env/export", "POST", {}, keys)
local env_content = result.env
if env_content and env_content ~= "" then
io.write(env_content)
if not env_content:match("\n$") then print() end
end
end
local function service_env_delete(service_id, keys)
api_request("/services/" .. service_id .. "/env", "DELETE", nil, keys)
print(GREEN .. "Environment vault deleted" .. RESET)
end
local function read_env_file_content(filepath)
local file = io.open(filepath, "r")
if not file then
io.stderr:write(RED .. "Error: Env file not found: " .. filepath .. RESET .. "\n")
os.exit(1)
end
local content = file:read("*all")
file:close()
return content
end
local function build_env_content(envs, env_file)
local parts = {}
-- Read from env file first
if env_file and env_file ~= "" then
table.insert(parts, read_env_file_content(env_file))
end
-- Add -e flags
for _, e in ipairs(envs) do
if e:find("=") then
table.insert(parts, e)
end
end
return table.concat(parts, "\n")
end
local function cmd_service_env(action, target, envs, env_file, keys)
if not action or action == "" then
io.stderr:write(RED .. "Error: env action required (status, set, export, delete)" .. RESET .. "\n")
os.exit(1)
end
if not target or target == "" then
io.stderr:write(RED .. "Error: Service ID required for env command" .. RESET .. "\n")
os.exit(1)
end
if action == "status" then
service_env_status(target, keys)
elseif action == "set" then
local env_content = build_env_content(envs, env_file)
if env_content == "" then
io.stderr:write(RED .. "Error: No env content provided. Use -e KEY=VAL or --env-file" .. RESET .. "\n")
os.exit(1)
end
service_env_set(target, env_content, keys)
elseif action == "export" then
service_env_export(target, keys)
elseif action == "delete" then
service_env_delete(target, keys)
else
io.stderr:write(RED .. "Error: Unknown env action '" .. action .. "'. Use: status, set, export, delete" .. RESET .. "\n")
os.exit(1)
end
end
local function read_file(filename) local function read_file(filename)
local file, err = io.open(filename, "rb") local file, err = io.open(filename, "rb")
if not file then if not file then
@ -648,9 +820,16 @@ local function cmd_service(options)
if options.vcpu then payload.vcpu = options.vcpu end if options.vcpu then payload.vcpu = options.vcpu end
local result = api_request("/services", "POST", payload, keys) local result = api_request("/services", "POST", payload, keys)
print(GREEN .. "Service created: " .. (result.id or "N/A") .. RESET) local service_id = result.id
print(GREEN .. "Service created: " .. (service_id or "N/A") .. RESET)
print("Name: " .. (result.name or "N/A")) print("Name: " .. (result.name or "N/A"))
if result.url then print("URL: " .. result.url) end if result.url then print("URL: " .. result.url) end
-- Auto-set vault if -e or --env-file provided
local env_content = build_env_content(options.env or {}, options.env_file)
if env_content ~= "" and service_id then
service_env_set(service_id, env_content, keys)
end
return return
end end
@ -693,7 +872,10 @@ local function main()
dump_bootstrap = nil, dump_bootstrap = nil,
dump_file = nil, dump_file = nil,
extend = false, extend = false,
exec_shell = nil exec_shell = nil,
env_file = nil,
env_action = nil,
env_target = nil
} }
local i = 1 local i = 1
@ -762,6 +944,23 @@ local function main()
elseif a == "--bootstrap-file" then elseif a == "--bootstrap-file" then
i = i + 1 i = i + 1
options.bootstrap_file = arg[i] options.bootstrap_file = arg[i]
elseif a == "--env-file" then
i = i + 1
options.env_file = arg[i]
elseif a == "env" then
-- Handle "service env <action> <target>" subcommand
if options.command == "service" then
i = i + 1
if i <= #arg then
options.env_action = arg[i]
end
i = i + 1
if i <= #arg and not arg[i]:match("^%-") then
options.env_target = arg[i]
else
i = i - 1 -- back up if next arg is a flag
end
end
elseif a == "--info" then elseif a == "--info" then
i = i + 1 i = i + 1
options.info = arg[i] options.info = arg[i]
@ -807,7 +1006,13 @@ local function main()
if options.command == "session" then if options.command == "session" then
cmd_session(options) cmd_session(options)
elseif options.command == "service" then elseif options.command == "service" then
cmd_service(options) -- Check for "service env" subcommand
if options.env_action then
local keys = get_api_keys(options.api_key)
cmd_service_env(options.env_action, options.env_target, options.env, options.env_file, keys)
else
cmd_service(options)
end
elseif options.command == "key" then elseif options.command == "key" then
cmd_key(options) cmd_key(options)
elseif options.source_file then elseif options.source_file then

142
un.m
View file

@ -203,6 +203,93 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat
return result; return result;
} }
// API request with text/plain body (for vault)
NSDictionary* apiRequestPutText(NSString* endpoint, NSString* content, NSString* publicKey, NSString* secretKey) {
NSString* urlString = [API_BASE stringByAppendingString:endpoint];
NSURL* url = [NSURL URLWithString:urlString];
NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url];
[request setHTTPMethod:@"PUT"];
[request setTimeoutInterval:300];
[request setHTTPBody:[content dataUsingEncoding:NSUTF8StringEncoding]];
// Generate timestamp and signature
long timestamp = (long)[[NSDate date] timeIntervalSince1970];
NSString* signature = computeSignature(secretKey, timestamp, @"PUT", endpoint, content);
// Set headers
[request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"];
[request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"];
[request setValue:signature forHTTPHeaderField:@"X-Signature"];
[request setValue:@"text/plain" forHTTPHeaderField:@"Content-Type"];
NSHTTPURLResponse* response = nil;
NSError* error = nil;
NSData* responseData = [NSURLConnection sendSynchronousRequest:request
returningResponse:&response
error:&error];
if (error || ([response statusCode] != 200 && [response statusCode] != 201)) {
fprintf(stderr, "%sError: HTTP %ld%s\n",
[RED UTF8String], (long)[response statusCode], [RESET UTF8String]);
if (responseData) {
NSString* errMsg = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding];
fprintf(stderr, "%s\n", [errMsg UTF8String]);
}
exit(1);
}
NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error];
return result;
}
// Build environment content from -e args and --env-file
NSString* buildEnvContent(NSArray* envVars, NSString* envFile) {
NSMutableArray* lines = [NSMutableArray array];
for (NSString* var in envVars) {
[lines addObject:var];
}
if (envFile && [[NSFileManager defaultManager] fileExistsAtPath:envFile]) {
NSString* fileContent = [NSString stringWithContentsOfFile:envFile encoding:NSUTF8StringEncoding error:nil];
for (NSString* line in [fileContent componentsSeparatedByString:@"\n"]) {
NSString* trimmed = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if ([trimmed length] == 0 || [trimmed hasPrefix:@"#"]) continue;
[lines addObject:line];
}
}
return [lines componentsJoinedByString:@"\n"];
}
// Service vault functions
void serviceEnvStatus(NSString* serviceId, NSString* publicKey, NSString* secretKey) {
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId];
NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey);
NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
printf("%s\n", [jsonString UTF8String]);
}
void serviceEnvSet(NSString* serviceId, NSString* content, NSString* publicKey, NSString* secretKey) {
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId];
NSDictionary* result = apiRequestPutText(endpoint, content, publicKey, secretKey);
NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
printf("%s\n", [jsonString UTF8String]);
}
void serviceEnvExport(NSString* serviceId, NSString* publicKey, NSString* secretKey) {
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env/export", serviceId];
NSDictionary* result = apiRequest(endpoint, @"POST", nil, publicKey, secretKey);
if (result[@"content"]) {
printf("%s", [result[@"content"] UTF8String]);
}
}
void serviceEnvDelete(NSString* serviceId, NSString* publicKey, NSString* secretKey) {
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId];
apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey);
printf("%sVault deleted for: %s%s\n", [GREEN UTF8String], [serviceId UTF8String], [RESET UTF8String]);
}
void cmdExecute(NSArray* args) { void cmdExecute(NSArray* args) {
NSString* publicKey, *secretKey; NSString* publicKey, *secretKey;
getApiKeys(&publicKey, &secretKey); getApiKeys(&publicKey, &secretKey);
@ -586,12 +673,54 @@ void cmdService(NSArray* args) {
NSString* network = nil; NSString* network = nil;
int vcpu = 0; int vcpu = 0;
NSMutableArray* inputFiles = [NSMutableArray array]; NSMutableArray* inputFiles = [NSMutableArray array];
NSMutableArray* envVars = [NSMutableArray array];
NSString* envFile = nil;
NSString* snapshotId = nil; NSString* snapshotId = nil;
NSString* restoreId = nil; NSString* restoreId = nil;
NSString* fromSnapshot = nil; NSString* fromSnapshot = nil;
NSString* snapshotName = nil; NSString* snapshotName = nil;
BOOL hotSnapshot = NO; BOOL hotSnapshot = NO;
// Check for 'env' subcommand first
if ([args count] >= 1 && [args[0] isEqualToString:@"env"]) {
if ([args count] < 3) {
fprintf(stderr, "Usage: un.m service env <status|set|export|delete> <service_id> [options]\n");
exit(1);
}
NSString* envAction = args[1];
NSString* envTarget = args[2];
// Parse remaining args for -e and --env-file
for (NSUInteger i = 3; i < [args count]; i++) {
NSString* arg = args[i];
if ([arg isEqualToString:@"-e"] && i + 1 < [args count]) {
[envVars addObject:args[++i]];
} else if ([arg isEqualToString:@"--env-file"] && i + 1 < [args count]) {
envFile = args[++i];
}
}
if ([envAction isEqualToString:@"status"]) {
serviceEnvStatus(envTarget, publicKey, secretKey);
} else if ([envAction isEqualToString:@"set"]) {
NSString* content = buildEnvContent(envVars, envFile);
if ([content length] == 0) {
fprintf(stderr, "%sError: No environment variables to set%s\n", [RED UTF8String], [RESET UTF8String]);
exit(1);
}
serviceEnvSet(envTarget, content, publicKey, secretKey);
} else if ([envAction isEqualToString:@"export"]) {
serviceEnvExport(envTarget, publicKey, secretKey);
} else if ([envAction isEqualToString:@"delete"]) {
serviceEnvDelete(envTarget, publicKey, secretKey);
} else {
fprintf(stderr, "%sError: Unknown env action '%s'. Use status, set, export, or delete%s\n",
[RED UTF8String], [envAction UTF8String], [RESET UTF8String]);
exit(1);
}
return;
}
// Parse arguments // Parse arguments
for (NSUInteger i = 0; i < [args count]; i++) { for (NSUInteger i = 0; i < [args count]; i++) {
NSString* arg = args[i]; NSString* arg = args[i];
@ -633,6 +762,10 @@ void cmdService(NSArray* args) {
bootstrapFile = args[++i]; bootstrapFile = args[++i];
} else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) { } else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) {
[inputFiles addObject:args[++i]]; [inputFiles addObject:args[++i]];
} else if ([arg isEqualToString:@"-e"] && i + 1 < [args count]) {
[envVars addObject:args[++i]];
} else if ([arg isEqualToString:@"--env-file"] && i + 1 < [args count]) {
envFile = args[++i];
} else if ([arg isEqualToString:@"-n"] && i + 1 < [args count]) { } else if ([arg isEqualToString:@"-n"] && i + 1 < [args count]) {
network = args[++i]; network = args[++i];
} else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) { } else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) {
@ -815,10 +948,17 @@ void cmdService(NSArray* args) {
if (result[@"url"]) { if (result[@"url"]) {
printf("URL: %s\n", [result[@"url"] UTF8String]); printf("URL: %s\n", [result[@"url"] UTF8String]);
} }
// Auto-set vault if -e or --env-file were provided
NSString* envContent = buildEnvContent(envVars, envFile);
if ([envContent length] > 0 && result[@"id"]) {
printf("%sSetting vault for service...%s\n", [YELLOW UTF8String], [RESET UTF8String]);
serviceEnvSet(result[@"id"], envContent, publicKey, secretKey);
}
return; return;
} }
fprintf(stderr, "%sError: Specify --name to create a service, or use --list, --info, etc.%s\n", fprintf(stderr, "%sError: Specify --name to create a service, or use --list, --info, env, etc.%s\n",
[RED UTF8String], [RESET UTF8String]); [RED UTF8String], [RESET UTF8String]);
exit(1); exit(1);
} }

226
un.ml
View file

@ -231,6 +231,151 @@ let curl_delete api_key endpoint =
check_clock_drift output; check_clock_drift output;
output output
let curl_put_text endpoint body =
let (public_key, secret_key) = get_api_keys () in
let auth_headers = build_auth_headers public_key secret_key "PUT" endpoint body in
let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.txt" (Random.int 999999) in
let oc = open_out tmp_file in
output_string oc body;
close_out oc;
let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' -X PUT https://api.unsandbox.com%s -H 'Content-Type: text/plain'%s -d @%s"
endpoint auth_headers tmp_file in
let ic = Unix.open_process_in cmd in
let status = try input_line ic with End_of_file -> "0" in
let _ = Unix.close_process_in ic in
Sys.remove tmp_file;
let code = int_of_string (String.trim status) in
code >= 200 && code < 300
let max_env_content_size = 65536
let read_env_file path =
if not (Sys.file_exists path) then begin
Printf.fprintf stderr "%sError: Env file not found: %s%s\n" red path reset;
exit 1
end;
read_file path
let build_env_content envs env_file =
let lines = ref envs in
(match env_file with
| Some path ->
let content = read_env_file path in
let file_lines = String.split_on_char '\n' content in
List.iter (fun line ->
let trimmed = String.trim line in
if String.length trimmed > 0 && trimmed.[0] <> '#' then
lines := trimmed :: !lines
) file_lines
| None -> ());
String.concat "\n" (List.rev !lines)
let service_env_status service_id =
let api_key = get_api_key () in
curl_get api_key (Printf.sprintf "/services/%s/env" service_id)
let service_env_set service_id env_content =
if String.length env_content > max_env_content_size then begin
Printf.fprintf stderr "%sError: Env content exceeds maximum size of 64KB%s\n" red reset;
false
end else
curl_put_text (Printf.sprintf "/services/%s/env" service_id) env_content
let service_env_export service_id =
let api_key = get_api_key () in
let (public_key, secret_key) = get_api_keys () in
let endpoint = Printf.sprintf "/services/%s/env/export" service_id in
let auth_headers = build_auth_headers public_key secret_key "POST" endpoint "{}" in
let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in
let oc = open_out tmp_file in
output_string oc "{}";
close_out oc;
let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s"
endpoint auth_headers tmp_file in
let ic = Unix.open_process_in cmd in
let rec read_all acc =
try let line = input_line ic in read_all (acc ^ line ^ "\n")
with End_of_file -> acc
in
let response = read_all "" in
let _ = Unix.close_process_in ic in
Sys.remove tmp_file;
response
let service_env_delete service_id =
let api_key = get_api_key () in
try
let _ = curl_delete api_key (Printf.sprintf "/services/%s/env" service_id) in
true
with _ -> false
let service_env_command action target envs env_file =
match action with
| "status" ->
(match target with
| Some sid ->
let response = service_env_status sid in
let has_vault = match extract_json_value response "has_vault" with
| Some "true" -> true
| _ -> false
in
if has_vault then begin
Printf.printf "%sVault: configured%s\n" green reset;
(match extract_json_value response "env_count" with
| Some c -> Printf.printf "Variables: %s\n" c
| None -> ());
(match extract_json_value response "updated_at" with
| Some u -> Printf.printf "Updated: %s\n" u
| None -> ())
end else
Printf.printf "%sVault: not configured%s\n" yellow reset
| None ->
Printf.fprintf stderr "%sError: service env status requires service ID%s\n" red reset;
exit 1)
| "set" ->
(match target with
| Some sid ->
if envs = [] && env_file = None then begin
Printf.fprintf stderr "%sError: service env set requires -e or --env-file%s\n" red reset;
exit 1
end;
let env_content = build_env_content envs env_file in
if service_env_set sid env_content then
Printf.printf "%sVault updated for service %s%s\n" green sid reset
else begin
Printf.fprintf stderr "%sError: Failed to update vault%s\n" red reset;
exit 1
end
| None ->
Printf.fprintf stderr "%sError: service env set requires service ID%s\n" red reset;
exit 1)
| "export" ->
(match target with
| Some sid ->
let response = service_env_export sid in
(match extract_json_value response "content" with
| Some content -> Printf.printf "%s" (unescape_json content)
| None -> ())
| None ->
Printf.fprintf stderr "%sError: service env export requires service ID%s\n" red reset;
exit 1)
| "delete" ->
(match target with
| Some sid ->
if service_env_delete sid then
Printf.printf "%sVault deleted for service %s%s\n" green sid reset
else begin
Printf.fprintf stderr "%sError: Failed to delete vault%s\n" red reset;
exit 1
end
| None ->
Printf.fprintf stderr "%sError: service env delete requires service ID%s\n" red reset;
exit 1)
| _ ->
Printf.fprintf stderr "%sError: Unknown env action: %s%s\n" red action reset;
Printf.fprintf stderr "Usage: un.ml service env <status|set|export|delete> <service_id>\n";
exit 1
(* Extract JSON value - simple regex-based parser *) (* Extract JSON value - simple regex-based parser *)
let extract_json_value json_str key = let extract_json_value json_str key =
let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in
@ -465,9 +610,17 @@ let session_command action shell network vcpu input_files =
| _ -> () | _ -> ()
(* Service command *) (* Service command *)
let service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files = let service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files envs env_file =
let api_key = get_api_key () in let api_key = get_api_key () in
match action with match action with
| "env" ->
service_env_command (match name with Some n -> n | None -> "") (match ports with Some p -> Some p | None -> None) envs env_file
| "env_cmd" ->
(match (name, ports) with
| (Some act, target) -> service_env_command act target envs env_file
| _ ->
Printf.fprintf stderr "Error: service env requires action\n";
exit 1)
| "list" -> | "list" ->
let response = curl_get api_key "/services" in let response = curl_get api_key "/services" in
Printf.printf "%s\n" response Printf.printf "%s\n" response
@ -622,7 +775,17 @@ let service_command action name ports bootstrap bootstrap_file service_type netw
let _ = Unix.close_process_in ic in let _ = Unix.close_process_in ic in
Sys.remove tmp_file; Sys.remove tmp_file;
Printf.printf "%sService created%s\n" green reset; Printf.printf "%sService created%s\n" green reset;
Printf.printf "%s\n" response Printf.printf "%s\n" response;
(* Auto-set vault if env vars were provided *)
(match extract_json_value response "id" with
| Some service_id when envs <> [] || env_file <> None ->
let env_content = build_env_content envs env_file in
if String.length env_content > 0 then
if service_env_set service_id env_content then
Printf.printf "%sVault configured with environment variables%s\n" green reset
else
Printf.printf "%sWarning: Failed to set vault%s\n" yellow reset
| _ -> ())
| None -> | None ->
Printf.fprintf stderr "Error: --name required to create service\n"; Printf.fprintf stderr "Error: --name required to create service\n";
exit 1) exit 1)
@ -649,7 +812,10 @@ let () =
Printf.printf "Usage: un.ml [options] <source_file>\n"; Printf.printf "Usage: un.ml [options] <source_file>\n";
Printf.printf " un.ml session [options]\n"; Printf.printf " un.ml session [options]\n";
Printf.printf " un.ml service [options]\n"; Printf.printf " un.ml service [options]\n";
Printf.printf " un.ml key [--extend]\n"; Printf.printf " un.ml service env <action> <service_id>\n";
Printf.printf " un.ml key [--extend]\n\n";
Printf.printf "Service options: --name, --ports, --bootstrap, --bootstrap-file, -e KEY=VALUE, --env-file FILE\n";
Printf.printf "Service env commands: status, set, export, delete\n";
exit 1 exit 1
| "key" :: rest -> | "key" :: rest ->
let extend = List.mem "--extend" rest in let extend = List.mem "--extend" rest in
@ -675,28 +841,40 @@ let () =
parse_session "create" None None None rest parse_session "create" None None None rest
| "service" :: rest -> | "service" :: rest ->
let input_files = parse_input_files [] rest in let input_files = parse_input_files [] rest in
let rec parse_service action name ports bootstrap bootstrap_file service_type network vcpu = function let rec parse_envs acc = function
| [] -> service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files | [] -> List.rev acc
| "--list" :: rest -> parse_service "list" name ports bootstrap bootstrap_file service_type network vcpu rest | "-e" :: kv :: rest -> parse_envs (kv :: acc) rest
| "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest | _ :: rest -> parse_envs acc rest
| "--logs" :: id :: rest -> parse_service "logs" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
| "--freeze" :: id :: rest -> parse_service "sleep" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
| "--unfreeze" :: id :: rest -> parse_service "wake" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
| "--destroy" :: id :: rest -> parse_service "destroy" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
| "--execute" :: id :: "--command" :: cmd :: rest -> parse_service "execute" (Some id) ports (Some cmd) bootstrap_file service_type network vcpu rest
| "--dump-bootstrap" :: id :: file :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap (Some file) service_type network vcpu rest
| "--dump-bootstrap" :: id :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
| "--name" :: n :: rest -> parse_service "create" (Some n) ports bootstrap bootstrap_file service_type network vcpu rest
| "--ports" :: p :: rest -> parse_service action name (Some p) bootstrap bootstrap_file service_type network vcpu rest
| "--bootstrap" :: b :: rest -> parse_service action name ports (Some b) bootstrap_file service_type network vcpu rest
| "--bootstrap-file" :: f :: rest -> parse_service action name ports bootstrap (Some f) service_type network vcpu rest
| "--type" :: t :: rest -> parse_service action name ports bootstrap bootstrap_file (Some t) network vcpu rest
| "-n" :: net :: rest -> parse_service action name ports bootstrap bootstrap_file service_type (Some net) vcpu rest
| "-v" :: v :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network (Some (int_of_string v)) rest
| "-f" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu rest (* skip -f, already parsed *)
| _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu rest
in in
parse_service "create" None None None None None None None rest let envs = parse_envs [] rest in
let rec parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file = function
| [] -> service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files envs env_file
| "env" :: env_action :: target :: rest when not (String.length target > 0 && target.[0] = '-') ->
parse_service "env_cmd" (Some env_action) (Some target) bootstrap bootstrap_file service_type network vcpu env_file rest
| "env" :: env_action :: rest ->
parse_service "env_cmd" (Some env_action) None bootstrap bootstrap_file service_type network vcpu env_file rest
| "--list" :: rest -> parse_service "list" name ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--logs" :: id :: rest -> parse_service "logs" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--freeze" :: id :: rest -> parse_service "sleep" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--unfreeze" :: id :: rest -> parse_service "wake" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--destroy" :: id :: rest -> parse_service "destroy" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--execute" :: id :: "--command" :: cmd :: rest -> parse_service "execute" (Some id) ports (Some cmd) bootstrap_file service_type network vcpu env_file rest
| "--dump-bootstrap" :: id :: file :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap (Some file) service_type network vcpu env_file rest
| "--dump-bootstrap" :: id :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--name" :: n :: rest -> parse_service "create" (Some n) ports bootstrap bootstrap_file service_type network vcpu env_file rest
| "--ports" :: p :: rest -> parse_service action name (Some p) bootstrap bootstrap_file service_type network vcpu env_file rest
| "--bootstrap" :: b :: rest -> parse_service action name ports (Some b) bootstrap_file service_type network vcpu env_file rest
| "--bootstrap-file" :: f :: rest -> parse_service action name ports bootstrap (Some f) service_type network vcpu env_file rest
| "--type" :: t :: rest -> parse_service action name ports bootstrap bootstrap_file (Some t) network vcpu env_file rest
| "-n" :: net :: rest -> parse_service action name ports bootstrap bootstrap_file service_type (Some net) vcpu env_file rest
| "-v" :: v :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network (Some (int_of_string v)) env_file rest
| "--env-file" :: f :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu (Some f) rest
| "-e" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file rest (* skip -e, already parsed *)
| "-f" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file rest (* skip -f, already parsed *)
| _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file rest
in
parse_service "create" None None None None None None None None rest
| args -> | args ->
let rec parse_execute file env_vars artifacts out_dir network vcpu = function let rec parse_execute file env_vars artifacts out_dir network vcpu = function
| [] -> execute_command file env_vars artifacts out_dir network vcpu | [] -> execute_command file env_vars artifacts out_dir network vcpu

180
un.nim
View file

@ -123,6 +123,130 @@ proc execCurl(cmd: string): string =
stderr.writeLine(" Windows: w32tm /resync") stderr.writeLine(" Windows: w32tm /resync")
quit(1) quit(1)
proc execCurlPut(endpoint, body, publicKey, secretKey: string): bool =
let tmpFile = fmt"/tmp/un_nim_{epochTime().int mod 999999}.txt"
writeFile(tmpFile, body)
let authHeaders = buildAuthHeaders("PUT", endpoint, body, publicKey, secretKey)
let cmd = fmt"""curl -s -o /dev/null -w '%{{http_code}}' -X PUT '{API_BASE}{endpoint}' -H 'Content-Type: text/plain' {authHeaders} -d @{tmpFile}"""
let output = execProcess(cmd).strip()
removeFile(tmpFile)
try:
let status = parseInt(output)
return status >= 200 and status < 300
except:
return false
const MAX_ENV_CONTENT_SIZE = 65536
proc readEnvFile(path: string): string =
if not fileExists(path):
stderr.writeLine(RED & "Error: Env file not found: " & path & RESET)
quit(1)
return readFile(path)
proc buildEnvContent(envs: seq[string], envFile: string): string =
var lines: seq[string] = envs
if envFile != "":
let content = readEnvFile(envFile)
for line in content.splitLines():
let trimmed = line.strip()
if trimmed.len > 0 and not trimmed.startsWith("#"):
lines.add(trimmed)
return lines.join("\n")
proc serviceEnvStatus(serviceId, publicKey, secretKey: string): string =
let path = fmt"/services/{serviceId}/env"
let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey)
let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{serviceId}/env' {authHeaders}"""
return execCurl(cmd)
proc serviceEnvSet(serviceId, envContent, publicKey, secretKey: string): bool =
if envContent.len > MAX_ENV_CONTENT_SIZE:
stderr.writeLine(RED & "Error: Env content exceeds maximum size of 64KB" & RESET)
return false
return execCurlPut(fmt"/services/{serviceId}/env", envContent, publicKey, secretKey)
proc serviceEnvExport(serviceId, publicKey, secretKey: string): string =
let path = fmt"/services/{serviceId}/env/export"
let authHeaders = buildAuthHeaders("POST", path, "{}", publicKey, secretKey)
let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{serviceId}/env/export' -H 'Content-Type: application/json' {authHeaders} -d '{{}}'"""
return execCurl(cmd)
proc serviceEnvDelete(serviceId, publicKey, secretKey: string): bool =
let path = fmt"/services/{serviceId}/env"
let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey)
let cmd = fmt"""curl -s -o /dev/null -w '%{{http_code}}' -X DELETE '{API_BASE}/services/{serviceId}/env' {authHeaders}"""
let output = execProcess(cmd).strip()
try:
let status = parseInt(output)
return status >= 200 and status < 300
except:
return false
proc extractJsonField(response, field: string): string =
let fieldStart = response.find("\"" & field & "\":\"")
if fieldStart >= 0:
let start = fieldStart + field.len + 4
var endPos = start
while endPos < response.len:
if response[endPos] == '"' and (endPos == 0 or response[endPos-1] != '\\'):
break
inc endPos
if endPos > start:
return response[start..<endPos]
return ""
proc cmdServiceEnv(action, target: string, envs: seq[string], envFile, publicKey, secretKey: string) =
case action
of "status":
if target == "":
stderr.writeLine(RED & "Error: service env status requires service ID" & RESET)
quit(1)
let response = serviceEnvStatus(target, publicKey, secretKey)
if response.contains("\"has_vault\":true"):
echo GREEN & "Vault: configured" & RESET
let envCount = extractJsonField(response, "env_count")
if envCount != "": echo "Variables: " & envCount
let updatedAt = extractJsonField(response, "updated_at")
if updatedAt != "": echo "Updated: " & updatedAt
else:
echo YELLOW & "Vault: not configured" & RESET
of "set":
if target == "":
stderr.writeLine(RED & "Error: service env set requires service ID" & RESET)
quit(1)
if envs.len == 0 and envFile == "":
stderr.writeLine(RED & "Error: service env set requires -e or --env-file" & RESET)
quit(1)
let envContent = buildEnvContent(envs, envFile)
if serviceEnvSet(target, envContent, publicKey, secretKey):
echo GREEN & "Vault updated for service " & target & RESET
else:
stderr.writeLine(RED & "Error: Failed to update vault" & RESET)
quit(1)
of "export":
if target == "":
stderr.writeLine(RED & "Error: service env export requires service ID" & RESET)
quit(1)
let response = serviceEnvExport(target, publicKey, secretKey)
let content = extractJsonField(response, "content")
if content != "":
var output = content.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\"", "\"").replace("\\\\", "\\")
stdout.write(output)
of "delete":
if target == "":
stderr.writeLine(RED & "Error: service env delete requires service ID" & RESET)
quit(1)
if serviceEnvDelete(target, publicKey, secretKey):
echo GREEN & "Vault deleted for service " & target & RESET
else:
stderr.writeLine(RED & "Error: Failed to delete vault" & RESET)
quit(1)
else:
stderr.writeLine(RED & "Error: Unknown env action: " & action & RESET)
stderr.writeLine("Usage: un.nim service env <status|set|export|delete> <service_id>")
quit(1)
proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, publicKey: string, secretKey: string) = proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, publicKey: string, secretKey: string) =
let lang = detectLanguage(sourceFile) let lang = detectLanguage(sourceFile)
if lang == "": if lang == "":
@ -178,7 +302,12 @@ proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, scree
let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' {authHeaders} -d '{json}'"""
echo execCurl(cmd) echo execCurl(cmd)
proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, inputFiles: seq[string], publicKey: string, secretKey: string) = proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, inputFiles: seq[string], svcEnvs: seq[string], svcEnvFile, envAction, envTarget: string, publicKey: string, secretKey: string) =
# Handle env subcommand
if envAction != "":
cmdServiceEnv(envAction, envTarget, svcEnvs, svcEnvFile, publicKey, secretKey)
return
if list: if list:
let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey) let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey)
let cmd = fmt"""curl -s -X GET '{API_BASE}/services' {authHeaders}""" let cmd = fmt"""curl -s -X GET '{API_BASE}/services' {authHeaders}"""
@ -326,7 +455,18 @@ proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list
echo YELLOW & "Creating service..." & RESET echo YELLOW & "Creating service..." & RESET
let authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey) let authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey)
let cmd = fmt"""curl -s -X POST '{API_BASE}/services' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" let cmd = fmt"""curl -s -X POST '{API_BASE}/services' -H 'Content-Type: application/json' {authHeaders} -d '{json}'"""
echo execCurl(cmd) let response = execCurl(cmd)
echo response
# Auto-set vault if -e or --env-file provided
if svcEnvs.len > 0 or svcEnvFile != "":
let serviceId = extractJsonField(response, "service_id")
if serviceId != "":
let envContent = buildEnvContent(svcEnvs, svcEnvFile)
if serviceEnvSet(serviceId, envContent, publicKey, secretKey):
echo GREEN & "Vault configured for service " & serviceId & RESET
else:
stderr.writeLine(YELLOW & "Warning: Failed to set vault" & RESET)
return return
stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET) stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET)
@ -422,7 +562,18 @@ proc main() =
stderr.writeLine("Usage: un.nim [options] <source_file>") stderr.writeLine("Usage: un.nim [options] <source_file>")
stderr.writeLine(" un.nim session [options]") stderr.writeLine(" un.nim session [options]")
stderr.writeLine(" un.nim service [options]") stderr.writeLine(" un.nim service [options]")
stderr.writeLine(" un.nim service env <action> <service_id> [options]")
stderr.writeLine(" un.nim key [options]") stderr.writeLine(" un.nim key [options]")
stderr.writeLine("")
stderr.writeLine("Service env commands:")
stderr.writeLine(" env status <id> Show vault status")
stderr.writeLine(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)")
stderr.writeLine(" env export <id> Export vault contents")
stderr.writeLine(" env delete <id> Delete vault")
stderr.writeLine("")
stderr.writeLine("Service options:")
stderr.writeLine(" -e KEY=VALUE Set environment variable (for vault)")
stderr.writeLine(" --env-file FILE Load env vars from file (for vault)")
quit(1) quit(1)
if args[0] == "key": if args[0] == "key":
@ -472,7 +623,28 @@ proc main() =
var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = "" var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = ""
var vcpu = 0 var vcpu = 0
var inputFiles: seq[string] = @[] var inputFiles: seq[string] = @[]
var svcEnvs: seq[string] = @[]
var svcEnvFile = ""
var envAction, envTarget = ""
var i = 1 var i = 1
# Check for env subcommand
if args.len > 1 and args[1] == "env":
if args.len > 2:
envAction = args[2]
if args.len > 3:
envTarget = args[3]
i = 4
while i < args.len:
case args[i]
of "-e": svcEnvs.add(args[i+1]); inc i
of "--env-file": svcEnvFile = args[i+1]; inc i
of "-k": publicKey = args[i+1]; inc i
else: discard
inc i
cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey)
return
while i < args.len: while i < args.len:
case args[i] case args[i]
of "--name": name = args[i+1]; inc i of "--name": name = args[i+1]; inc i
@ -494,6 +666,8 @@ proc main() =
of "-n": network = args[i+1]; inc i of "-n": network = args[i+1]; inc i
of "-v": vcpu = parseInt(args[i+1]); inc i of "-v": vcpu = parseInt(args[i+1]); inc i
of "-k": publicKey = args[i+1]; inc i of "-k": publicKey = args[i+1]; inc i
of "-e": svcEnvs.add(args[i+1]); inc i
of "--env-file": svcEnvFile = args[i+1]; inc i
of "-f": of "-f":
let file = args[i+1] let file = args[i+1]
if fileExists(file): if fileExists(file):
@ -504,7 +678,7 @@ proc main() =
inc i inc i
else: discard else: discard
inc i inc i
cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, publicKey, secretKey) cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey)
return return
# Execute mode # Execute mode

202
un.php
View file

@ -178,6 +178,172 @@ function api_request($endpoint, $method = 'GET', $data = null, $keys = null) {
return json_decode($response, true); return json_decode($response, true);
} }
function api_request_text($endpoint, $method, $body, $keys) {
$url = API_BASE . $endpoint;
$ch = curl_init($url);
$timestamp = (string)time();
// Parse URL to get path
$parsed_url = parse_url($url);
$path = $parsed_url['path'];
$message = "$timestamp:$method:$path:$body";
$signature = hash_hmac('sha256', $message, $keys['secret_key']);
$headers = [
'Authorization: Bearer ' . $keys['public_key'],
'X-Timestamp: ' . $timestamp,
'X-Signature: ' . $signature,
'Content-Type: text/plain'
];
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 300,
CURLOPT_POSTFIELDS => $body
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) {
curl_close($ch);
return ['error' => curl_error($ch)];
}
curl_close($ch);
if ($http_code < 200 || $http_code >= 300) {
return ['error' => "HTTP $http_code - $response"];
}
return json_decode($response, true);
}
// ============================================================================
// Environment Secrets Vault Functions
// ============================================================================
const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max
function service_env_status($service_id, $keys) {
$result = api_request("/services/$service_id/env", 'GET', null, $keys);
$has_vault = $result['has_vault'] ?? false;
if (!$has_vault) {
echo "Vault exists: no\n";
echo "Variable count: 0\n";
} else {
echo "Vault exists: yes\n";
echo "Variable count: " . ($result['count'] ?? 0) . "\n";
if (isset($result['updated_at'])) {
echo "Last updated: " . date('Y-m-d H:i:s', $result['updated_at']) . "\n";
}
}
}
function service_env_set($service_id, $env_content, $keys) {
if (empty($env_content)) {
fwrite(STDERR, RED . "Error: No environment content provided" . RESET . "\n");
return false;
}
if (strlen($env_content) > MAX_ENV_CONTENT_SIZE) {
fwrite(STDERR, RED . "Error: Environment content too large (max " . MAX_ENV_CONTENT_SIZE . " bytes)" . RESET . "\n");
return false;
}
$result = api_request_text("/services/$service_id/env", 'PUT', $env_content, $keys);
if (isset($result['error'])) {
fwrite(STDERR, RED . "Error: " . $result['error'] . RESET . "\n");
return false;
}
$count = $result['count'] ?? 0;
$plural = $count === 1 ? '' : 's';
echo GREEN . "Environment vault updated: $count variable$plural" . RESET . "\n";
if (!empty($result['message'])) echo $result['message'] . "\n";
return true;
}
function service_env_export($service_id, $keys) {
$result = api_request("/services/$service_id/env/export", 'POST', [], $keys);
$env_content = $result['env'] ?? '';
if (!empty($env_content)) {
echo $env_content;
if (!str_ends_with($env_content, "\n")) echo "\n";
}
}
function service_env_delete($service_id, $keys) {
api_request("/services/$service_id/env", 'DELETE', null, $keys);
echo GREEN . "Environment vault deleted" . RESET . "\n";
}
function read_env_file($filepath) {
if (!file_exists($filepath)) {
fwrite(STDERR, RED . "Error: Env file not found: $filepath" . RESET . "\n");
exit(1);
}
return file_get_contents($filepath);
}
function build_env_content($envs, $env_file) {
$parts = [];
// Read from env file first
if (!empty($env_file)) {
$parts[] = read_env_file($env_file);
}
// Add -e flags
foreach ($envs as $e) {
if (str_contains($e, '=')) {
$parts[] = $e;
}
}
return implode("\n", $parts);
}
function cmd_service_env($action, $target, $envs, $env_file, $keys) {
if (empty($action)) {
fwrite(STDERR, RED . "Error: env action required (status, set, export, delete)" . RESET . "\n");
exit(1);
}
if (empty($target)) {
fwrite(STDERR, RED . "Error: Service ID required for env command" . RESET . "\n");
exit(1);
}
switch ($action) {
case 'status':
service_env_status($target, $keys);
break;
case 'set':
$env_content = build_env_content($envs, $env_file);
if (empty($env_content)) {
fwrite(STDERR, RED . "Error: No env content provided. Use -e KEY=VAL or --env-file" . RESET . "\n");
exit(1);
}
service_env_set($target, $env_content, $keys);
break;
case 'export':
service_env_export($target, $keys);
break;
case 'delete':
service_env_delete($target, $keys);
break;
default:
fwrite(STDERR, RED . "Error: Unknown env action '$action'. Use: status, set, export, delete" . RESET . "\n");
exit(1);
}
}
function cmd_execute($options) { function cmd_execute($options) {
$keys = get_api_keys($options['api_key']); $keys = get_api_keys($options['api_key']);
@ -568,9 +734,16 @@ function cmd_service($options) {
if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; if ($options['vcpu']) $payload['vcpu'] = $options['vcpu'];
$result = api_request('/services', 'POST', $payload, $keys); $result = api_request('/services', 'POST', $payload, $keys);
echo GREEN . "Service created: " . ($result['id'] ?? 'N/A') . RESET . "\n"; $service_id = $result['id'] ?? null;
echo GREEN . "Service created: " . ($service_id ?? 'N/A') . RESET . "\n";
echo "Name: " . ($result['name'] ?? 'N/A') . "\n"; echo "Name: " . ($result['name'] ?? 'N/A') . "\n";
if (!empty($result['url'])) echo "URL: {$result['url']}\n"; if (!empty($result['url'])) echo "URL: {$result['url']}\n";
// Auto-set vault if -e or --env-file provided
$env_content = build_env_content($options['env'] ?? [], $options['env_file']);
if (!empty($env_content) && $service_id) {
service_env_set($service_id, $env_content, $keys);
}
return; return;
} }
@ -614,7 +787,10 @@ function main() {
'command' => null, 'command' => null,
'dump_bootstrap' => null, 'dump_bootstrap' => null,
'dump_file' => null, 'dump_file' => null,
'extend' => false 'extend' => false,
'env_file' => null,
'env_action' => null,
'env_target' => null
]; ];
for ($i = 1; $i < count($argv); $i++) { for ($i = 1; $i < count($argv); $i++) {
@ -688,6 +864,20 @@ function main() {
case '--bootstrap-file': case '--bootstrap-file':
$options['bootstrap_file'] = $argv[++$i]; $options['bootstrap_file'] = $argv[++$i];
break; break;
case '--env-file':
$options['env_file'] = $argv[++$i];
break;
case 'env':
// Handle "service env <action> <target>" subcommand
if ($options['command'] === 'service') {
if (isset($argv[$i + 1])) {
$options['env_action'] = $argv[++$i];
}
if (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) {
$options['env_target'] = $argv[++$i];
}
}
break;
case '--info': case '--info':
$options['info'] = $argv[++$i]; $options['info'] = $argv[++$i];
break; break;
@ -735,7 +925,13 @@ function main() {
if ($options['command'] === 'session') { if ($options['command'] === 'session') {
cmd_session($options); cmd_session($options);
} elseif ($options['command'] === 'service') { } elseif ($options['command'] === 'service') {
cmd_service($options); // Check for "service env" subcommand
if ($options['env_action']) {
$keys = get_api_keys($options['api_key']);
cmd_service_env($options['env_action'], $options['env_target'], $options['env'], $options['env_file'], $keys);
} else {
cmd_service($options);
}
} elseif ($options['command'] === 'key') { } elseif ($options['command'] === 'key') {
cmd_key($options); cmd_key($options);
} elseif ($options['source_file']) { } elseif ($options['source_file']) {

190
un.pl
View file

@ -169,6 +169,164 @@ sub api_request {
return decode_json($response->content); return decode_json($response->content);
} }
sub api_request_text {
my ($endpoint, $method, $body, $public_key, $secret_key) = @_;
my $url = "$API_BASE$endpoint";
my $ua = LWP::UserAgent->new(timeout => 300);
my $request = HTTP::Request->new($method => $url);
$request->header('Authorization' => "Bearer $public_key");
$request->header('Content-Type' => 'text/plain');
$request->content($body);
# Add HMAC signature if secret_key is present
if ($secret_key) {
my $timestamp = time();
my $sig_input = "${timestamp}:${method}:${endpoint}:${body}";
my $signature = hmac_sha256_hex($sig_input, $secret_key);
$request->header('X-Timestamp' => $timestamp);
$request->header('X-Signature' => $signature);
}
my $response = $ua->request($request);
unless ($response->is_success) {
return { error => "HTTP " . $response->code . " - " . $response->content };
}
return decode_json($response->content);
}
# ============================================================================
# Environment Secrets Vault Functions
# ============================================================================
my $MAX_ENV_CONTENT_SIZE = 64 * 1024; # 64KB max
sub service_env_status {
my ($service_id, $public_key, $secret_key) = @_;
my $result = api_request("/services/$service_id/env", 'GET', undef, $public_key, $secret_key);
my $has_vault = $result->{has_vault};
if (!$has_vault) {
print "Vault exists: no\n";
print "Variable count: 0\n";
} else {
print "Vault exists: yes\n";
print "Variable count: ", ($result->{count} // 0), "\n";
if ($result->{updated_at}) {
my @t = localtime($result->{updated_at});
printf "Last updated: %04d-%02d-%02d %02d:%02d:%02d\n",
$t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0];
}
}
}
sub service_env_set {
my ($service_id, $env_content, $public_key, $secret_key) = @_;
unless ($env_content) {
print STDERR "${RED}Error: No environment content provided${RESET}\n";
return 0;
}
if (length($env_content) > $MAX_ENV_CONTENT_SIZE) {
print STDERR "${RED}Error: Environment content too large (max $MAX_ENV_CONTENT_SIZE bytes)${RESET}\n";
return 0;
}
my $result = api_request_text("/services/$service_id/env", 'PUT', $env_content, $public_key, $secret_key);
if ($result->{error}) {
print STDERR "${RED}Error: $result->{error}${RESET}\n";
return 0;
}
my $count = $result->{count} // 0;
my $plural = $count == 1 ? '' : 's';
print "${GREEN}Environment vault updated: $count variable$plural${RESET}\n";
print "$result->{message}\n" if $result->{message};
return 1;
}
sub service_env_export {
my ($service_id, $public_key, $secret_key) = @_;
my $result = api_request("/services/$service_id/env/export", 'POST', {}, $public_key, $secret_key);
my $env_content = $result->{env} // '';
if ($env_content) {
print $env_content;
print "\n" unless $env_content =~ /\n$/;
}
}
sub service_env_delete {
my ($service_id, $public_key, $secret_key) = @_;
api_request("/services/$service_id/env", 'DELETE', undef, $public_key, $secret_key);
print "${GREEN}Environment vault deleted${RESET}\n";
}
sub read_env_file {
my ($filepath) = @_;
unless (-e $filepath) {
print STDERR "${RED}Error: Env file not found: $filepath${RESET}\n";
exit 1;
}
open my $fh, '<', $filepath or die "Cannot read file: $!";
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
sub build_env_content {
my ($envs, $env_file) = @_;
my @parts;
# Read from env file first
if ($env_file) {
push @parts, read_env_file($env_file);
}
# Add -e flags
foreach my $e (@$envs) {
push @parts, $e if $e =~ /=/;
}
return join("\n", @parts);
}
sub cmd_service_env {
my ($action, $target, $envs, $env_file, $public_key, $secret_key) = @_;
unless ($action) {
print STDERR "${RED}Error: env action required (status, set, export, delete)${RESET}\n";
exit 1;
}
unless ($target) {
print STDERR "${RED}Error: Service ID required for env command${RESET}\n";
exit 1;
}
if ($action eq 'status') {
service_env_status($target, $public_key, $secret_key);
} elsif ($action eq 'set') {
my $env_content = build_env_content($envs, $env_file);
unless ($env_content) {
print STDERR "${RED}Error: No env content provided. Use -e KEY=VAL or --env-file${RESET}\n";
exit 1;
}
service_env_set($target, $env_content, $public_key, $secret_key);
} elsif ($action eq 'export') {
service_env_export($target, $public_key, $secret_key);
} elsif ($action eq 'delete') {
service_env_delete($target, $public_key, $secret_key);
} else {
print STDERR "${RED}Error: Unknown env action '$action'. Use: status, set, export, delete${RESET}\n";
exit 1;
}
}
sub cmd_execute { sub cmd_execute {
my ($options) = @_; my ($options) = @_;
my ($public_key, $secret_key) = get_api_key($options->{api_key}); my ($public_key, $secret_key) = get_api_key($options->{api_key});
@ -451,9 +609,16 @@ sub cmd_service {
$payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; $payload->{vcpu} = $options->{vcpu} if $options->{vcpu};
my $result = api_request('/services', 'POST', $payload, $public_key, $secret_key); my $result = api_request('/services', 'POST', $payload, $public_key, $secret_key);
print "${GREEN}Service created: ", ($result->{id} // 'N/A'), "${RESET}\n"; my $service_id = $result->{id};
print "${GREEN}Service created: ", ($service_id // 'N/A'), "${RESET}\n";
print "Name: ", ($result->{name} // 'N/A'), "\n"; print "Name: ", ($result->{name} // 'N/A'), "\n";
print "URL: $result->{url}\n" if $result->{url}; print "URL: $result->{url}\n" if $result->{url};
# Auto-set vault if -e or --env-file provided
my $env_content = build_env_content($options->{env} || [], $options->{env_file});
if ($env_content && $service_id) {
service_env_set($service_id, $env_content, $public_key, $secret_key);
}
return; return;
} }
@ -573,7 +738,10 @@ sub main {
command => undef, command => undef,
dump_bootstrap => undef, dump_bootstrap => undef,
dump_file => undef, dump_file => undef,
extend => 0 extend => 0,
env_file => undef,
env_action => undef,
env_target => undef
); );
for (my $i = 0; $i < @ARGV; $i++) { for (my $i = 0; $i < @ARGV; $i++) {
@ -621,6 +789,16 @@ sub main {
$options{bootstrap} = $ARGV[++$i]; $options{bootstrap} = $ARGV[++$i];
} elsif ($arg eq '--bootstrap-file') { } elsif ($arg eq '--bootstrap-file') {
$options{bootstrap_file} = $ARGV[++$i]; $options{bootstrap_file} = $ARGV[++$i];
} elsif ($arg eq '--env-file') {
$options{env_file} = $ARGV[++$i];
} elsif ($arg eq 'env') {
# Handle "service env <action> <target>" subcommand
if ($options{command} && $options{command} eq 'service') {
$options{env_action} = $ARGV[++$i] if defined $ARGV[$i + 1];
if (defined $ARGV[$i + 1] && $ARGV[$i + 1] !~ /^-/) {
$options{env_target} = $ARGV[++$i];
}
}
} elsif ($arg eq '--info') { } elsif ($arg eq '--info') {
$options{info} = $ARGV[++$i]; $options{info} = $ARGV[++$i];
} elsif ($arg eq '--logs') { } elsif ($arg eq '--logs') {
@ -654,7 +832,13 @@ sub main {
if ($options{command} && $options{command} eq 'session') { if ($options{command} && $options{command} eq 'session') {
cmd_session(\%options); cmd_session(\%options);
} elsif ($options{command} && $options{command} eq 'service') { } elsif ($options{command} && $options{command} eq 'service') {
cmd_service(\%options); # Check for "service env" subcommand
if ($options{env_action}) {
my ($public_key, $secret_key) = get_api_key($options{api_key});
cmd_service_env($options{env_action}, $options{env_target}, $options{env}, $options{env_file}, $public_key, $secret_key);
} else {
cmd_service(\%options);
}
} elsif ($options{command} && $options{command} eq 'key') { } elsif ($options{command} && $options{command} eq 'key') {
cmd_key(\%options); cmd_key(\%options);
} elsif ($options{source_file}) { } elsif ($options{source_file}) {

158
un.pro
View file

@ -230,6 +230,42 @@ service_destroy(ServiceId) :-
[ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]),
shell(Cmd, 0). shell(Cmd, 0).
% Service env status
service_env_status(ServiceId) :-
get_public_key(PublicKey),
get_secret_key(SecretKey),
format(atom(Cmd),
'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/services/~w/env:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET "https://api.unsandbox.com/services/~w/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq .',
[ServiceId, SecretKey, ServiceId, PublicKey]),
shell(Cmd, 0).
% Service env set
service_env_set(ServiceId, Envs, EnvFile) :-
get_public_key(PublicKey),
get_secret_key(SecretKey),
format(atom(Cmd),
'ENV_CONTENT=""; ENV_LINES="~w"; if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ENV_FILE="~w"; if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then while IFS= read -r line || [ -n "$line" ]; do case "$line" in "#"*|"") continue ;; esac; if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT\\n"; fi; ENV_CONTENT="$ENV_CONTENT$line"; done < "$ENV_FILE"; fi; if [ -z "$ENV_CONTENT" ]; then echo -e "\\x1b[31mError: No environment variables to set\\x1b[0m" >&2; exit 1; fi; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:PUT:/services/~w/env:$ENV_CONTENT"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X PUT "https://api.unsandbox.com/services/~w/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -H "Content-Type: text/plain" --data-binary "$ENV_CONTENT" | jq .',
[Envs, EnvFile, ServiceId, SecretKey, ServiceId, PublicKey]),
shell(Cmd, 0).
% Service env export
service_env_export(ServiceId) :-
get_public_key(PublicKey),
get_secret_key(SecretKey),
format(atom(Cmd),
'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services/~w/env/export:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST "https://api.unsandbox.com/services/~w/env/export" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -r ".content // empty"',
[ServiceId, SecretKey, ServiceId, PublicKey]),
shell(Cmd, 0).
% Service env delete
service_env_delete(ServiceId) :-
get_public_key(PublicKey),
get_secret_key(SecretKey),
format(atom(Cmd),
'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/services/~w/env:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X DELETE "https://api.unsandbox.com/services/~w/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mVault deleted for: ~w\\x1b[0m"',
[ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]),
shell(Cmd, 0).
% Service dump bootstrap % Service dump bootstrap
service_dump_bootstrap(ServiceId, DumpFile) :- service_dump_bootstrap(ServiceId, DumpFile) :-
get_public_key(PublicKey), get_public_key(PublicKey),
@ -330,58 +366,126 @@ parse_session_args([Arg|Rest], Shell, Files, ShellOut, InputFiles) :-
). ).
% Handle service subcommand % Handle service subcommand
handle_service(['env', Action, ServiceId|Rest]) :-
!,
parse_env_args(Rest, '', '', Envs, EnvFile),
handle_env_action(Action, ServiceId, Envs, EnvFile).
handle_service(Args) :- handle_service(Args) :-
parse_service_args(Args, '', '', '', '', '', [], Action, InputFiles), parse_service_args(Args, '', '', '', '', '', [], '', '', Action, InputFiles),
execute_service_action(Action, InputFiles). execute_service_action(Action, InputFiles).
% Handle env action
handle_env_action('status', ServiceId, _, _) :- service_env_status(ServiceId).
handle_env_action('set', ServiceId, Envs, EnvFile) :- service_env_set(ServiceId, Envs, EnvFile).
handle_env_action('export', ServiceId, _, _) :- service_env_export(ServiceId).
handle_env_action('delete', ServiceId, _, _) :- service_env_delete(ServiceId).
handle_env_action(Action, _, _, _) :-
format(user_error, 'Error: Unknown env action: ~w~n', [Action]),
write(user_error, 'Usage: un.pro service env <status|set|export|delete> <service_id>\n'),
halt(1).
% Parse env arguments for -e and --env-file
parse_env_args([], Envs, EnvFile, Envs, EnvFile).
parse_env_args(['-e', EnvVal|Rest], Envs, EnvFile, EnvsOut, EnvFileOut) :-
( Envs \= ''
-> format(atom(NewEnvs), '~w\\n~w', [Envs, EnvVal])
; NewEnvs = EnvVal
),
parse_env_args(Rest, NewEnvs, EnvFile, EnvsOut, EnvFileOut).
parse_env_args(['--env-file', EnvFileVal|Rest], Envs, _, EnvsOut, EnvFileOut) :-
parse_env_args(Rest, Envs, EnvFileVal, EnvsOut, EnvFileOut).
parse_env_args([_|Rest], Envs, EnvFile, EnvsOut, EnvFileOut) :-
parse_env_args(Rest, Envs, EnvFile, EnvsOut, EnvFileOut).
% Parse service arguments % Parse service arguments
parse_service_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFiles) :- parse_service_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, create, InputFiles) :-
( Name \= '' ( Name \= ''
-> service_create(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles) -> service_create_with_vault(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile)
; write(user_error, 'Error: --name required for service creation\n'), ; write(user_error, 'Error: --name required for service creation\n'),
halt(1) halt(1)
). ).
parse_service_args([], _, _, _, _, _, InputFiles, Action, InputFiles) :- parse_service_args([], _, _, _, _, _, InputFiles, _, _, Action, InputFiles) :-
( Action = list ( Action = list
-> service_list -> service_list
; write(user_error, 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, or --name\n'), ; write(user_error, 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --name, or env\n'),
halt(1) halt(1)
). ).
parse_service_args(['--list'|_], _, _, _, _, _, _, _, _) :- service_list. parse_service_args(['--list'|_], _, _, _, _, _, _, _, _, _, _) :- service_list.
parse_service_args(['-l'|_], _, _, _, _, _, _, _, _) :- service_list. parse_service_args(['-l'|_], _, _, _, _, _, _, _, _, _, _) :- service_list.
parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _) :- service_info(ServiceId). parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_info(ServiceId).
parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _) :- service_logs(ServiceId). parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_logs(ServiceId).
parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_sleep(ServiceId). parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_sleep(ServiceId).
parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_wake(ServiceId). parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_wake(ServiceId).
parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _) :- service_destroy(ServiceId). parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_destroy(ServiceId).
parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _) :- parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _, _, _) :-
( Rest = ['--dump-file', DumpFile|_] ( Rest = ['--dump-file', DumpFile|_]
-> service_dump_bootstrap(ServiceId, DumpFile) -> service_dump_bootstrap(ServiceId, DumpFile)
; service_dump_bootstrap(ServiceId, '') ; service_dump_bootstrap(ServiceId, '')
). ).
parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, _, InputFilesOut) :- parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, _, InputFilesOut) :-
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFilesOut). parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, create, InputFilesOut).
parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
parse_service_args(Rest, Name, PortsList, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). parse_service_args(Rest, Name, PortsList, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
parse_service_args(['--bootstrap', BootstrapVal|Rest], Name, Ports, _, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- parse_service_args(['--bootstrap', BootstrapVal|Rest], Name, Ports, _, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
parse_service_args(Rest, Name, Ports, BootstrapVal, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). parse_service_args(Rest, Name, Ports, BootstrapVal, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
parse_service_args(['--bootstrap-file', BootstrapFileVal|Rest], Name, Ports, Bootstrap, _, ServiceType, InputFiles, Action, InputFilesOut) :- parse_service_args(['--bootstrap-file', BootstrapFileVal|Rest], Name, Ports, Bootstrap, _, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFileVal, ServiceType, InputFiles, Action, InputFilesOut). parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFileVal, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, BootstrapFile, _, InputFiles, Action, InputFilesOut) :- parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, BootstrapFile, _, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, Type, InputFiles, Action, InputFilesOut). parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, Type, InputFiles, Envs, EnvFile, Action, InputFilesOut).
parse_service_args(['-f', FilePath|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- parse_service_args(['-e', EnvVal|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
( Envs \= ''
-> format(atom(NewEnvs), '~w\\n~w', [Envs, EnvVal])
; NewEnvs = EnvVal
),
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, NewEnvs, EnvFile, Action, InputFilesOut).
parse_service_args(['--env-file', EnvFileVal|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, _, Action, InputFilesOut) :-
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFileVal, Action, InputFilesOut).
parse_service_args(['-f', FilePath|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
( exists_file(FilePath) ( exists_file(FilePath)
-> append(InputFiles, [FilePath], NewInputFiles), -> append(InputFiles, [FilePath], NewInputFiles),
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, NewInputFiles, Action, InputFilesOut) parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, NewInputFiles, Envs, EnvFile, Action, InputFilesOut)
; format(user_error, 'Error: File not found: ~w~n', [FilePath]), ; format(user_error, 'Error: File not found: ~w~n', [FilePath]),
halt(1) halt(1)
). ).
parse_service_args([_|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- parse_service_args([_|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
% Execute service action (not used, but kept for structure) % Execute service action (not used, but kept for structure)
execute_service_action(_, _). execute_service_action(_, _).
% Service create with auto-vault
service_create_with_vault(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile) :-
get_public_key(PublicKey),
get_secret_key(SecretKey),
% Build JSON payload
( Ports \= ''
-> format(atom(PortsJson), ',"ports":[~w]', [Ports])
; PortsJson = ''
),
( Bootstrap \= ''
-> format(atom(BootstrapJson), ',"bootstrap":"~w"', [Bootstrap])
; BootstrapJson = ''
),
( BootstrapFile \= ''
-> ( exists_file(BootstrapFile)
-> read_file_content(BootstrapFile, BootstrapContent),
format(atom(BootstrapContentJson), ',"bootstrap_content":"~w"', [BootstrapContent])
; format(user_error, 'Error: Bootstrap file not found: ~w~n', [BootstrapFile]),
halt(1)
)
; BootstrapContentJson = ''
),
( ServiceType \= ''
-> format(atom(ServiceTypeJson), ',"service_type":"~w"', [ServiceType])
; ServiceTypeJson = ''
),
% Build file arguments for bash script
build_file_args(InputFiles, FileArgs),
format(atom(Cmd),
'echo -e "\\x1b[33mCreating service...\\x1b[0m"; INPUT_FILES=""; ~w if [ -n "$INPUT_FILES" ]; then INPUT_FILES_JSON=",\\\"input_files\\\":[$INPUT_FILES]"; else INPUT_FILES_JSON=""; fi; BODY="{\\\"name\\\":\\\"~w\\\"~w~w~w~w$INPUT_FILES_JSON}"; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -X POST https://api.unsandbox.com/services -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY"); SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); if [ -n "$SVC_ID" ]; then echo -e "\\x1b[32m$SVC_ID created\\x1b[0m"; ENV_CONTENT=""; ENV_LINES="~w"; if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ENV_FILE="~w"; if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then while IFS= read -r line || [ -n "$line" ]; do case "$line" in "#"*|"") continue ;; esac; if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT\\n"; fi; ENV_CONTENT="$ENV_CONTENT$line"; done < "$ENV_FILE"; fi; if [ -n "$ENV_CONTENT" ]; then TS2=$(date +%s); SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" -H "Content-Type: text/plain" --data-binary "$ENV_CONTENT" >/dev/null && echo -e "\\x1b[32mVault configured\\x1b[0m"; fi; else echo "$RESP" | jq .; fi',
[FileArgs, Name, PortsJson, BootstrapJson, BootstrapContentJson, ServiceTypeJson, SecretKey, PublicKey, Envs, EnvFile, SecretKey, PublicKey]),
shell(Cmd, 0).
% Main program % Main program
main(Argv) :- main(Argv) :-
% Check arguments % Check arguments

233
un.ps1
View file

@ -126,6 +126,181 @@ function Invoke-Api {
} }
} }
function Invoke-ApiText {
param($Endpoint, $Method, $Body, $BaseUrl = $null)
$publicKey, $secretKey = Get-ApiKeys
$headers = @{
"Authorization" = "Bearer $publicKey"
"Content-Type" = "text/plain"
}
# Add HMAC signature if secret key exists
if ($secretKey) {
$timestamp = [int][double]::Parse((Get-Date -UFormat %s))
$bodyContent = if ($Body) { $Body } else { "" }
$sigInput = "${timestamp}:${Method}:${Endpoint}:${bodyContent}"
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($secretKey)
$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sigInput))
$signature = [System.BitConverter]::ToString($hash).Replace("-", "").ToLower()
$headers["X-Timestamp"] = $timestamp.ToString()
$headers["X-Signature"] = $signature
}
$base = if ($BaseUrl) { $BaseUrl } else { $API_BASE }
$uri = "$base$Endpoint"
try {
$response = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -Body $Body
return @{ Success = $true; Data = $response }
} catch {
return @{ Success = $false; Error = $_.Exception.Message }
}
}
function Read-EnvFile {
param($Path)
if (-not (Test-Path $Path)) {
Write-Error "Error: Env file not found: $Path"
exit 1
}
return Get-Content -Raw $Path
}
function Build-EnvContent {
param($Envs, $EnvFile)
$lines = @()
# Add from -e flags
foreach ($env in $Envs) {
$lines += $env
}
# Add from --env-file
if ($EnvFile) {
$content = Read-EnvFile -Path $EnvFile
foreach ($line in ($content -split "`n")) {
$trimmed = $line.Trim()
if ($trimmed -and -not $trimmed.StartsWith("#")) {
$lines += $trimmed
}
}
}
return $lines -join "`n"
}
$MAX_ENV_CONTENT_SIZE = 65536
function Invoke-ServiceEnvStatus {
param($ServiceId)
return Invoke-Api -Endpoint "/services/$ServiceId/env"
}
function Invoke-ServiceEnvSet {
param($ServiceId, $EnvContent)
if ($EnvContent.Length -gt $MAX_ENV_CONTENT_SIZE) {
Write-Host "`e[31mError: Env content exceeds maximum size of 64KB`e[0m"
return $false
}
$result = Invoke-ApiText -Endpoint "/services/$ServiceId/env" -Method "PUT" -Body $EnvContent
return $result.Success
}
function Invoke-ServiceEnvExport {
param($ServiceId)
return Invoke-Api -Endpoint "/services/$ServiceId/env/export" -Method "POST" -Body "{}"
}
function Invoke-ServiceEnvDelete {
param($ServiceId)
try {
Invoke-Api -Endpoint "/services/$ServiceId/env" -Method "DELETE"
return $true
} catch {
return $false
}
}
function Invoke-ServiceEnv {
param($Action, $Target, $Envs, $EnvFile)
switch ($Action) {
"status" {
if (-not $Target) {
Write-Error "Error: service env status requires service ID"
exit 1
}
$result = Invoke-ServiceEnvStatus -ServiceId $Target
if ($result.has_vault) {
Write-Host "`e[32mVault: configured`e[0m"
if ($result.env_count) {
Write-Host "Variables: $($result.env_count)"
}
if ($result.updated_at) {
Write-Host "Updated: $($result.updated_at)"
}
} else {
Write-Host "`e[33mVault: not configured`e[0m"
}
}
"set" {
if (-not $Target) {
Write-Error "Error: service env set requires service ID"
exit 1
}
if ($Envs.Count -eq 0 -and -not $EnvFile) {
Write-Error "Error: service env set requires -e or --env-file"
exit 1
}
$envContent = Build-EnvContent -Envs $Envs -EnvFile $EnvFile
if (Invoke-ServiceEnvSet -ServiceId $Target -EnvContent $envContent) {
Write-Host "`e[32mVault updated for service $Target`e[0m"
} else {
Write-Error "Error: Failed to update vault"
exit 1
}
}
"export" {
if (-not $Target) {
Write-Error "Error: service env export requires service ID"
exit 1
}
$result = Invoke-ServiceEnvExport -ServiceId $Target
if ($result.content) {
Write-Host $result.content -NoNewline
}
}
"delete" {
if (-not $Target) {
Write-Error "Error: service env delete requires service ID"
exit 1
}
if (Invoke-ServiceEnvDelete -ServiceId $Target) {
Write-Host "`e[32mVault deleted for service $Target`e[0m"
} else {
Write-Error "Error: Failed to delete vault"
exit 1
}
}
default {
Write-Error "Error: Unknown env action: $Action"
Write-Host "Usage: pwsh un.ps1 service env <status|set|export|delete> <service_id>"
exit 1
}
}
}
function Invoke-Execute { function Invoke-Execute {
param($SourceFile, $EnvVars = @{}, $Network = $null) param($SourceFile, $EnvVars = @{}, $Network = $null)
@ -283,6 +458,41 @@ function Invoke-Key {
function Invoke-Service { function Invoke-Service {
param($Args) param($Args)
# Parse env subcommand and -e/--env-file
$envAction = $null
$envTarget = $null
$envs = @()
$envFile = $null
for ($i = 0; $i -lt $Args.Count; $i++) {
if ($Args[$i] -eq "env" -and ($i + 1) -lt $Args.Count) {
$next = $Args[$i + 1]
if (-not $next.StartsWith("-")) {
$envAction = $next
$i++
if (($i + 1) -lt $Args.Count) {
$next2 = $Args[$i + 1]
if (-not $next2.StartsWith("-")) {
$envTarget = $next2
$i++
}
}
}
} elseif ($Args[$i] -eq "-e" -and ($i + 1) -lt $Args.Count) {
$envs += $Args[$i + 1]
$i++
} elseif ($Args[$i] -eq "--env-file" -and ($i + 1) -lt $Args.Count) {
$envFile = $Args[$i + 1]
$i++
}
}
# Handle env subcommand
if ($envAction) {
Invoke-ServiceEnv -Action $envAction -Target $envTarget -Envs $envs -EnvFile $envFile
return
}
if ($Args -contains "--list" -or $Args -contains "-l") { if ($Args -contains "--list" -or $Args -contains "-l") {
$result = Invoke-Api -Endpoint "/services" $result = Invoke-Api -Endpoint "/services"
$result | ConvertTo-Json -Depth 5 $result | ConvertTo-Json -Depth 5
@ -417,8 +627,21 @@ function Invoke-Service {
$body = $payload | ConvertTo-Json -Depth 10 $body = $payload | ConvertTo-Json -Depth 10
$result = Invoke-Api -Endpoint "/services" -Method "POST" -Body $body $result = Invoke-Api -Endpoint "/services" -Method "POST" -Body $body
Write-Host "`e[32mService created`e[0m" $serviceId = $result.id
Write-Host "`e[32mService created: $serviceId`e[0m"
$result | ConvertTo-Json -Depth 5 $result | ConvertTo-Json -Depth 5
# Auto-set vault if env vars were provided
if ($envs.Count -gt 0 -or $envFile) {
$envContent = Build-EnvContent -Envs $envs -EnvFile $envFile
if ($envContent) {
if (Invoke-ServiceEnvSet -ServiceId $serviceId -EnvContent $envContent) {
Write-Host "`e[32mVault configured with environment variables`e[0m"
} else {
Write-Host "`e[33mWarning: Failed to set vault`e[0m"
}
}
}
return return
} }
@ -450,6 +673,8 @@ Service options:
--type TYPE Service type (minecraft, mumble, teamspeak, source, tcp, udp) --type TYPE Service type (minecraft, mumble, teamspeak, source, tcp, udp)
--bootstrap CMD Bootstrap command --bootstrap CMD Bootstrap command
-f FILE Input file (can be repeated) -f FILE Input file (can be repeated)
-e KEY=VALUE Environment variable for vault (can be repeated)
--env-file FILE Load vault variables from file
--list, -l List services --list, -l List services
--info ID Get service info --info ID Get service info
--logs ID Get logs --logs ID Get logs
@ -459,6 +684,12 @@ Service options:
--dump-bootstrap ID Dump bootstrap script from service --dump-bootstrap ID Dump bootstrap script from service
--dump-file FILE Save bootstrap to file (with --dump-bootstrap) --dump-file FILE Save bootstrap to file (with --dump-bootstrap)
Service env commands:
env status ID Show vault status
env set ID Set vault (-e KEY=VALUE or --env-file FILE)
env export ID Export vault contents
env delete ID Delete vault
Key options: Key options:
--extend Open browser to extend key --extend Open browser to extend key
"@ "@

197
un.py
View file

@ -196,6 +196,149 @@ def api_request(endpoint, method="GET", data=None, public_key=None, secret_key=N
sys.exit(1) sys.exit(1)
def api_request_text(endpoint, method="PUT", body="", public_key=None, secret_key=None):
"""Make API request with text/plain body and HMAC authentication"""
url = f"{API_BASE}{endpoint}"
# Generate HMAC signature
timestamp = str(int(time.time()))
signature_input = f"{timestamp}:{method}:{endpoint}:{body}"
signature = hmac.new(
secret_key.encode('utf-8'),
signature_input.encode('utf-8'),
hashlib.sha256
).hexdigest()
headers = {
"Authorization": f"Bearer {public_key}",
"X-Timestamp": timestamp,
"X-Signature": signature,
"Content-Type": "text/plain"
}
req = urllib.request.Request(url, method=method, headers=headers)
if body:
req.data = body.encode('utf-8')
try:
with urllib.request.urlopen(req, timeout=300) as resp:
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else str(e)
print(f"{RED}Error: HTTP {e.code} - {error_body}{RESET}", file=sys.stderr)
return None
except urllib.error.URLError as e:
print(f"{RED}Error: {e.reason}{RESET}", file=sys.stderr)
return None
# ============================================================================
# Environment Secrets Vault Functions
# ============================================================================
MAX_ENV_CONTENT_SIZE = 64 * 1024 # 64KB max env vault size
def service_env_status(public_key, secret_key, service_id):
"""Get environment vault status for a service"""
result = api_request(f"/services/{service_id}/env", public_key=public_key, secret_key=secret_key)
has_vault = result.get("has_vault", False)
if not has_vault:
print("Vault exists: no")
print("Variable count: 0")
else:
print("Vault exists: yes")
count = result.get("count", 0)
print(f"Variable count: {count}")
updated_at = result.get("updated_at")
if updated_at:
from datetime import datetime
dt = datetime.fromtimestamp(updated_at)
print(f"Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}")
def service_env_set(public_key, secret_key, service_id, env_content):
"""Set environment vault for a service (PUT /services/:id/env)"""
if not env_content or len(env_content) == 0:
print(f"{RED}Error: No environment content provided{RESET}", file=sys.stderr)
return False
if len(env_content) > MAX_ENV_CONTENT_SIZE:
print(f"{RED}Error: Environment content too large (max {MAX_ENV_CONTENT_SIZE} bytes){RESET}", file=sys.stderr)
return False
result = api_request_text(f"/services/{service_id}/env", method="PUT", body=env_content,
public_key=public_key, secret_key=secret_key)
if result is None:
return False
count = result.get("count", -1)
if count >= 0:
print(f"{GREEN}Environment vault updated: {count} variable{'s' if count != 1 else ''}{RESET}")
else:
print(f"{GREEN}Environment vault updated{RESET}")
message = result.get("message")
if message:
print(message)
return True
def service_env_export(public_key, secret_key, service_id):
"""Export environment vault for a service (POST /services/:id/env/export)"""
result = api_request(f"/services/{service_id}/env/export", method="POST", data={},
public_key=public_key, secret_key=secret_key)
env_content = result.get("env", "")
if env_content:
print(env_content, end='')
if not env_content.endswith('\n'):
print()
def service_env_delete(public_key, secret_key, service_id):
"""Delete environment vault for a service (DELETE /services/:id/env)"""
result = api_request(f"/services/{service_id}/env", method="DELETE",
public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Environment vault deleted{RESET}")
message = result.get("message")
if message:
print(message)
def read_env_file(filepath):
"""Read .env file contents"""
try:
with open(filepath, 'r') as f:
return f.read()
except FileNotFoundError:
print(f"{RED}Error: Env file not found: {filepath}{RESET}", file=sys.stderr)
sys.exit(1)
except IOError as e:
print(f"{RED}Error reading env file: {e}{RESET}", file=sys.stderr)
sys.exit(1)
def build_env_content(env_vars, env_file=None):
"""Build .env format content from -e flags and/or --env-file"""
content_parts = []
# Read from env file first
if env_file:
content_parts.append(read_env_file(env_file))
# Add -e flags (these override/append to file)
if env_vars:
for e in env_vars:
if '=' in e:
content_parts.append(e)
return '\n'.join(content_parts) if content_parts else None
def cmd_execute(args): def cmd_execute(args):
"""Execute source code""" """Execute source code"""
public_key, secret_key = get_api_keys(args.api_key) public_key, secret_key = get_api_keys(args.api_key)
@ -521,6 +664,43 @@ def cmd_service(args):
"""Manage persistent services""" """Manage persistent services"""
public_key, secret_key = get_api_keys(args.api_key) public_key, secret_key = get_api_keys(args.api_key)
# Handle env subcommand: un.py service env <action> <id>
if getattr(args, 'subcommand', None) == "env":
action = getattr(args, 'env_action', None)
target = getattr(args, 'env_target', None)
if not action:
print(f"{RED}Error: env action required (status, set, export, delete){RESET}", file=sys.stderr)
sys.exit(1)
if not target:
print(f"{RED}Error: Service ID required for env command{RESET}", file=sys.stderr)
sys.exit(1)
if action == "status":
service_env_status(public_key, secret_key, target)
return
elif action == "set":
env_content = build_env_content(args.env, args.env_file)
if not env_content:
# Try reading from stdin
import select
if select.select([sys.stdin], [], [], 0.0)[0]:
env_content = sys.stdin.read()
if not env_content:
print(f"{RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin{RESET}", file=sys.stderr)
sys.exit(1)
service_env_set(public_key, secret_key, target, env_content)
return
elif action == "export":
service_env_export(public_key, secret_key, target)
return
elif action == "delete":
service_env_delete(public_key, secret_key, target)
return
else:
print(f"{RED}Error: Unknown env action '{action}'. Use: status, set, export, delete{RESET}", file=sys.stderr)
sys.exit(1)
if args.list: if args.list:
result = api_request("/services", public_key=public_key, secret_key=secret_key) result = api_request("/services", public_key=public_key, secret_key=secret_key)
services = result.get("services", []) services = result.get("services", [])
@ -658,10 +838,19 @@ def cmd_service(args):
payload["vcpu"] = args.vcpu payload["vcpu"] = args.vcpu
result = api_request("/services", method="POST", data=payload, public_key=public_key, secret_key=secret_key) result = api_request("/services", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Service created: {result.get('id', 'N/A')}{RESET}") created_id = result.get('id')
print(f"{GREEN}Service created: {created_id or 'N/A'}{RESET}")
print(f"Name: {result.get('name', 'N/A')}") print(f"Name: {result.get('name', 'N/A')}")
if result.get('url'): if result.get('url'):
print(f"URL: {result.get('url')}") print(f"URL: {result.get('url')}")
# Set environment vault if -e or --env-file provided
if created_id:
env_content = build_env_content(args.env, args.env_file)
if env_content:
print(f"{YELLOW}Setting environment vault...{RESET}", file=sys.stderr)
if not service_env_set(public_key, secret_key, created_id, env_content):
print(f"{YELLOW}Warning: Failed to set environment vault{RESET}", file=sys.stderr)
return return
print(f"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}", file=sys.stderr) print(f"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}", file=sys.stderr)
@ -719,6 +908,10 @@ Examples:
# Service subcommand # Service subcommand
service_parser = subparsers.add_parser("service", help="Persistent services") service_parser = subparsers.add_parser("service", help="Persistent services")
# For env subcommand: un.py service env <action> <id>
service_parser.add_argument("subcommand", nargs="?", help="Subcommand (env)")
service_parser.add_argument("env_action", nargs="?", help="Env vault action (status, set, export, delete)")
service_parser.add_argument("env_target", nargs="?", help="Service ID for env command")
service_parser.add_argument("--name", help="Service name") service_parser.add_argument("--name", help="Service name")
service_parser.add_argument("--ports", help="Comma-separated ports") service_parser.add_argument("--ports", help="Comma-separated ports")
service_parser.add_argument("--domains", help="Comma-separated custom domains") service_parser.add_argument("--domains", help="Comma-separated custom domains")
@ -726,6 +919,8 @@ Examples:
service_parser.add_argument("--bootstrap", help="Bootstrap command or URI") service_parser.add_argument("--bootstrap", help="Bootstrap command or URI")
service_parser.add_argument("--bootstrap-file", dest="bootstrap_file", help="Upload local file as bootstrap script") service_parser.add_argument("--bootstrap-file", dest="bootstrap_file", help="Upload local file as bootstrap script")
service_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file") service_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file")
service_parser.add_argument("-e", "--env", action="append", metavar="KEY=VALUE", help="Set environment variable (stored in vault)")
service_parser.add_argument("--env-file", dest="env_file", metavar="FILE", help="Load env vars from .env file")
service_parser.add_argument("-l", "--list", action="store_true", help="List services") service_parser.add_argument("-l", "--list", action="store_true", help="List services")
service_parser.add_argument("--info", metavar="ID", help="Get service details") service_parser.add_argument("--info", metavar="ID", help="Get service details")
service_parser.add_argument("--tail", metavar="ID", help="Get last 9000 lines of logs") service_parser.add_argument("--tail", metavar="ID", help="Get last 9000 lines of logs")

192
un.r
View file

@ -66,6 +66,7 @@ RESET <- "\033[0m"
API_BASE <- "https://api.unsandbox.com" API_BASE <- "https://api.unsandbox.com"
PORTAL_BASE <- "https://unsandbox.com" PORTAL_BASE <- "https://unsandbox.com"
MAX_ENV_CONTENT_SIZE <- 65536
detect_language <- function(filename) { detect_language <- function(filename) {
ext <- tolower(sub(".*(\\..*)$", "\\1", filename)) ext <- tolower(sub(".*(\\..*)$", "\\1", filename))
@ -158,6 +159,138 @@ api_request <- function(endpoint, public_key, secret_key, method = "GET", data =
}) })
} }
api_request_text <- function(endpoint, public_key, secret_key, body) {
url <- paste0(API_BASE, endpoint)
headers <- add_headers(
`Content-Type` = "text/plain",
`Authorization` = paste("Bearer", public_key)
)
# Add HMAC signature if secret_key is present
if (secret_key != "") {
timestamp <- as.integer(Sys.time())
sig_input <- paste0(timestamp, ":PUT:", endpoint, ":", body)
signature <- hmac(sig_input, secret_key, algo = "sha256")
headers <- add_headers(
`Content-Type` = "text/plain",
`Authorization` = paste("Bearer", public_key),
`X-Timestamp` = as.character(timestamp),
`X-Signature` = signature
)
}
tryCatch({
response <- PUT(url, headers, body = body, encode = "raw", timeout(300))
status_code <- status_code(response)
return(status_code >= 200 && status_code < 300)
}, error = function(e) {
return(FALSE)
})
}
read_env_file <- function(path) {
if (!file.exists(path)) {
cat(sprintf("%sError: Env file not found: %s%s\n", RED, path, RESET), file = stderr())
quit(status = 1)
}
return(paste(readLines(path, warn = FALSE), collapse = "\n"))
}
build_env_content <- function(envs, env_file) {
lines <- c()
if (!is.null(envs)) {
lines <- c(lines, envs)
}
if (!is.null(env_file) && env_file != "") {
content <- read_env_file(env_file)
for (line in strsplit(content, "\n")[[1]]) {
trimmed <- trimws(line)
if (nchar(trimmed) > 0 && !startsWith(trimmed, "#")) {
lines <- c(lines, trimmed)
}
}
}
return(paste(lines, collapse = "\n"))
}
cmd_service_env <- function(args) {
keys <- get_api_keys(args$api_key)
public_key <- keys$public_key
secret_key <- keys$secret_key
action <- args$env_action
target <- args$env_target
if (action == "status") {
if (is.null(target) || target == "") {
cat(sprintf("%sError: service env status requires service ID%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
result <- api_request(paste0("/services/", target, "/env"), public_key, secret_key)
if (!is.null(result$has_vault) && result$has_vault) {
cat(sprintf("%sVault: configured%s\n", GREEN, RESET))
if (!is.null(result$env_count)) {
cat(sprintf("Variables: %s\n", result$env_count))
}
if (!is.null(result$updated_at)) {
cat(sprintf("Updated: %s\n", result$updated_at))
}
} else {
cat(sprintf("%sVault: not configured%s\n", YELLOW, RESET))
}
return()
}
if (action == "set") {
if (is.null(target) || target == "") {
cat(sprintf("%sError: service env set requires service ID%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
if ((is.null(args$svc_envs) || length(args$svc_envs) == 0) && (is.null(args$svc_env_file) || args$svc_env_file == "")) {
cat(sprintf("%sError: service env set requires -e or --env-file%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
env_content <- build_env_content(args$svc_envs, args$svc_env_file)
if (nchar(env_content) > MAX_ENV_CONTENT_SIZE) {
cat(sprintf("%sError: Env content exceeds maximum size of 64KB%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
if (api_request_text(paste0("/services/", target, "/env"), public_key, secret_key, env_content)) {
cat(sprintf("%sVault updated for service %s%s\n", GREEN, target, RESET))
} else {
cat(sprintf("%sError: Failed to update vault%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
return()
}
if (action == "export") {
if (is.null(target) || target == "") {
cat(sprintf("%sError: service env export requires service ID%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
result <- api_request(paste0("/services/", target, "/env/export"), public_key, secret_key, method = "POST", data = list())
if (!is.null(result$content)) {
cat(result$content)
}
return()
}
if (action == "delete") {
if (is.null(target) || target == "") {
cat(sprintf("%sError: service env delete requires service ID%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
result <- api_request(paste0("/services/", target, "/env"), public_key, secret_key, method = "DELETE")
cat(sprintf("%sVault deleted for service %s%s\n", GREEN, target, RESET))
return()
}
cat(sprintf("%sError: Unknown env action: %s%s\n", RED, action, RESET), file = stderr())
cat("Usage: un.r service env <status|set|export|delete> <service_id>\n", file = stderr())
quit(status = 1)
}
cmd_execute <- function(args) { cmd_execute <- function(args) {
keys <- get_api_keys(args$api_key) keys <- get_api_keys(args$api_key)
public_key <- keys$public_key public_key <- keys$public_key
@ -517,6 +650,12 @@ cmd_snapshot <- function(args) {
} }
cmd_service <- function(args) { cmd_service <- function(args) {
# Handle env subcommand
if (!is.null(args$env_action) && args$env_action != "") {
cmd_service_env(args)
return()
}
keys <- get_api_keys(args$api_key) keys <- get_api_keys(args$api_key)
public_key <- keys$public_key public_key <- keys$public_key
secret_key <- keys$secret_key secret_key <- keys$secret_key
@ -686,6 +825,19 @@ cmd_service <- function(args) {
if (!is.null(result$url)) { if (!is.null(result$url)) {
cat(sprintf("URL: %s\n", result$url)) cat(sprintf("URL: %s\n", result$url))
} }
# Auto-set vault if -e or --env-file provided
if ((!is.null(args$svc_envs) && length(args$svc_envs) > 0) || (!is.null(args$svc_env_file) && args$svc_env_file != "")) {
service_id <- result$id
if (!is.null(service_id)) {
env_content <- build_env_content(args$svc_envs, args$svc_env_file)
if (api_request_text(paste0("/services/", service_id, "/env"), public_key, secret_key, env_content)) {
cat(sprintf("%sVault configured for service %s%s\n", GREEN, service_id, RESET))
} else {
cat(sprintf("%sWarning: Failed to set vault%s\n", YELLOW, RESET), file = stderr())
}
}
}
return() return()
} }
@ -732,7 +884,11 @@ parse_args <- function() {
bootstrap = NULL, bootstrap = NULL,
bootstrap_file = NULL, bootstrap_file = NULL,
vcpu = NULL, vcpu = NULL,
extend = FALSE extend = FALSE,
svc_envs = NULL,
svc_env_file = NULL,
env_action = NULL,
env_target = NULL
) )
i <- 1 i <- 1
@ -745,6 +901,18 @@ parse_args <- function() {
} else if (arg == "service") { } else if (arg == "service") {
result$command <- "service" result$command <- "service"
i <- i + 1 i <- i + 1
# Check for env subcommand
if (i <= length(args) && args[i] == "env") {
i <- i + 1
if (i <= length(args)) {
result$env_action <- args[i]
i <- i + 1
}
if (i <= length(args) && !startsWith(args[i], "-")) {
result$env_target <- args[i]
i <- i + 1
}
}
} else if (arg == "key") { } else if (arg == "key") {
result$command <- "key" result$command <- "key"
i <- i + 1 i <- i + 1
@ -761,7 +929,15 @@ parse_args <- function() {
i <- i + 1 i <- i + 1
} else if (arg %in% c("-e", "--env")) { } else if (arg %in% c("-e", "--env")) {
i <- i + 1 i <- i + 1
result$env <- c(result$env, args[i]) if (!is.null(result$command) && result$command == "service") {
result$svc_envs <- c(result$svc_envs, args[i])
} else {
result$env <- c(result$env, args[i])
}
i <- i + 1
} else if (arg == "--env-file") {
i <- i + 1
result$svc_env_file <- args[i]
i <- i + 1 i <- i + 1
} else if (arg %in% c("-f", "--files")) { } else if (arg %in% c("-f", "--files")) {
i <- i + 1 i <- i + 1
@ -887,8 +1063,14 @@ parse_args <- function() {
cat("Usage: un.r [options] <source_file>\n", file = stderr()) cat("Usage: un.r [options] <source_file>\n", file = stderr())
cat(" un.r session [options]\n", file = stderr()) cat(" un.r session [options]\n", file = stderr())
cat(" un.r service [options]\n", file = stderr()) cat(" un.r service [options]\n", file = stderr())
cat(" un.r service env <action> <service_id> [options]\n", file = stderr())
cat(" un.r snapshot [options]\n", file = stderr()) cat(" un.r snapshot [options]\n", file = stderr())
cat(" un.r key [options]\n", file = stderr()) cat(" un.r key [options]\n", file = stderr())
cat("\nService env commands:\n", file = stderr())
cat(" env status <id> Show vault status\n", file = stderr())
cat(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n", file = stderr())
cat(" env export <id> Export vault contents\n", file = stderr())
cat(" env delete <id> Delete vault\n", file = stderr())
quit(status = 1) quit(status = 1)
} }
} }
@ -913,8 +1095,14 @@ main <- function() {
cat("Usage: un.r [options] <source_file>\n", file = stderr()) cat("Usage: un.r [options] <source_file>\n", file = stderr())
cat(" un.r session [options]\n", file = stderr()) cat(" un.r session [options]\n", file = stderr())
cat(" un.r service [options]\n", file = stderr()) cat(" un.r service [options]\n", file = stderr())
cat(" un.r service env <action> <service_id> [options]\n", file = stderr())
cat(" un.r snapshot [options]\n", file = stderr()) cat(" un.r snapshot [options]\n", file = stderr())
cat(" un.r key [options]\n", file = stderr()) cat(" un.r key [options]\n", file = stderr())
cat("\nService env commands:\n", file = stderr())
cat(" env status <id> Show vault status\n", file = stderr())
cat(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n", file = stderr())
cat(" env export <id> Export vault contents\n", file = stderr())
cat(" env delete <id> Delete vault\n", file = stderr())
quit(status = 1) quit(status = 1)
} }
} }

138
un.raku
View file

@ -139,7 +139,7 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec
@args.append: $url; @args.append: $url;
my $proc = run |@args, :out, :err; my $proc = run |@args, :out, :err;
my $body = $proc.out.slurp; my $resp-body = $proc.out.slurp;
my $err = $proc.err.slurp; my $err = $proc.err.slurp;
if $proc.exitcode != 0 { if $proc.exitcode != 0 {
@ -149,7 +149,7 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec
} }
# Check for clock drift errors # Check for clock drift errors
if $body.contains('timestamp') && ($body.contains('401') || $body.contains('expired') || $body.contains('invalid')) { if $resp-body.contains('timestamp') && ($resp-body.contains('401') || $resp-body.contains('expired') || $resp-body.contains('invalid')) {
note "{$RED}Error: Request timestamp expired (must be within 5 minutes of server time){$RESET}"; note "{$RED}Error: Request timestamp expired (must be within 5 minutes of server time){$RESET}";
note "{$YELLOW}Your computer's clock may have drifted.{$RESET}"; note "{$YELLOW}Your computer's clock may have drifted.{$RESET}";
note "{$YELLOW}Check your system time and sync with NTP if needed:{$RESET}"; note "{$YELLOW}Check your system time and sync with NTP if needed:{$RESET}";
@ -159,7 +159,32 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec
exit 1; exit 1;
} }
return from-json($body); return from-json($resp-body);
}
# API request for PUT with text/plain body (used for vault)
sub api-request-put-text(Str $endpoint, Str $content, Str :$public-key!, Str :$secret-key!) {
my $url = $API_BASE ~ $endpoint;
my @args = 'curl', '-s', '-X', 'PUT';
@args.append: '-H', 'Content-Type: text/plain';
@args.append: '-H', "Authorization: Bearer $public-key";
# Add HMAC signature if secret-key is present
if $secret-key {
my $timestamp = now.Int;
my $sig-input = "{$timestamp}:PUT:{$endpoint}:{$content}";
my $signature = hmac-hex($sig-input, $secret-key, &sha256);
@args.append: '-H', "X-Timestamp: $timestamp";
@args.append: '-H', "X-Signature: $signature";
}
@args.append: '--data-binary', $content;
@args.append: $url;
my $proc = run |@args, :out, :err;
my $resp-body = $proc.out.slurp;
return from-json($resp-body);
} }
sub cmd-execute(@args) { sub cmd-execute(@args) {
@ -370,6 +395,41 @@ sub cmd-session(@args) {
say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}"; say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}";
} }
# Service vault functions
sub service-env-status(Str $service-id, Str :$public-key!, Str :$secret-key!) {
my %result = api-request("/services/$service-id/env", 'GET', :$public-key, :$secret-key);
say to-json(%result, :pretty);
}
sub service-env-set(Str $service-id, Str $content, Str :$public-key!, Str :$secret-key!) {
my %result = api-request-put-text("/services/$service-id/env", $content, :$public-key, :$secret-key);
say to-json(%result, :pretty);
}
sub service-env-export(Str $service-id, Str :$public-key!, Str :$secret-key!) {
my %result = api-request("/services/$service-id/env/export", 'POST', :$public-key, :$secret-key);
say %result<content> if %result<content>;
}
sub service-env-delete(Str $service-id, Str :$public-key!, Str :$secret-key!) {
api-request("/services/$service-id/env", 'DELETE', :$public-key, :$secret-key);
say "{$GREEN}Vault deleted for: $service-id{$RESET}";
}
sub build-env-content(@env-vars, Str $env-file --> Str) {
my @lines;
for @env-vars -> $var {
@lines.push($var);
}
if $env-file && $env-file.IO.e {
for $env-file.IO.lines -> $line {
next if $line.starts-with('#') || $line.trim eq '';
@lines.push($line);
}
}
return @lines.join("\n");
}
sub cmd-service(@args) { sub cmd-service(@args) {
my ($public-key, $secret-key) = get-api-keys(); my ($public-key, $secret-key) = get-api-keys();
my $list-mode = False; my $list-mode = False;
@ -388,6 +448,61 @@ sub cmd-service(@args) {
my $network = ''; my $network = '';
my $vcpu = 0; my $vcpu = 0;
my @input-files; my @input-files;
my @env-vars;
my $env-file = '';
my $env-action = '';
my $env-target = '';
# Check for 'env' subcommand first
if @args.elems >= 1 && @args[0] eq 'env' {
if @args.elems < 3 {
note "Usage: un.raku service env <status|set|export|delete> <service_id> [options]";
exit 1;
}
$env-action = @args[1];
$env-target = @args[2];
# Parse remaining args for -e and --env-file
my $i = 3;
while $i < @args.elems {
given @args[$i] {
when '-e' {
$i++;
@env-vars.push(@args[$i]);
}
when '--env-file' {
$i++;
$env-file = @args[$i];
}
}
$i++;
}
given $env-action {
when 'status' {
service-env-status($env-target, :$public-key, :$secret-key);
}
when 'set' {
my $content = build-env-content(@env-vars, $env-file);
if !$content {
note "{$RED}Error: No environment variables to set{$RESET}";
exit 1;
}
service-env-set($env-target, $content, :$public-key, :$secret-key);
}
when 'export' {
service-env-export($env-target, :$public-key, :$secret-key);
}
when 'delete' {
service-env-delete($env-target, :$public-key, :$secret-key);
}
default {
note "{$RED}Error: Unknown env action '$env-action'. Use status, set, export, or delete{$RESET}";
exit 1;
}
}
return;
}
# Parse arguments # Parse arguments
my $i = 0; my $i = 0;
@ -456,6 +571,14 @@ sub cmd-service(@args) {
$i++; $i++;
@input-files.push(@args[$i]); @input-files.push(@args[$i]);
} }
when '-e' {
$i++;
@env-vars.push(@args[$i]);
}
when '--env-file' {
$i++;
$env-file = @args[$i];
}
} }
$i++; $i++;
} }
@ -579,10 +702,17 @@ sub cmd-service(@args) {
say "{$GREEN}Service created: {%result<id>}{$RESET}"; say "{$GREEN}Service created: {%result<id>}{$RESET}";
say "Name: {%result<name>}"; say "Name: {%result<name>}";
say "URL: {%result<url>}" if %result<url>; say "URL: {%result<url>}" if %result<url>;
# Auto-set vault if -e or --env-file were provided
my $env-content = build-env-content(@env-vars, $env-file);
if $env-content && %result<id> {
say "{$YELLOW}Setting vault for service...{$RESET}";
service-env-set(%result<id>, $env-content, :$public-key, :$secret-key);
}
return; return;
} }
note "{$RED}Error: Specify --name to create a service, or use --list, --info, etc.{$RESET}"; note "{$RED}Error: Specify --name to create a service, or use --list, --info, env, etc.{$RESET}";
exit 1; exit 1;
} }

183
un.rb
View file

@ -169,6 +169,152 @@ rescue => e
exit 1 exit 1
end end
def api_request_text(endpoint, method:, body:, keys:)
require 'openssl'
uri = URI("#{API_BASE}#{endpoint}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 300
timestamp = Time.now.to_i.to_s
message = "#{timestamp}:#{method}:#{uri.path}:#{body}"
signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message)
request = case method
when 'PUT' then Net::HTTP::Put.new(uri)
else raise "Unknown method: #{method}"
end
request['Authorization'] = "Bearer #{keys[:public_key]}"
request['X-Timestamp'] = timestamp
request['X-Signature'] = signature
request['Content-Type'] = 'text/plain'
request.body = body
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
return { 'error' => "HTTP #{response.code} - #{response.body}" }
end
JSON.parse(response.body)
rescue => e
{ 'error' => e.message }
end
# ============================================================================
# Environment Secrets Vault Functions
# ============================================================================
MAX_ENV_CONTENT_SIZE = 64 * 1024 # 64KB max env vault size
def service_env_status(service_id, keys)
result = api_request("/services/#{service_id}/env", keys: keys)
has_vault = result['has_vault']
if !has_vault
puts "Vault exists: no"
puts "Variable count: 0"
else
puts "Vault exists: yes"
puts "Variable count: #{result['count'] || 0}"
if result['updated_at']
puts "Last updated: #{Time.at(result['updated_at']).strftime('%Y-%m-%d %H:%M:%S')}"
end
end
end
def service_env_set(service_id, env_content, keys)
if env_content.nil? || env_content.empty?
warn "#{RED}Error: No environment content provided#{RESET}"
return false
end
if env_content.bytesize > MAX_ENV_CONTENT_SIZE
warn "#{RED}Error: Environment content too large (max #{MAX_ENV_CONTENT_SIZE} bytes)#{RESET}"
return false
end
result = api_request_text("/services/#{service_id}/env", method: 'PUT', body: env_content, keys: keys)
if result['error']
warn "#{RED}Error: #{result['error']}#{RESET}"
return false
end
count = result['count'] || 0
plural = count == 1 ? '' : 's'
puts "#{GREEN}Environment vault updated: #{count} variable#{plural}#{RESET}"
puts result['message'] if result['message']
true
end
def service_env_export(service_id, keys)
result = api_request("/services/#{service_id}/env/export", method: 'POST', data: {}, keys: keys)
env_content = result['env']
if env_content && !env_content.empty?
print env_content
puts unless env_content.end_with?("\n")
end
end
def service_env_delete(service_id, keys)
api_request("/services/#{service_id}/env", method: 'DELETE', keys: keys)
puts "#{GREEN}Environment vault deleted#{RESET}"
end
def read_env_file(filepath)
File.read(filepath)
rescue => e
warn "#{RED}Error: Env file not found: #{filepath}#{RESET}"
exit 1
end
def build_env_content(envs, env_file)
parts = []
# Read from env file first
parts << read_env_file(env_file) if env_file && !env_file.empty?
# Add -e flags
envs.each do |e|
parts << e if e.include?('=')
end
parts.join("\n")
end
def cmd_service_env(action, target, envs, env_file, keys)
if action.nil? || action.empty?
warn "#{RED}Error: env action required (status, set, export, delete)#{RESET}"
exit 1
end
if target.nil? || target.empty?
warn "#{RED}Error: Service ID required for env command#{RESET}"
exit 1
end
case action
when 'status'
service_env_status(target, keys)
when 'set'
env_content = build_env_content(envs, env_file)
if env_content.empty?
warn "#{RED}Error: No env content provided. Use -e KEY=VAL or --env-file#{RESET}"
exit 1
end
service_env_set(target, env_content, keys)
when 'export'
service_env_export(target, keys)
when 'delete'
service_env_delete(target, keys)
else
warn "#{RED}Error: Unknown env action '#{action}'. Use: status, set, export, delete#{RESET}"
exit 1
end
end
def cmd_execute(options) def cmd_execute(options)
keys = get_api_keys(options[:api_key]) keys = get_api_keys(options[:api_key])
@ -620,9 +766,16 @@ def cmd_service(options)
payload[:vcpu] = options[:vcpu] if options[:vcpu] payload[:vcpu] = options[:vcpu] if options[:vcpu]
result = api_request('/services', method: 'POST', data: payload, keys: keys) result = api_request('/services', method: 'POST', data: payload, keys: keys)
puts "#{GREEN}Service created: #{result['id'] || 'N/A'}#{RESET}" service_id = result['id']
puts "#{GREEN}Service created: #{service_id || 'N/A'}#{RESET}"
puts "Name: #{result['name'] || 'N/A'}" puts "Name: #{result['name'] || 'N/A'}"
puts "URL: #{result['url']}" if result['url'] puts "URL: #{result['url']}" if result['url']
# Auto-set vault if -e or --env-file provided
env_content = build_env_content(options[:env] || [], options[:env_file])
if !env_content.empty? && service_id
service_env_set(service_id, env_content, keys)
end
return return
end end
@ -678,7 +831,10 @@ def main
dump_file: nil, dump_file: nil,
extend: false, extend: false,
bootstrap_file: nil, bootstrap_file: nil,
exec_shell: nil exec_shell: nil,
env_file: nil,
env_action: nil,
env_target: nil
} }
# Manual argument parsing # Manual argument parsing
@ -749,6 +905,21 @@ def main
when '--bootstrap-file' when '--bootstrap-file'
i += 1 i += 1
options[:bootstrap_file] = ARGV[i] options[:bootstrap_file] = ARGV[i]
when '--env-file'
i += 1
options[:env_file] = ARGV[i]
when 'env'
# Handle "service env <action> <target>" subcommand
if options[:command] == 'service'
i += 1
options[:env_action] = ARGV[i] if i < ARGV.length
i += 1
if i < ARGV.length && !ARGV[i].start_with?('-')
options[:env_target] = ARGV[i]
else
i -= 1 # back up if next arg is a flag
end
end
when '--info' when '--info'
i += 1 i += 1
options[:info] = ARGV[i] options[:info] = ARGV[i]
@ -846,7 +1017,13 @@ def main
when 'session' when 'session'
cmd_session(options) cmd_session(options)
when 'service' when 'service'
cmd_service(options) # Check for "service env" subcommand
if options[:env_action]
keys = get_api_keys(options[:api_key])
cmd_service_env(options[:env_action], options[:env_target], options[:env], options[:env_file], keys)
else
cmd_service(options)
end
when 'snapshot' when 'snapshot'
cmd_snapshot(options) cmd_snapshot(options)
when 'key' when 'key'

196
un.rs
View file

@ -244,6 +244,153 @@ fn api_request(endpoint: &str, method: &str, body: Option<&str>, public_key: &st
result result
} }
fn api_request_text(endpoint: &str, method: &str, body: &str, public_key: &str, secret_key: &str) -> String {
let url = format!("{}{}", API_BASE, endpoint);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string();
let signature = compute_hmac(secret_key, &timestamp, method, endpoint, body);
let mut cmd = Command::new("curl");
cmd.arg("-s")
.arg("-X")
.arg(method)
.arg(&url)
.arg("-H")
.arg("Content-Type: text/plain")
.arg("-H")
.arg(format!("Authorization: Bearer {}", public_key))
.arg("-H")
.arg(format!("X-Timestamp: {}", timestamp))
.arg("-H")
.arg(format!("X-Signature: {}", signature));
if !body.is_empty() {
cmd.arg("-d").arg(body);
}
let output = cmd.output().unwrap_or_else(|e| {
eprintln!("{}Error running curl: {}{}", RED, e, RESET);
process::exit(1);
});
String::from_utf8_lossy(&output.stdout).to_string()
}
fn read_env_file(path: &str) -> String {
fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("{}Error reading env file: {}{}", RED, e, RESET);
process::exit(1);
})
}
fn build_env_content(envs: &[String], env_file: Option<&str>) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(path) = env_file {
parts.push(read_env_file(path).trim().to_string());
}
for e in envs {
if e.contains('=') {
parts.push(e.clone());
}
}
parts.join("\n")
}
fn service_env_status(service_id: &str, public_key: &str, secret_key: &str) -> String {
api_request(&format!("/services/{}/env", service_id), "GET", None, public_key, secret_key)
}
fn service_env_set(service_id: &str, env_content: &str, public_key: &str, secret_key: &str) -> bool {
api_request_text(&format!("/services/{}/env", service_id), "PUT", env_content, public_key, secret_key);
true
}
fn service_env_export(service_id: &str, public_key: &str, secret_key: &str) -> String {
api_request(&format!("/services/{}/env/export", service_id), "POST", None, public_key, secret_key)
}
fn service_env_delete(service_id: &str, public_key: &str, secret_key: &str) -> bool {
api_request(&format!("/services/{}/env", service_id), "DELETE", None, public_key, secret_key);
true
}
fn cmd_service_env(
action: &str,
target: Option<&str>,
envs: &[String],
env_file: Option<&str>,
public_key: &str,
secret_key: &str,
) {
match action {
"status" => {
let id = target.unwrap_or_else(|| {
eprintln!("{}Error: Usage: service env status <service_id>{}", RED, RESET);
process::exit(1);
});
let result = service_env_status(id, public_key, secret_key);
let has_env = result.contains("\"has_env\":true");
let size = extract_json_int(&result, "size");
let updated_at = extract_json_string(&result, "updated_at");
println!("Service: {}", id);
println!("Has Vault: {}", if has_env { "Yes" } else { "No" });
if has_env {
println!("Size: {} bytes", size);
println!("Updated: {}", updated_at);
}
}
"set" => {
let id = target.unwrap_or_else(|| {
eprintln!("{}Error: Usage: service env set <service_id> [-e KEY=VAL] [--env-file FILE]{}", RED, RESET);
process::exit(1);
});
let env_content = build_env_content(envs, env_file);
if env_content.is_empty() {
eprintln!("{}Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE{}", RED, RESET);
process::exit(1);
}
if env_content.len() > 65536 {
eprintln!("{}Error: Environment content exceeds 64KB limit{}", RED, RESET);
process::exit(1);
}
service_env_set(id, &env_content, public_key, secret_key);
println!("{}Vault updated for service: {}{}", GREEN, id, RESET);
}
"export" => {
let id = target.unwrap_or_else(|| {
eprintln!("{}Error: Usage: service env export <service_id>{}", RED, RESET);
process::exit(1);
});
let result = service_env_export(id, public_key, secret_key);
let content = extract_json_string(&result, "content");
if !content.is_empty() {
print!("{}", content);
if !content.ends_with('\n') {
println!();
}
} else {
eprintln!("{}Vault is empty{}", YELLOW, RESET);
}
}
"delete" => {
let id = target.unwrap_or_else(|| {
eprintln!("{}Error: Usage: service env delete <service_id>{}", RED, RESET);
process::exit(1);
});
service_env_delete(id, public_key, secret_key);
println!("{}Vault deleted for service: {}{}", GREEN, id, RESET);
}
_ => {
eprintln!("{}Error: Unknown env action: {}. Use status, set, export, or delete{}", RED, action, RESET);
process::exit(1);
}
}
}
fn cmd_execute( fn cmd_execute(
source_file: &str, source_file: &str,
envs: Vec<String>, envs: Vec<String>,
@ -430,9 +577,19 @@ fn cmd_service(
dump_file: Option<&str>, dump_file: Option<&str>,
network: Option<&str>, network: Option<&str>,
vcpu: Option<i32>, vcpu: Option<i32>,
envs: &[String],
env_file: Option<&str>,
env_action: Option<&str>,
env_target: Option<&str>,
public_key: &str, public_key: &str,
secret_key: &str, secret_key: &str,
) { ) {
// Handle service env subcommand
if let Some(action) = env_action {
cmd_service_env(action, env_target, envs, env_file, public_key, secret_key);
return;
}
if list { if list {
let result = api_request("/services", "GET", None, public_key, secret_key); let result = api_request("/services", "GET", None, public_key, secret_key);
println!("{}", result); println!("{}", result);
@ -601,6 +758,16 @@ fn cmd_service(
let result = api_request("/services", "POST", Some(&json), public_key, secret_key); let result = api_request("/services", "POST", Some(&json), public_key, secret_key);
let id = extract_json_string(&result, "id"); let id = extract_json_string(&result, "id");
println!("{}Service created: {}{}", GREEN, id, RESET); println!("{}Service created: {}{}", GREEN, id, RESET);
// Auto-set vault if env vars provided
if !id.is_empty() && (!envs.is_empty() || env_file.is_some()) {
let env_content = build_env_content(envs, env_file);
if !env_content.is_empty() && env_content.len() <= 65536 {
if service_env_set(&id, &env_content, public_key, secret_key) {
println!("{}Vault configured with environment variables{}", GREEN, RESET);
}
}
}
return; return;
} }
@ -778,13 +945,36 @@ fn main() {
} }
"service" => { "service" => {
let (public_key, secret_key) = get_api_keys(api_key.as_deref()); let (public_key, secret_key) = get_api_keys(api_key.as_deref());
// Collect -f files for service // Collect -f files and -e envs for service
let mut service_files: Vec<String> = Vec::new(); let mut service_files: Vec<String> = Vec::new();
let mut service_envs: Vec<String> = Vec::new();
let mut env_file_opt: Option<String> = None;
let mut env_action: Option<String> = None;
let mut env_target: Option<String> = None;
let mut j = i + 1; let mut j = i + 1;
while j < args.len() { while j < args.len() {
if args[j] == "-f" && j + 1 < args.len() { if args[j] == "-f" && j + 1 < args.len() {
service_files.push(args[j + 1].clone()); service_files.push(args[j + 1].clone());
j += 2; j += 2;
} else if args[j] == "-e" && j + 1 < args.len() {
service_envs.push(args[j + 1].clone());
j += 2;
} else if args[j] == "--env-file" && j + 1 < args.len() {
env_file_opt = Some(args[j + 1].clone());
j += 2;
} else if args[j] == "env" && env_action.is_none() {
// service env <action> <target>
if j + 1 < args.len() && !args[j + 1].starts_with('-') {
env_action = Some(args[j + 1].clone());
if j + 2 < args.len() && !args[j + 2].starts_with('-') {
env_target = Some(args[j + 2].clone());
j += 3;
} else {
j += 2;
}
} else {
j += 1;
}
} else { } else {
j += 1; j += 1;
} }
@ -810,6 +1000,10 @@ fn main() {
args.iter().position(|x| x == "--dump-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--dump-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()),
network.as_deref(), network.as_deref(),
vcpu, vcpu,
&service_envs,
env_file_opt.as_deref(),
env_action.as_deref(),
env_target.as_deref(),
&public_key, &public_key,
&secret_key, &secret_key,
); );

137
un.scm
View file

@ -243,6 +243,58 @@
(exit 1)) (exit 1))
output)) output))
(define (curl-put-text api-key endpoint content)
"PUT request with text/plain content type (for vault)"
(let* ((tmp-file (write-temp-file content))
(keys (get-api-keys))
(public-key (car keys))
(secret-key (cadr keys))
(auth-headers (build-auth-headers public-key secret-key "PUT" endpoint content))
(cmd (string-append "curl -s -X PUT https://api.unsandbox.com" endpoint
" -H 'Content-Type: text/plain' "
(string-join auth-headers " ")
" --data-binary @" tmp-file))
(port (open-input-pipe cmd))
(output (let loop ((lines '()))
(let ((line (read-line port)))
(if (eof-object? line)
(string-join (reverse lines) "\n")
(loop (cons line lines)))))))
(close-pipe port)
(delete-file tmp-file)
output))
(define (build-env-content env-vars env-file)
"Build env content from -e args and --env-file"
(let* ((var-lines env-vars)
(file-lines (if (and env-file (file-exists? env-file))
(let ((content (read-file env-file)))
(filter (lambda (line)
(let ((trimmed (string-trim-both line)))
(and (> (string-length trimmed) 0)
(not (char=? (string-ref trimmed 0) #\#)))))
(string-split content #\newline)))
'())))
(string-join (append var-lines file-lines) "\n")))
;; Service vault functions
(define (service-env-status api-key service-id)
(display (curl-get api-key (format #f "/services/~a/env" service-id)))
(newline))
(define (service-env-set api-key service-id content)
(display (curl-put-text api-key (format #f "/services/~a/env" service-id) content))
(newline))
(define (service-env-export api-key service-id)
(let* ((response (curl-post api-key (format #f "/services/~a/env/export" service-id) "{}"))
(content (json-extract-string response "content")))
(when content (display content))))
(define (service-env-delete api-key service-id)
(curl-delete api-key (format #f "/services/~a/env" service-id))
(format #t "~aVault deleted for: ~a~a\n" green service-id reset))
(define (get-api-keys) (define (get-api-keys)
(let ((public-key (getenv "UNSANDBOX_PUBLIC_KEY")) (let ((public-key (getenv "UNSANDBOX_PUBLIC_KEY"))
(secret-key (getenv "UNSANDBOX_SECRET_KEY")) (secret-key (getenv "UNSANDBOX_SECRET_KEY"))
@ -382,7 +434,7 @@
(display response) (display response)
(newline)))))) (newline))))))
(define (service-cmd action id name ports bootstrap bootstrap-file type input-files) (define (service-cmd action id name ports bootstrap bootstrap-file type input-files env-vars env-file)
(let ((api-key (get-api-key))) (let ((api-key (get-api-key)))
(cond (cond
((equal? action "list") ((equal? action "list")
@ -403,6 +455,19 @@
((equal? action "destroy") ((equal? action "destroy")
(curl-delete api-key (format #f "/services/~a" id)) (curl-delete api-key (format #f "/services/~a" id))
(format #t "~aService destroyed: ~a~a\n" green id reset)) (format #t "~aService destroyed: ~a~a\n" green id reset))
((equal? action "env-status")
(service-env-status api-key id))
((equal? action "env-set")
(let ((content (build-env-content env-vars env-file)))
(if (> (string-length content) 0)
(service-env-set api-key id content)
(begin
(format (current-error-port) "~aError: No environment variables to set~a\n" red reset)
(exit 1)))))
((equal? action "env-export")
(service-env-export api-key id))
((equal? action "env-delete")
(service-env-delete api-key id))
((equal? action "execute") ((equal? action "execute")
(when (and id bootstrap) (when (and id bootstrap)
(let* ((json (format #f "{\"command\":\"~a\"}" (escape-json bootstrap))) (let* ((json (format #f "{\"command\":\"~a\"}" (escape-json bootstrap)))
@ -436,12 +501,18 @@
(type-json (if type (format #f ",\"service_type\":\"~a\"" type) "")) (type-json (if type (format #f ",\"service_type\":\"~a\"" type) ""))
(input-files-json (build-input-files-json input-files)) (input-files-json (build-input-files-json input-files))
(json (format #f "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json)) (json (format #f "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json))
(response (curl-post api-key "/services" json))) (response (curl-post api-key "/services" json))
(service-id (json-extract-string response "id")))
(format #t "~aService created~a\n" green reset) (format #t "~aService created~a\n" green reset)
(display response) (display response)
(newline))) (newline)
;; Auto-set vault if env vars were provided
(let ((env-content (build-env-content env-vars env-file)))
(when (and service-id (> (string-length env-content) 0))
(format #t "~aSetting vault for service...~a\n" yellow reset)
(service-env-set api-key service-id env-content)))))
(else (else
(display "Error: --name required to create service\n" (current-error-port)) (display "Error: --name required to create service, or use env subcommand\n" (current-error-port))
(exit 1))))) (exit 1)))))
(define (parse-input-files args) (define (parse-input-files args)
@ -497,23 +568,53 @@
((equal? (car args) "service") ((equal? (car args) "service")
(cond (cond
((and (> (length args) 1) (equal? (cadr args) "--list")) ((and (> (length args) 1) (equal? (cadr args) "--list"))
(service-cmd "list" #f #f #f #f #f #f '())) (service-cmd "list" #f #f #f #f #f #f '() '() #f))
((and (> (length args) 2) (equal? (cadr args) "--info")) ((and (> (length args) 2) (equal? (cadr args) "--info"))
(service-cmd "info" (caddr args) #f #f #f #f #f '())) (service-cmd "info" (caddr args) #f #f #f #f #f '() '() #f))
((and (> (length args) 2) (equal? (cadr args) "--logs")) ((and (> (length args) 2) (equal? (cadr args) "--logs"))
(service-cmd "logs" (caddr args) #f #f #f #f #f '())) (service-cmd "logs" (caddr args) #f #f #f #f #f '() '() #f))
((and (> (length args) 2) (equal? (cadr args) "--freeze")) ((and (> (length args) 2) (equal? (cadr args) "--freeze"))
(service-cmd "sleep" (caddr args) #f #f #f #f #f '())) (service-cmd "sleep" (caddr args) #f #f #f #f #f '() '() #f))
((and (> (length args) 2) (equal? (cadr args) "--unfreeze")) ((and (> (length args) 2) (equal? (cadr args) "--unfreeze"))
(service-cmd "wake" (caddr args) #f #f #f #f #f '())) (service-cmd "wake" (caddr args) #f #f #f #f #f '() '() #f))
((and (> (length args) 2) (equal? (cadr args) "--destroy")) ((and (> (length args) 2) (equal? (cadr args) "--destroy"))
(service-cmd "destroy" (caddr args) #f #f #f #f #f '())) (service-cmd "destroy" (caddr args) #f #f #f #f #f '() '() #f))
((and (> (length args) 3) (equal? (cadr args) "--execute")) ((and (> (length args) 3) (equal? (cadr args) "--execute"))
(service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '())) (service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '() '() #f))
((and (> (length args) 3) (equal? (cadr args) "--dump-bootstrap")) ((and (> (length args) 3) (equal? (cadr args) "--dump-bootstrap"))
(service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '())) (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '() '() #f))
((and (> (length args) 2) (equal? (cadr args) "--dump-bootstrap")) ((and (> (length args) 2) (equal? (cadr args) "--dump-bootstrap"))
(service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '())) (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '() '() #f))
;; Service env subcommand: service env <action> <id> [options]
((and (> (length args) 1) (equal? (cadr args) "env"))
(if (< (length args) 4)
(begin
(display "Usage: un.scm service env <status|set|export|delete> <service_id> [options]\n" (current-error-port))
(exit 1))
(let* ((env-action (caddr args))
(service-id (list-ref args 3))
(rest-args (if (> (length args) 4) (list-tail args 4) '())))
(cond
((equal? env-action "status")
(service-cmd "env-status" service-id #f #f #f #f #f '() '() #f))
((equal? env-action "set")
;; Parse -e and --env-file from rest-args
(let loop ((args rest-args) (env-vars '()) (env-file #f))
(if (null? args)
(service-cmd "env-set" service-id #f #f #f #f #f '() env-vars env-file)
(cond
((and (equal? (car args) "-e") (pair? (cdr args)))
(loop (cddr args) (cons (cadr args) env-vars) env-file))
((and (equal? (car args) "--env-file") (pair? (cdr args)))
(loop (cddr args) env-vars (cadr args)))
(else (loop (cdr args) env-vars env-file))))))
((equal? env-action "export")
(service-cmd "env-export" service-id #f #f #f #f #f '() '() #f))
((equal? env-action "delete")
(service-cmd "env-delete" service-id #f #f #f #f #f '() '() #f))
(else
(format (current-error-port) "~aUnknown env action: ~a~a\n" red env-action reset)
(exit 1))))))
((and (> (length args) 2) (equal? (cadr args) "--name")) ((and (> (length args) 2) (equal? (cadr args) "--name"))
(let* ((name (caddr args)) (let* ((name (caddr args))
(rest-args (cdddr args)) (rest-args (cdddr args))
@ -521,6 +622,8 @@
(bootstrap #f) (bootstrap #f)
(bootstrap-file #f) (bootstrap-file #f)
(type #f) (type #f)
(env-vars '())
(env-file #f)
(input-files (parse-input-files rest-args))) (input-files (parse-input-files rest-args)))
;; Parse remaining args ;; Parse remaining args
(let loop ((args rest-args)) (let loop ((args rest-args))
@ -538,10 +641,16 @@
((equal? (car args) "--type") ((equal? (car args) "--type")
(set! type (cadr args)) (set! type (cadr args))
(loop (cddr args))) (loop (cddr args)))
((equal? (car args) "-e")
(set! env-vars (cons (cadr args) env-vars))
(loop (cddr args)))
((equal? (car args) "--env-file")
(set! env-file (cadr args))
(loop (cddr args)))
((equal? (car args) "-f") ((equal? (car args) "-f")
(loop (cddr args))) ; skip -f, already parsed (loop (cddr args))) ; skip -f, already parsed
(else (loop (cdr args)))))) (else (loop (cdr args))))))
(service-cmd "create" #f name ports bootstrap bootstrap-file type input-files))) (service-cmd "create" #f name ports bootstrap bootstrap-file type input-files env-vars env-file)))
(else (else
(display "Error: Invalid service command\n" (current-error-port)) (display "Error: Invalid service command\n" (current-error-port))
(exit 1)))) (exit 1))))

265
un.sh
View file

@ -206,6 +206,177 @@ api_request() {
echo "$body" echo "$body"
} }
api_request_text() {
local endpoint="$1"
local method="${2:-PUT}"
local body="${3:-}"
local public_key="${4:-${UNSANDBOX_PUBLIC_KEY:-}}"
local secret_key="${5:-${UNSANDBOX_SECRET_KEY:-}}"
# Fallback to old UNSANDBOX_API_KEY for backwards compat
if [[ -z "$public_key" ]] && [[ -n "${UNSANDBOX_API_KEY:-}" ]]; then
public_key="${UNSANDBOX_API_KEY}"
secret_key=""
fi
if [[ -z "$public_key" ]]; then
echo -e "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}" >&2
return 1
fi
local url="${API_BASE}${endpoint}"
local timestamp=$(date +%s)
# Build HMAC signature: timestamp:METHOD:path:body
local sig_input="${timestamp}:${method}:${endpoint}:${body}"
local signature=""
if [[ -n "$secret_key" ]]; then
signature=$(echo -n "$sig_input" | openssl dgst -sha256 -hmac "$secret_key" | sed 's/^.* //')
fi
local response
if [[ -n "$signature" ]]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \
-H "Authorization: Bearer $public_key" \
-H "X-Timestamp: $timestamp" \
-H "X-Signature: $signature" \
-H "Content-Type: text/plain" \
-d "$body" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \
-H "Authorization: Bearer $public_key" \
-H "Content-Type: text/plain" \
-d "$body" 2>&1)
fi
local http_code=$(echo "$response" | tail -n1)
local resp_body=$(echo "$response" | head -n-1)
if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then
echo -e "${RED}Error: HTTP $http_code - $resp_body${RESET}" >&2
return 1
fi
echo "$resp_body"
}
# ============================================================================
# Environment Secrets Vault Functions
# ============================================================================
MAX_ENV_CONTENT_SIZE=65536 # 64KB max env vault size
service_env_status() {
local service_id="$1"
local api_key="${2:-${UNSANDBOX_API_KEY:-}}"
local result=$(api_request "/services/$service_id/env" "GET" "" "$api_key")
local has_vault=$(echo "$result" | jq -r '.has_vault // false')
if [[ "$has_vault" != "true" ]]; then
echo "Vault exists: no"
echo "Variable count: 0"
else
echo "Vault exists: yes"
local count=$(echo "$result" | jq -r '.count // 0')
echo "Variable count: $count"
local updated_at=$(echo "$result" | jq -r '.updated_at // ""')
if [[ -n "$updated_at" ]] && [[ "$updated_at" != "null" ]]; then
local dt=$(date -d "@$updated_at" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$updated_at" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$updated_at")
echo "Last updated: $dt"
fi
fi
}
service_env_set() {
local service_id="$1"
local env_content="$2"
local api_key="${3:-${UNSANDBOX_API_KEY:-}}"
if [[ -z "$env_content" ]]; then
echo -e "${RED}Error: No environment content provided${RESET}" >&2
return 1
fi
local content_size=${#env_content}
if [[ $content_size -gt $MAX_ENV_CONTENT_SIZE ]]; then
echo -e "${RED}Error: Environment content too large (max $MAX_ENV_CONTENT_SIZE bytes)${RESET}" >&2
return 1
fi
local result
result=$(api_request_text "/services/$service_id/env" "PUT" "$env_content" "$api_key")
if [[ $? -ne 0 ]]; then
return 1
fi
local count=$(echo "$result" | jq -r '.count // -1')
if [[ "$count" != "-1" ]]; then
local plural="s"
[[ "$count" == "1" ]] && plural=""
echo -e "${GREEN}Environment vault updated: $count variable$plural${RESET}"
else
echo -e "${GREEN}Environment vault updated${RESET}"
fi
local message=$(echo "$result" | jq -r '.message // ""')
[[ -n "$message" ]] && echo "$message"
return 0
}
service_env_export() {
local service_id="$1"
local api_key="${2:-${UNSANDBOX_API_KEY:-}}"
local result=$(api_request "/services/$service_id/env/export" "POST" "{}" "$api_key")
local env_content=$(echo "$result" | jq -r '.env // ""')
if [[ -n "$env_content" ]]; then
echo -n "$env_content"
[[ "${env_content: -1}" != $'\n' ]] && echo
fi
}
service_env_delete() {
local service_id="$1"
local api_key="${2:-${UNSANDBOX_API_KEY:-}}"
api_request "/services/$service_id/env" "DELETE" "" "$api_key" > /dev/null
echo -e "${GREEN}Environment vault deleted${RESET}"
}
read_env_file() {
local filepath="$1"
if [[ ! -f "$filepath" ]]; then
echo -e "${RED}Error: Env file not found: $filepath${RESET}" >&2
exit 1
fi
cat "$filepath"
}
build_env_content() {
local env_file="$1"
shift
local -a env_vars=("$@")
local parts=""
# Read from env file first
if [[ -n "$env_file" ]]; then
parts+=$(read_env_file "$env_file")
fi
# Add -e flags (these override/append to file)
for e in "${env_vars[@]}"; do
if [[ "$e" == *"="* ]]; then
[[ -n "$parts" ]] && parts+=$'\n'
parts+="$e"
fi
done
echo "$parts"
}
cmd_execute() { cmd_execute() {
local source_file="" local source_file=""
local -a env_vars=() local -a env_vars=()
@ -523,6 +694,71 @@ cmd_session() {
echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}" echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}"
} }
cmd_service_env() {
local action="$1"
local target="$2"
shift 2
local api_key="${UNSANDBOX_API_KEY:-}"
local env_file=""
local -a env_vars=()
# Parse remaining args for -e and --env-file
while [[ $# -gt 0 ]]; do
case "$1" in
-e)
env_vars+=("$2")
shift 2
;;
--env-file)
env_file="$2"
shift 2
;;
-k)
api_key="$2"
shift 2
;;
*)
shift
;;
esac
done
if [[ -z "$action" ]]; then
echo -e "${RED}Error: env action required (status, set, export, delete)${RESET}" >&2
exit 1
fi
if [[ -z "$target" ]]; then
echo -e "${RED}Error: Service ID required for env command${RESET}" >&2
exit 1
fi
case "$action" in
status)
service_env_status "$target" "$api_key"
;;
set)
local env_content=$(build_env_content "$env_file" "${env_vars[@]}")
if [[ -z "$env_content" ]]; then
echo -e "${RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin${RESET}" >&2
exit 1
fi
service_env_set "$target" "$env_content" "$api_key"
;;
export)
service_env_export "$target" "$api_key"
;;
delete)
service_env_delete "$target" "$api_key"
;;
*)
echo -e "${RED}Error: Unknown env action '$action'. Use: status, set, export, delete${RESET}" >&2
exit 1
;;
esac
}
cmd_service() { cmd_service() {
local name="" local name=""
local ports="" local ports=""
@ -543,6 +779,8 @@ cmd_service() {
local vcpu="" local vcpu=""
local api_key="${UNSANDBOX_API_KEY:-}" local api_key="${UNSANDBOX_API_KEY:-}"
local -a input_files=() local -a input_files=()
local -a env_vars=()
local env_file=""
local snapshot="" local snapshot=""
local restore="" local restore=""
local from_snapshot="" local from_snapshot=""
@ -551,6 +789,14 @@ cmd_service() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-e)
env_vars+=("$2")
shift 2
;;
--env-file)
env_file="$2"
shift 2
;;
--name) --name)
name="$2" name="$2"
shift 2 shift 2
@ -826,6 +1072,17 @@ cmd_service() {
echo -e "${GREEN}Service created: $service_id${RESET}" echo -e "${GREEN}Service created: $service_id${RESET}"
echo "Name: $service_name" echo "Name: $service_name"
[[ -n "$service_url" ]] && echo "URL: $service_url" [[ -n "$service_url" ]] && echo "URL: $service_url"
# Set environment vault if -e or --env-file provided
if [[ "$service_id" != "N/A" ]]; then
local env_content=$(build_env_content "$env_file" "${env_vars[@]}")
if [[ -n "$env_content" ]]; then
echo -e "${YELLOW}Setting environment vault...${RESET}" >&2
if ! service_env_set "$service_id" "$env_content" "$api_key"; then
echo -e "${YELLOW}Warning: Failed to set environment vault${RESET}" >&2
fi
fi
fi
return return
fi fi
@ -1198,7 +1455,13 @@ if [[ "$1" == "session" ]]; then
cmd_session "$@" cmd_session "$@"
elif [[ "$1" == "service" ]]; then elif [[ "$1" == "service" ]]; then
shift shift
cmd_service "$@" # Check for env subcommand: service env <action> <target>
if [[ "${1:-}" == "env" ]]; then
shift
cmd_service_env "$@"
else
cmd_service "$@"
fi
elif [[ "$1" == "snapshot" ]]; then elif [[ "$1" == "snapshot" ]]; then
shift shift
cmd_snapshot "$@" cmd_snapshot "$@"

213
un.tcl
View file

@ -168,6 +168,158 @@ proc api_request {endpoint method data public_key secret_key} {
return [::json::json2dict $body] return [::json::json2dict $body]
} }
proc api_request_text {endpoint method body public_key secret_key} {
set url "${::API_BASE}${endpoint}"
set headers [list Authorization "Bearer $public_key" Content-Type "text/plain"]
# Add HMAC signature if secret_key is present
if {$secret_key ne ""} {
set timestamp [clock seconds]
set sig_input "${timestamp}:${method}:${endpoint}:${body}"
set signature [::sha2::hmac -hex -key $secret_key $sig_input]
lappend headers X-Timestamp $timestamp
lappend headers X-Signature $signature
}
set token [::http::geturl $url -method $method -headers $headers -query $body -timeout 300000]
set status [::http::status $token]
set ncode [::http::ncode $token]
set response [::http::data $token]
::http::cleanup $token
return [list $ncode $response]
}
proc read_env_file {path} {
if {![file exists $path]} {
puts stderr "${::RED}Error: Env file not found: $path${::RESET}"
exit 1
}
set fp [open $path r]
set content [read $fp]
close $fp
return $content
}
proc build_env_content {envs env_file} {
set lines [list]
# Add from -e flags
foreach env $envs {
lappend lines $env
}
# Add from --env-file
if {$env_file ne ""} {
set content [read_env_file $env_file]
foreach line [split $content "\n"] {
set line [string trim $line]
if {$line ne "" && [string index $line 0] ne "#"} {
lappend lines $line
}
}
}
return [join $lines "\n"]
}
set MAX_ENV_CONTENT_SIZE 65536
proc service_env_status {service_id public_key secret_key} {
return [api_request "/services/$service_id/env" "GET" {} $public_key $secret_key]
}
proc service_env_set {service_id env_content public_key secret_key} {
if {[string length $env_content] > $::MAX_ENV_CONTENT_SIZE} {
puts stderr "${::RED}Error: Env content exceeds maximum size of 64KB${::RESET}"
return 0
}
lassign [api_request_text "/services/$service_id/env" "PUT" $env_content $public_key $secret_key] ncode response
if {$ncode == 200 || $ncode == 201} {
return 1
}
return 0
}
proc service_env_export {service_id public_key secret_key} {
return [api_request "/services/$service_id/env/export" "POST" {} $public_key $secret_key]
}
proc service_env_delete {service_id public_key secret_key} {
if {[catch {api_request "/services/$service_id/env" "DELETE" {} $public_key $secret_key}]} {
return 0
}
return 1
}
proc cmd_service_env {action target envs env_file public_key secret_key} {
switch -exact -- $action {
status {
if {$target eq ""} {
puts stderr "${::RED}Error: service env status requires service ID${::RESET}"
exit 1
}
set result [service_env_status $target $public_key $secret_key]
if {[dict exists $result has_vault] && [dict get $result has_vault]} {
puts "${::GREEN}Vault: configured${::RESET}"
if {[dict exists $result env_count]} {
puts "Variables: [dict get $result env_count]"
}
if {[dict exists $result updated_at]} {
puts "Updated: [dict get $result updated_at]"
}
} else {
puts "${::YELLOW}Vault: not configured${::RESET}"
}
}
set {
if {$target eq ""} {
puts stderr "${::RED}Error: service env set requires service ID${::RESET}"
exit 1
}
if {[llength $envs] == 0 && $env_file eq ""} {
puts stderr "${::RED}Error: service env set requires -e or --env-file${::RESET}"
exit 1
}
set env_content [build_env_content $envs $env_file]
if {[service_env_set $target $env_content $public_key $secret_key]} {
puts "${::GREEN}Vault updated for service $target${::RESET}"
} else {
puts stderr "${::RED}Error: Failed to update vault${::RESET}"
exit 1
}
}
export {
if {$target eq ""} {
puts stderr "${::RED}Error: service env export requires service ID${::RESET}"
exit 1
}
set result [service_env_export $target $public_key $secret_key]
if {[dict exists $result content]} {
puts -nonewline [dict get $result content]
}
}
delete {
if {$target eq ""} {
puts stderr "${::RED}Error: service env delete requires service ID${::RESET}"
exit 1
}
if {[service_env_delete $target $public_key $secret_key]} {
puts "${::GREEN}Vault deleted for service $target${::RESET}"
} else {
puts stderr "${::RED}Error: Failed to delete vault${::RESET}"
exit 1
}
}
default {
puts stderr "${::RED}Error: Unknown env action: $action${::RESET}"
puts stderr "Usage: un.tcl service env <status|set|export|delete> <service_id>"
exit 1
}
}
}
proc cmd_execute {args} { proc cmd_execute {args} {
lassign [get_api_keys] public_key secret_key lassign [get_api_keys] public_key secret_key
set source_file "" set source_file ""
@ -533,11 +685,32 @@ proc cmd_service {args} {
set network "" set network ""
set vcpu 0 set vcpu 0
set input_files [list] set input_files [list]
set envs [list]
set env_file ""
set env_action ""
set env_target ""
# Parse arguments # Parse arguments
for {set i 0} {$i < [llength $args]} {incr i} { for {set i 0} {$i < [llength $args]} {incr i} {
set arg [lindex $args $i] set arg [lindex $args $i]
switch -exact -- $arg { switch -exact -- $arg {
env {
# Parse: env <action> [target]
if {$i + 1 < [llength $args]} {
set next [lindex $args [expr {$i + 1}]]
if {[string index $next 0] ne "-"} {
incr i
set env_action $next
if {$i + 1 < [llength $args]} {
set next2 [lindex $args [expr {$i + 1}]]
if {[string index $next2 0] ne "-"} {
incr i
set env_target $next2
}
}
}
}
}
--list { --list {
set list_mode 1 set list_mode 1
} }
@ -601,9 +774,23 @@ proc cmd_service {args} {
incr i incr i
lappend input_files [lindex $args $i] lappend input_files [lindex $args $i]
} }
-e {
incr i
lappend envs [lindex $args $i]
}
--env-file {
incr i
set env_file [lindex $args $i]
}
} }
} }
# Handle env subcommand
if {$env_action ne ""} {
cmd_service_env $env_action $env_target $envs $env_file $public_key $secret_key
return
}
if {$list_mode} { if {$list_mode} {
set result [api_request "/services" "GET" {} $public_key $secret_key] set result [api_request "/services" "GET" {} $public_key $secret_key]
set services [dict get $result services] set services [dict get $result services]
@ -740,11 +927,24 @@ proc cmd_service {args} {
} }
set result [api_request "/services" "POST" $payload $public_key $secret_key] set result [api_request "/services" "POST" $payload $public_key $secret_key]
puts "${::GREEN}Service created: [dict get $result id]${::RESET}" set service_id [dict get $result id]
puts "${::GREEN}Service created: $service_id${::RESET}"
puts "Name: [dict get $result name]" puts "Name: [dict get $result name]"
if {[dict exists $result url]} { if {[dict exists $result url]} {
puts "URL: [dict get $result url]" puts "URL: [dict get $result url]"
} }
# Auto-set vault if env vars were provided
if {[llength $envs] > 0 || $env_file ne ""} {
set env_content [build_env_content $envs $env_file]
if {$env_content ne ""} {
if {[service_env_set $service_id $env_content $public_key $secret_key]} {
puts "${::GREEN}Vault configured with environment variables${::RESET}"
} else {
puts stderr "${::YELLOW}Warning: Failed to set vault${::RESET}"
}
}
}
return return
} }
@ -757,7 +957,18 @@ proc main {argv} {
puts stderr "Usage: un.tcl \[options\] <source_file>" puts stderr "Usage: un.tcl \[options\] <source_file>"
puts stderr " un.tcl session \[options\]" puts stderr " un.tcl session \[options\]"
puts stderr " un.tcl service \[options\]" puts stderr " un.tcl service \[options\]"
puts stderr " un.tcl service env <action> <service_id> \[options\]"
puts stderr " un.tcl key \[--extend\]" puts stderr " un.tcl key \[--extend\]"
puts stderr ""
puts stderr "Service env commands:"
puts stderr " env status ID Check vault status"
puts stderr " env set ID Set vault (use -e or --env-file)"
puts stderr " env export ID Export vault contents"
puts stderr " env delete ID Delete vault"
puts stderr ""
puts stderr "Service vault options:"
puts stderr " -e KEY=VALUE Set vault env var (with --name or env set)"
puts stderr " --env-file FILE Load vault vars from file"
exit 1 exit 1
} }

206
un.ts
View file

@ -125,6 +125,9 @@ interface Args {
clonePorts: string | null; clonePorts: string | null;
dumpBootstrap: string | null; dumpBootstrap: string | null;
dumpFile: string | null; dumpFile: string | null;
envFile: string | null;
envAction: string | null;
envTarget: string | null;
} }
interface ApiKeys { interface ApiKeys {
@ -277,6 +280,175 @@ function portalRequest(endpoint: string, method: string = "GET", data: any = nul
}); });
} }
function apiRequestText(endpoint: string, method: string, body: string, keys: ApiKeys): Promise<any> {
return new Promise((resolve, reject) => {
const url = new URL(API_BASE + endpoint);
const timestamp = Math.floor(Date.now() / 1000).toString();
const message = `${timestamp}:${method}:${url.pathname}:${body}`;
const signature = crypto.createHmac('sha256', keys.secretKey).update(message).digest('hex');
const options: https.RequestOptions = {
hostname: url.hostname,
path: url.pathname,
method: method,
headers: {
'Authorization': `Bearer ${keys.publicKey}`,
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'text/plain'
},
timeout: 300000
};
const req = https.request(options, (res) => {
let responseBody = '';
res.on('data', chunk => responseBody += chunk);
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(responseBody));
} catch (e) {
resolve({ error: responseBody });
}
} else {
resolve({ error: `HTTP ${res.statusCode} - ${responseBody}` });
}
});
});
req.on('error', (e) => {
resolve({ error: e.message });
});
req.write(body);
req.end();
});
}
// ============================================================================
// Environment Secrets Vault Functions
// ============================================================================
const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max
async function serviceEnvStatus(serviceId: string, keys: ApiKeys): Promise<void> {
const result = await apiRequest(`/services/${serviceId}/env`, "GET", null, keys);
const hasVault = result.has_vault;
if (!hasVault) {
console.log("Vault exists: no");
console.log("Variable count: 0");
} else {
console.log("Vault exists: yes");
console.log(`Variable count: ${result.count || 0}`);
if (result.updated_at) {
const date = new Date(result.updated_at * 1000);
console.log(`Last updated: ${date.toISOString().replace('T', ' ').split('.')[0]}`);
}
}
}
async function serviceEnvSet(serviceId: string, envContent: string, keys: ApiKeys): Promise<boolean> {
if (!envContent) {
console.error(`${RED}Error: No environment content provided${RESET}`);
return false;
}
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
console.error(`${RED}Error: Environment content too large (max ${MAX_ENV_CONTENT_SIZE} bytes)${RESET}`);
return false;
}
const result = await apiRequestText(`/services/${serviceId}/env`, "PUT", envContent, keys);
if (result.error) {
console.error(`${RED}Error: ${result.error}${RESET}`);
return false;
}
const count = result.count || 0;
const plural = count === 1 ? '' : 's';
console.log(`${GREEN}Environment vault updated: ${count} variable${plural}${RESET}`);
if (result.message) console.log(result.message);
return true;
}
async function serviceEnvExport(serviceId: string, keys: ApiKeys): Promise<void> {
const result = await apiRequest(`/services/${serviceId}/env/export`, "POST", {}, keys);
const envContent = result.env || '';
if (envContent) {
process.stdout.write(envContent);
if (!envContent.endsWith('\n')) console.log();
}
}
async function serviceEnvDelete(serviceId: string, keys: ApiKeys): Promise<void> {
await apiRequest(`/services/${serviceId}/env`, "DELETE", null, keys);
console.log(`${GREEN}Environment vault deleted${RESET}`);
}
function readEnvFile(filepath: string): string {
try {
return fs.readFileSync(filepath, 'utf-8');
} catch (e) {
console.error(`${RED}Error: Env file not found: ${filepath}${RESET}`);
process.exit(1);
}
}
function buildEnvContent(envs: string[], envFile: string | null): string {
const parts: string[] = [];
// Read from env file first
if (envFile) {
parts.push(readEnvFile(envFile));
}
// Add -e flags
envs.forEach(e => {
if (e.includes('=')) {
parts.push(e);
}
});
return parts.join('\n');
}
async function cmdServiceEnv(action: string, target: string, envs: string[], envFile: string | null, keys: ApiKeys): Promise<void> {
if (!action) {
console.error(`${RED}Error: env action required (status, set, export, delete)${RESET}`);
process.exit(1);
}
if (!target) {
console.error(`${RED}Error: Service ID required for env command${RESET}`);
process.exit(1);
}
switch (action) {
case 'status':
await serviceEnvStatus(target, keys);
break;
case 'set':
const envContent = buildEnvContent(envs, envFile);
if (!envContent) {
console.error(`${RED}Error: No env content provided. Use -e KEY=VAL or --env-file${RESET}`);
process.exit(1);
}
await serviceEnvSet(target, envContent, keys);
break;
case 'export':
await serviceEnvExport(target, keys);
break;
case 'delete':
await serviceEnvDelete(target, keys);
break;
default:
console.error(`${RED}Error: Unknown env action '${action}'. Use: status, set, export, delete${RESET}`);
process.exit(1);
}
}
async function cmdExecute(args: Args): Promise<void> { async function cmdExecute(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.apiKey);
@ -524,9 +696,16 @@ async function cmdService(args: Args): Promise<void> {
if (args.vcpu) payload.vcpu = args.vcpu; if (args.vcpu) payload.vcpu = args.vcpu;
const result = await apiRequest("/services", "POST", payload, keys); const result = await apiRequest("/services", "POST", payload, keys);
console.log(`${GREEN}Service created: ${result.id || 'N/A'}${RESET}`); const serviceId = result.id;
console.log(`${GREEN}Service created: ${serviceId || 'N/A'}${RESET}`);
console.log(`Name: ${result.name || 'N/A'}`); console.log(`Name: ${result.name || 'N/A'}`);
if (result.url) console.log(`URL: ${result.url}`); if (result.url) console.log(`URL: ${result.url}`);
// Auto-set vault if -e or --env-file provided
const envContent = buildEnvContent(args.env || [], args.envFile);
if (envContent && serviceId) {
await serviceEnvSet(serviceId, envContent, keys);
}
return; return;
} }
@ -639,6 +818,9 @@ function parseArgs(argv: string[]): Args {
dumpBootstrap: null, dumpBootstrap: null,
dumpFile: null, dumpFile: null,
extend: false, extend: false,
envFile: null,
envAction: null,
envTarget: null,
}; };
let i = 2; let i = 2;
@ -708,6 +890,20 @@ function parseArgs(argv: string[]): Args {
} else if (arg === '--bootstrap-file' && i + 1 < argv.length) { } else if (arg === '--bootstrap-file' && i + 1 < argv.length) {
args.bootstrapFile = argv[++i]; args.bootstrapFile = argv[++i];
i++; i++;
} else if (arg === '--env-file' && i + 1 < argv.length) {
args.envFile = argv[++i];
i++;
} else if (arg === 'env') {
// Handle "service env <action> <target>" subcommand
if (args.command === 'service') {
if (i + 1 < argv.length) {
args.envAction = argv[++i];
}
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
args.envTarget = argv[++i];
}
}
i++;
} else if (arg === '--info' && i + 1 < argv.length) { } else if (arg === '--info' && i + 1 < argv.length) {
args.info = argv[++i]; args.info = argv[++i];
i++; i++;
@ -759,7 +955,13 @@ async function main(): Promise<void> {
if (args.command === 'session') { if (args.command === 'session') {
await cmdSession(args); await cmdSession(args);
} else if (args.command === 'service') { } else if (args.command === 'service') {
await cmdService(args); // Check for "service env" subcommand
if (args.envAction) {
const keys = getApiKeys(args.apiKey);
await cmdServiceEnv(args.envAction, args.envTarget!, args.env, args.envFile, keys);
} else {
await cmdService(args);
}
} else if (args.command === 'key') { } else if (args.command === 'key') {
await cmdKey(args); await cmdKey(args);
} else if (args.sourceFile) { } else if (args.sourceFile) {

146
un.v
View file

@ -47,6 +47,7 @@ import os
const api_base = 'https://api.unsandbox.com' const api_base = 'https://api.unsandbox.com'
const portal_base = 'https://unsandbox.com' const portal_base = 'https://unsandbox.com'
const max_env_content_size = 65536
const blue = '\x1b[34m' const blue = '\x1b[34m'
const red = '\x1b[31m' const red = '\x1b[31m'
const green = '\x1b[32m' const green = '\x1b[32m'
@ -156,6 +157,97 @@ fn extract_json_string(json string, key string) string {
return '' return ''
} }
fn read_env_file(filename string) string {
content := os.read_file(filename) or {
eprintln('${red}Error: Cannot read env file: ${filename}${reset}')
return ''
}
return content
}
fn build_env_content(envs []string, env_file string) string {
mut result := ''
// Add -e flags
for env in envs {
result += env + '\n'
}
// Add content from env file
if env_file != '' {
file_content := read_env_file(env_file)
for line in file_content.split('\n') {
trimmed := line.trim_space()
if trimmed.len == 0 || trimmed.starts_with('#') {
continue
}
result += trimmed + '\n'
}
}
return result
}
fn exec_curl_put(endpoint string, body string, public_key string, secret_key string) bool {
// Write body to temp file to avoid shell escaping issues
body_file := '/tmp/unsandbox_env_body.txt'
os.write_file(body_file, body) or {
eprintln('${red}Error: Cannot write temp file${reset}')
return false
}
defer {
os.rm(body_file) or {}
}
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:PUT:${endpoint}:${body}\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X PUT '${api_base}${endpoint}' -H 'Content-Type: text/plain' -H 'Authorization: Bearer ${public_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" --data-binary @${body_file}"
result := os.execute(cmd)
return result.exit_code == 0
}
fn service_env_set(service_id string, content string, public_key string, secret_key string) bool {
endpoint := '/services/${service_id}/env'
return exec_curl_put(endpoint, content, public_key, secret_key)
}
fn cmd_service_env(action string, target string, svc_envs []string, svc_env_file string, api_key string) {
pub_key := get_public_key()
secret_key := get_secret_key()
match action {
'status' {
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:/services/${target}/env:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/services/${target}/env' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\""
println(exec_curl(cmd))
}
'set' {
if svc_envs.len == 0 && svc_env_file == '' {
eprintln('${red}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE${reset}')
return
}
content := build_env_content(svc_envs, svc_env_file)
if content.len > max_env_content_size {
eprintln('${red}Error: Environment content exceeds 64KB limit${reset}')
return
}
if service_env_set(target, content, pub_key, secret_key) {
println('${green}Vault updated for service ${target}${reset}')
}
}
'export' {
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services/${target}/env/export:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${target}/env/export' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\""
println(exec_curl(cmd))
}
'delete' {
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:DELETE:/services/${target}/env:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X DELETE '${api_base}/services/${target}/env' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\""
exec_curl(cmd)
println('${green}Vault deleted for service ${target}${reset}')
}
else {
eprintln('${red}Error: Unknown env action: ${action}${reset}')
eprintln('Usage: un service env <status|set|export|delete> <service_id>')
}
}
}
fn cmd_key(extend bool, api_key string) { fn cmd_key(extend bool, api_key string) {
pub_key := get_public_key() pub_key := get_public_key()
secret_key := get_secret_key() secret_key := get_secret_key()
@ -318,7 +410,7 @@ fn cmd_session(list bool, kill string, shell string, network string, vcpu int, t
println(exec_curl(cmd)) println(exec_curl(cmd))
} }
fn cmd_service(name string, ports string, service_type string, bootstrap string, bootstrap_file string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, input_files []string, api_key string) { fn cmd_service(name string, ports string, service_type string, bootstrap string, bootstrap_file string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, input_files []string, svc_envs []string, svc_env_file string, api_key string) {
pub_key := get_public_key() pub_key := get_public_key()
secret_key := get_secret_key() secret_key := get_secret_key()
@ -442,7 +534,21 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string,
println('${yellow}Creating service...${reset}') println('${yellow}Creating service...${reset}')
cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\""
println(exec_curl(cmd)) result := exec_curl(cmd)
println(result)
// Auto-set vault if -e or --env-file provided
if svc_envs.len > 0 || svc_env_file != '' {
service_id := extract_json_string(result, 'service_id')
if service_id != '' {
env_content := build_env_content(svc_envs, svc_env_file)
if env_content.len > 0 {
if service_env_set(service_id, env_content, pub_key, secret_key) {
println('${green}Vault configured for service ${service_id}${reset}')
}
}
}
}
return return
} }
@ -482,7 +588,14 @@ fn main() {
eprintln('Usage: ${os.args[0]} [options] <source_file>') eprintln('Usage: ${os.args[0]} [options] <source_file>')
eprintln(' ${os.args[0]} session [options]') eprintln(' ${os.args[0]} session [options]')
eprintln(' ${os.args[0]} service [options]') eprintln(' ${os.args[0]} service [options]')
eprintln(' ${os.args[0]} service env <action> <service_id> [options]')
eprintln(' ${os.args[0]} key [--extend]') eprintln(' ${os.args[0]} key [--extend]')
eprintln('')
eprintln('Vault commands:')
eprintln(' service env status <id> Check vault status')
eprintln(' service env set <id> Set vault (-e KEY=VAL or --env-file FILE)')
eprintln(' service env export <id> Export vault contents')
eprintln(' service env delete <id> Delete vault')
exit(1) exit(1)
} }
@ -561,10 +674,23 @@ fn main() {
mut network := '' mut network := ''
mut vcpu := 0 mut vcpu := 0
mut input_files := []string{} mut input_files := []string{}
mut svc_envs := []string{}
mut svc_env_file := ''
mut env_action := ''
mut env_target := ''
mut i := 2 mut i := 2
for i < os.args.len { for i < os.args.len {
match os.args[i] { match os.args[i] {
'env' {
// service env <action> <service_id>
if i + 2 < os.args.len {
i++
env_action = os.args[i]
i++
env_target = os.args[i]
}
}
'--name' { '--name' {
i++ i++
name = os.args[i] name = os.args[i]
@ -626,6 +752,14 @@ fn main() {
i++ i++
dump_file = os.args[i] dump_file = os.args[i]
} }
'-e' {
i++
svc_envs << os.args[i]
}
'--env-file' {
i++
svc_env_file = os.args[i]
}
'-n' { '-n' {
i++ i++
network = os.args[i] network = os.args[i]
@ -653,8 +787,14 @@ fn main() {
i++ i++
} }
// Handle env subcommand
if env_action != '' && env_target != '' {
cmd_service_env(env_action, env_target, svc_envs, svc_env_file, api_key)
return
}
cmd_service(name, ports, service_type, bootstrap, bootstrap_file, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, cmd_service(name, ports, service_type, bootstrap, bootstrap_file, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network,
vcpu, input_files, api_key) vcpu, input_files, svc_envs, svc_env_file, api_key)
return return
} }

212
un.zig
View file

@ -54,6 +54,11 @@ const time = std.time;
const API_BASE = "https://api.unsandbox.com"; const API_BASE = "https://api.unsandbox.com";
const PORTAL_BASE = "https://unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com";
const MAX_ENV_CONTENT_SIZE: usize = 65536;
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const YELLOW = "\x1b[33m";
const RESET = "\x1b[0m";
fn computeHmacCmd(allocator: std.mem.Allocator, secret_key: []const u8, message: []const u8) ![]const u8 { fn computeHmacCmd(allocator: std.mem.Allocator, secret_key: []const u8, message: []const u8) ![]const u8 {
return try std.fmt.allocPrint(allocator, "echo -n '{s}' | openssl dgst -sha256 -hmac '{s}' -hex 2>/dev/null | sed 's/.*= //'", .{ message, secret_key }); return try std.fmt.allocPrint(allocator, "echo -n '{s}' | openssl dgst -sha256 -hmac '{s}' -hex 2>/dev/null | sed 's/.*= //'", .{ message, secret_key });
@ -116,6 +121,136 @@ fn base64EncodeFile(allocator: std.mem.Allocator, filename: []const u8) ![]u8 {
return try allocator.dupe(u8, trimmed); return try allocator.dupe(u8, trimmed);
} }
fn readEnvFile(allocator: std.mem.Allocator, filename: []const u8) ![]u8 {
const content = fs.cwd().readFileAlloc(allocator, filename, MAX_ENV_CONTENT_SIZE) catch |err| {
std.debug.print("{s}Error: Cannot read env file: {s} ({s}){s}\n", .{ RED, filename, @errorName(err), RESET });
return try allocator.dupe(u8, "");
};
return content;
}
fn buildEnvContent(allocator: std.mem.Allocator, envs: std.ArrayList([]const u8), env_file: ?[]const u8) ![]u8 {
var list = std.ArrayList(u8).init(allocator);
errdefer list.deinit();
// Add environment variables from -e flags
for (envs.items) |env| {
try list.appendSlice(env);
try list.append('\n');
}
// Add content from env file
if (env_file) |ef| {
const file_content = try readEnvFile(allocator, ef);
defer allocator.free(file_content);
// Process line by line, skip comments and empty lines
var lines = mem.splitScalar(u8, file_content, '\n');
while (lines.next()) |line| {
const trimmed = mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
if (trimmed[0] == '#') continue;
try list.appendSlice(trimmed);
try list.append('\n');
}
}
return list.toOwnedSlice();
}
fn extractJsonField(json: []const u8, field: []const u8) ?[]const u8 {
// Build search pattern: "field":"
var pattern_buf: [256]u8 = undefined;
const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\":\"", .{field}) catch return null;
if (mem.indexOf(u8, json, pattern)) |start_idx| {
const value_start = start_idx + pattern.len;
if (mem.indexOfPos(u8, json, value_start, "\"")) |end_idx| {
return json[value_start..end_idx];
}
}
return null;
}
fn execCurlPut(allocator: std.mem.Allocator, endpoint: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8) !bool {
const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ API_BASE, endpoint });
defer allocator.free(url);
const auth_headers = try buildAuthCmd(allocator, "PUT", endpoint, body, public_key, secret_key);
defer allocator.free(auth_headers);
// Write body to temp file to avoid shell escaping issues
const body_file = "/tmp/unsandbox_env_body.txt";
const file = try fs.cwd().createFile(body_file, .{});
try file.writeAll(body);
file.close();
defer fs.cwd().deleteFile(body_file) catch {};
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X PUT '{s}' -H 'Content-Type: text/plain' {s} --data-binary @{s}", .{ url, auth_headers, body_file });
defer allocator.free(cmd);
const ret = std.c.system(cmd.ptr);
return ret == 0;
}
fn cmdServiceEnv(allocator: std.mem.Allocator, action: []const u8, target: []const u8, envs: std.ArrayList([]const u8), env_file: ?[]const u8, public_key: []const u8, secret_key: []const u8) !void {
if (mem.eql(u8, action, "status")) {
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target});
defer allocator.free(path);
const auth_headers = try buildAuthCmd(allocator, "GET", path, "", public_key, secret_key);
defer allocator.free(auth_headers);
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}{s}' {s}", .{ API_BASE, path, auth_headers });
defer allocator.free(cmd);
_ = std.c.system(cmd.ptr);
std.debug.print("\n", .{});
} else if (mem.eql(u8, action, "set")) {
if (envs.items.len == 0 and env_file == null) {
std.debug.print("{s}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE{s}\n", .{ RED, RESET });
return;
}
const content = try buildEnvContent(allocator, envs, env_file);
defer allocator.free(content);
if (content.len > MAX_ENV_CONTENT_SIZE) {
std.debug.print("{s}Error: Environment content exceeds 64KB limit{s}\n", .{ RED, RESET });
return;
}
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target});
defer allocator.free(path);
_ = try execCurlPut(allocator, path, content, public_key, secret_key);
std.debug.print("\n{s}Vault updated for service {s}{s}\n", .{ GREEN, target, RESET });
} else if (mem.eql(u8, action, "export")) {
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env/export", .{target});
defer allocator.free(path);
const auth_headers = try buildAuthCmd(allocator, "POST", path, "", public_key, secret_key);
defer allocator.free(auth_headers);
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}{s}' {s}", .{ API_BASE, path, auth_headers });
defer allocator.free(cmd);
_ = std.c.system(cmd.ptr);
std.debug.print("\n", .{});
} else if (mem.eql(u8, action, "delete")) {
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target});
defer allocator.free(path);
const auth_headers = try buildAuthCmd(allocator, "DELETE", path, "", public_key, secret_key);
defer allocator.free(auth_headers);
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X DELETE '{s}{s}' {s}", .{ API_BASE, path, auth_headers });
defer allocator.free(cmd);
_ = std.c.system(cmd.ptr);
std.debug.print("\n{s}Vault deleted for service {s}{s}\n", .{ GREEN, target, RESET });
} else {
std.debug.print("{s}Error: Unknown env action: {s}{s}\n", .{ RED, action, RESET });
std.debug.print("Usage: un service env <status|set|export|delete> <service_id>\n", .{});
}
}
fn serviceEnvSet(allocator: std.mem.Allocator, service_id: []const u8, content: []const u8, public_key: []const u8, secret_key: []const u8) !bool {
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{service_id});
defer allocator.free(path);
return try execCurlPut(allocator, path, content, public_key, secret_key);
}
fn buildInputFilesJson(allocator: std.mem.Allocator, files: std.ArrayList([]const u8)) ![]u8 { fn buildInputFilesJson(allocator: std.mem.Allocator, files: std.ArrayList([]const u8)) ![]u8 {
if (files.items.len == 0) { if (files.items.len == 0) {
return try allocator.dupe(u8, ""); return try allocator.dupe(u8, "");
@ -162,7 +297,13 @@ pub fn main() !u8 {
std.debug.print("Usage: {s} [options] <source_file>\n", .{args[0]}); std.debug.print("Usage: {s} [options] <source_file>\n", .{args[0]});
std.debug.print(" {s} session [options]\n", .{args[0]}); std.debug.print(" {s} session [options]\n", .{args[0]});
std.debug.print(" {s} service [options]\n", .{args[0]}); std.debug.print(" {s} service [options]\n", .{args[0]});
std.debug.print(" {s} service env <action> <service_id> [options]\n", .{args[0]});
std.debug.print(" {s} key [--extend]\n", .{args[0]}); std.debug.print(" {s} key [--extend]\n", .{args[0]});
std.debug.print("\nVault commands:\n", .{});
std.debug.print(" service env status <id> Check vault status\n", .{});
std.debug.print(" service env set <id> Set vault (-e KEY=VAL or --env-file FILE)\n", .{});
std.debug.print(" service env export <id> Export vault contents\n", .{});
std.debug.print(" service env delete <id> Delete vault\n", .{});
return 1; return 1;
} }
@ -258,10 +399,21 @@ pub fn main() !u8 {
var dump_file: ?[]const u8 = null; var dump_file: ?[]const u8 = null;
var input_files = std.ArrayList([]const u8).init(allocator); var input_files = std.ArrayList([]const u8).init(allocator);
defer input_files.deinit(); defer input_files.deinit();
var svc_envs = std.ArrayList([]const u8).init(allocator);
defer svc_envs.deinit();
var svc_env_file: ?[]const u8 = null;
var env_action: ?[]const u8 = null;
var env_target: ?[]const u8 = null;
var i: usize = 2; var i: usize = 2;
while (i < args.len) : (i += 1) { while (i < args.len) : (i += 1) {
if (mem.eql(u8, args[i], "--list")) { if (mem.eql(u8, args[i], "--list")) {
list = true; list = true;
} else if (mem.eql(u8, args[i], "env") and i + 2 < args.len) {
// service env <action> <service_id>
i += 1;
env_action = args[i];
i += 1;
env_target = args[i];
} else if (mem.eql(u8, args[i], "--name") and i + 1 < args.len) { } else if (mem.eql(u8, args[i], "--name") and i + 1 < args.len) {
i += 1; i += 1;
name = args[i]; name = args[i];
@ -292,6 +444,12 @@ pub fn main() !u8 {
} else if (mem.eql(u8, args[i], "--dump-file") and i + 1 < args.len) { } else if (mem.eql(u8, args[i], "--dump-file") and i + 1 < args.len) {
i += 1; i += 1;
dump_file = args[i]; dump_file = args[i];
} else if (mem.eql(u8, args[i], "-e") and i + 1 < args.len) {
i += 1;
try svc_envs.append(args[i]);
} else if (mem.eql(u8, args[i], "--env-file") and i + 1 < args.len) {
i += 1;
svc_env_file = args[i];
} else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) { } else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) {
i += 1; i += 1;
allocator.free(public_key); allocator.free(public_key);
@ -308,6 +466,14 @@ pub fn main() !u8 {
} }
} }
// Handle env subcommand
if (env_action) |action| {
if (env_target) |target| {
try cmdServiceEnv(allocator, action, target, svc_envs, svc_env_file, public_key, secret_key);
return 0;
}
}
if (list) { if (list) {
const auth_headers = try buildAuthCmd(allocator, "GET", "/services", "", public_key, secret_key); const auth_headers = try buildAuthCmd(allocator, "GET", "/services", "", public_key, secret_key);
defer allocator.free(auth_headers); defer allocator.free(auth_headers);
@ -441,11 +607,47 @@ pub fn main() !u8 {
const auth_headers = try buildAuthCmd(allocator, "POST", "/services", json_str, public_key, secret_key); const auth_headers = try buildAuthCmd(allocator, "POST", "/services", json_str, public_key, secret_key);
defer allocator.free(auth_headers); defer allocator.free(auth_headers);
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, auth_headers, json_str });
defer allocator.free(cmd); // Check if we need auto-vault
std.debug.print("\x1b[33mCreating service...\x1b[0m\n", .{}); const has_env = svc_envs.items.len > 0 or svc_env_file != null;
_ = std.c.system(cmd.ptr);
std.debug.print("\n", .{}); if (has_env) {
// Capture response to temp file to extract service_id
const response_file = "/tmp/unsandbox_service_create.json";
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}' -o {s}", .{ API_BASE, auth_headers, json_str, response_file });
defer allocator.free(cmd);
std.debug.print("{s}Creating service...{s}\n", .{ YELLOW, RESET });
_ = std.c.system(cmd.ptr);
// Read response
const response_content = fs.cwd().readFileAlloc(allocator, response_file, 1024 * 1024) catch {
std.debug.print("{s}Error: Failed to read service creation response{s}\n", .{ RED, RESET });
return 1;
};
defer allocator.free(response_content);
fs.cwd().deleteFile(response_file) catch {};
// Print the response
std.debug.print("{s}\n", .{response_content});
// Extract service_id and auto-set vault
if (extractJsonField(response_content, "service_id")) |service_id| {
const env_content = try buildEnvContent(allocator, svc_envs, svc_env_file);
defer allocator.free(env_content);
if (env_content.len > 0) {
if (try serviceEnvSet(allocator, service_id, env_content, public_key, secret_key)) {
std.debug.print("\n{s}Vault configured for service {s}{s}\n", .{ GREEN, service_id, RESET });
}
}
}
} else {
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, auth_headers, json_str });
defer allocator.free(cmd);
std.debug.print("{s}Creating service...{s}\n", .{ YELLOW, RESET });
_ = std.c.system(cmd.ptr);
std.debug.print("\n", .{});
}
} }
return 0; return 0;
} }