diff --git a/Un.cs b/Un.cs index 5b42108..6722469 100644 --- a/Un.cs +++ b/Un.cs @@ -338,6 +338,13 @@ class Un { var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + // Handle env subcommand + if (!string.IsNullOrEmpty(args.EnvAction)) + { + CmdServiceEnv(args, publicKey, secretKey); + return; + } + if (args.ServiceList) { var result = ApiRequest("/services", "GET", null, publicKey, secretKey); @@ -496,12 +503,30 @@ class Un } 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"]}"); if (result.ContainsKey("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; } @@ -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 envs, string envFile) + { + var lines = new List(); + + // 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 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 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 "); + Environment.Exit(1); + } + } + static string ToJson(object obj) { if (obj == null) return "null"; @@ -884,6 +1145,9 @@ class Un public string ServiceCommand = null; public string ServiceDumpBootstrap = null; public string ServiceDumpFile = null; + public string EnvFile = null; + public string EnvAction = null; + public string EnvTarget = null; public bool KeyExtend = false; } @@ -896,10 +1160,23 @@ class Un if (arg == "session") result.Command = "session"; else if (arg == "service") result.Command = "service"; else if (arg == "key") result.Command = "key"; + else if (arg == "env" && result.Command == "service") + { + // Parse: service env + 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 == "-n" || arg == "--network") result.Network = 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 == "--env-file") result.EnvFile = 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 == "-o" || arg == "--output-dir") result.OutputDir = args[++i]; @@ -935,6 +1212,7 @@ class Un Console.WriteLine(@"Usage: Un [options] Un session [options] Un service [options] + Un service env [options] Un key [options] Execute options: @@ -960,13 +1238,21 @@ Service options: --info ID Get service details --logs ID Get all logs --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service + --freeze ID Freeze service + --unfreeze ID Unfreeze service --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script --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: --extend Open browser to extend expired key"); diff --git a/Un.java b/Un.java index f2cc54a..7febdcd 100644 --- a/Un.java +++ b/Un.java @@ -234,6 +234,12 @@ public class Un { String publicKey = keys[0]; String secretKey = keys[1]; + // Handle service env subcommand + if (args.envAction != null) { + cmdServiceEnv(args, publicKey, secretKey); + return; + } + if (args.serviceList) { Map result = apiRequest("/services", "GET", null, publicKey, secretKey); @SuppressWarnings("unchecked") @@ -374,11 +380,25 @@ public class Un { } Map 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")); if (result.containsKey("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; } @@ -599,6 +619,141 @@ public class Un { 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 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 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 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 " + RESET); + System.exit(1); + } + + if (action.equals("status")) { + if (target == null) { + System.err.println(RED + "Error: Usage: service env status " + RESET); + System.exit(1); + } + Map 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 [-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 " + RESET); + System.exit(1); + } + Map 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 " + 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 { if (is == null) return ""; StringBuilder sb = new StringBuilder(); @@ -810,6 +965,11 @@ public class Un { String serviceDumpBootstrap = null; String serviceDumpFile = null; + // Vault args + String envFile = null; + String envAction = null; + String envTarget = null; + // Key args boolean keyExtend = false; } @@ -873,6 +1033,16 @@ public class Un { result.serviceDumpBootstrap = args[++i]; } else if (arg.equals("--dump-file")) { 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 + 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")) { result.keyExtend = true; } else if (!arg.startsWith("-")) { @@ -923,6 +1093,13 @@ public class Un { 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(); + 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(" --extend Open browser to extend key"); } diff --git a/un.awk b/un.awk index f2b3ed6..d6a9841 100644 --- a/un.awk +++ b/un.awk @@ -409,7 +409,7 @@ function session_create(shell, network, vcpu, input_files , json, tmp, timest 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() # Build JSON payload @@ -495,6 +495,12 @@ function service_create(name, ports, domains, service_type, bootstrap, bootstrap # Clean up 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 } @@ -833,6 +839,141 @@ function service_restore(snapshot_id , endpoint, json, tmp, timestamp, sig_he 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() { print "Usage: awk -f un.awk " 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 key [--extend]" 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 --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 --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 --info ID" print " awk -f un.awk snapshot --delete ID" @@ -867,12 +1012,20 @@ function show_help() { print " --bootstrap CMD Bootstrap command or script" print " --dump-bootstrap ID Dump bootstrap script from service" 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 " --snapshot SERVICE_ID Create snapshot of service" print " --restore SNAPSHOT_ID Restore from snapshot ID" print " --snapshot-name N Name for snapshot" print " --hot Take snapshot without freezing (live snapshot)" 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 " -l, --list List all snapshots" print " --info ID Get snapshot details" @@ -1013,6 +1166,50 @@ END { } else if (ARGC >= 4 && ARGV[2] == "--restore") { # --restore takes snapshot ID directly service_restore(ARGV[3]) + } else if (ARGC >= 4 && ARGV[2] == "env") { + # Service vault commands: service env [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 [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 " > "/dev/stderr" + exit 1 + } } else if (ARGV[2] == "--create") { # Parse service creation arguments name = "" @@ -1022,6 +1219,8 @@ END { bootstrap = "" bootstrap_file = "" input_files = "" + env_vars = "" + env_file = "" i = 3 while (i < ARGC) { @@ -1047,6 +1246,13 @@ END { if (input_files != "") input_files = input_files "," input_files = input_files ARGV[i + 1] 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 { i++ } @@ -1058,6 +1264,16 @@ END { } 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 { print "Usage: awk -f un.awk service --list|--create|--destroy ID" } diff --git a/un.clj b/un.clj index 7844681..6434cac 100644 --- a/un.clj +++ b/un.clj @@ -202,6 +202,121 @@ (check-clock-drift-error 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 ")) + (System/exit 1)))) + (defn curl-portal-post [api-key endpoint json-data] (let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json") [public-key secret-key] (get-api-keys) @@ -265,9 +380,10 @@ (println (str yellow "Session created (WebSocket required)" reset)) (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)] (case action + :env (service-env-command sid name envs env-file) :list (println (curl-get api-key "/services")) :info (println (curl-get api-key (str "/services/" sid))) :logs (println (curl-get api-key (str "/services/" sid "/logs"))) @@ -315,9 +431,18 @@ network-json (if network (str ",\"network\":\"" network "\"") "") vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "") 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 (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?] (let [response (curl-portal-post api-key "/keys/validate" "{}") @@ -388,33 +513,36 @@ service-bootstrap-file nil service-type nil service-input-files [] + service-envs [] + service-env-file nil key-extend false mode :execute] (cond (empty? args) (case mode :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) :execute (if file (execute-command file env-vars artifacts out-dir network vcpu) (do (println "Usage: un.clj [options] ") (println " un.clj session [options]") (println " un.clj service [options]") + (println " un.clj service env ") (println " un.clj key [options]") (System/exit 1)))) (= (first args) "session") (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") (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") (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 (and (= mode :key) (= (first args) "--extend")) @@ -424,107 +552,122 @@ ;; Session options (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 - 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")) (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"))) (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")) (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 (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 - :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")) (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")) (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")) (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")) (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")) (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")) (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) "-"))) (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")) (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")) (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")) (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")) (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")) (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")) (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")) (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 (= (first args) "-e") (let [[k v] (str/split (second args) #"=" 2)] (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") (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") (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") (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") (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 (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 - 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 (and (= mode :session) (.startsWith (first args) "-")) @@ -536,6 +679,6 @@ :else (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*) diff --git a/un.cob b/un.cob index f040da1..56d08d4 100644 --- a/un.cob +++ b/un.cob @@ -80,6 +80,10 @@ 01 WS-PORTAL-BASE PIC X(256) VALUE "https://unsandbox.com". 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. MAIN-PROCEDURE. @@ -177,12 +181,26 @@ END-IF. HANDLE-SERVICE. - * Get API key - ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY". - IF WS-API-KEY = SPACES - DISPLAY "Error: UNSANDBOX_API_KEY not set" UPON SYSERR - MOVE 1 TO RETURN-CODE - STOP RUN + * Get API keys (try new format first, fall back to old) + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY". + IF WS-PUBLIC-KEY NOT = SPACES + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY" + IF WS-SECRET-KEY = SPACES + 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. * Initialize service parameters @@ -193,12 +211,21 @@ MOVE SPACES TO WS-BOOTSTRAP. MOVE SPACES TO WS-BOOTSTRAP-FILE. 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 ACCEPT WS-ARG2 FROM ARGUMENT-VALUE. IF WS-ARG2 = "-l" OR WS-ARG2 = "--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" ACCEPT WS-ID FROM ARGUMENT-VALUE PERFORM SERVICE-INFO @@ -223,7 +250,8 @@ PERFORM SERVICE-CREATE ELSE 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 END-IF. @@ -558,6 +586,18 @@ ACCEPT WS-BOOTSTRAP FROM ARGUMENT-VALUE ELSE IF WS-ARG3 = "--bootstrap-file" 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" ACCEPT WS-ARG3 FROM ARGUMENT-VALUE IF WS-INPUT-FILES NOT = SPACES @@ -572,23 +612,160 @@ ACCEPT WS-ARG3 FROM ARGUMENT-VALUE 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 " + " " 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. - * Build JSON payload for service creation - * Start with base payload containing 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) - """" + * Build service creation with HMAC auth and auto-vault + STRING "BODY='{\"name\":\"" FUNCTION TRIM(WS-NAME) "\"" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. * Add ports if provided IF WS-PORTS NOT = SPACES STRING FUNCTION TRIM(WS-CURL-CMD) - ",""ports"":[" FUNCTION TRIM(WS-PORTS) "]" + ",\"ports\":[" FUNCTION TRIM(WS-PORTS) "]" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING END-IF. @@ -596,9 +773,7 @@ * Add domains if provided IF WS-DOMAINS NOT = SPACES STRING FUNCTION TRIM(WS-CURL-CMD) - ",""domains"":[""" - FUNCTION TRIM(WS-DOMAINS) - """]" + ",\"domains\":[\"" FUNCTION TRIM(WS-DOMAINS) "\"]" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING END-IF. @@ -606,9 +781,7 @@ * Add service_type if provided IF WS-SERVICE-TYPE NOT = SPACES STRING FUNCTION TRIM(WS-CURL-CMD) - ",""service_type"":""" - FUNCTION TRIM(WS-SERVICE-TYPE) - """" + ",\"service_type\":\"" FUNCTION TRIM(WS-SERVICE-TYPE) "\"" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING END-IF. @@ -616,53 +789,57 @@ * Add bootstrap if provided IF WS-BOOTSTRAP NOT = SPACES STRING FUNCTION TRIM(WS-CURL-CMD) - ",""bootstrap"":""" - FUNCTION TRIM(WS-BOOTSTRAP) - """" + ",\"bootstrap\":\"" FUNCTION TRIM(WS-BOOTSTRAP) "\"" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING END-IF. - * Add bootstrap_content from file if provided - IF WS-BOOTSTRAP-FILE NOT = SPACES - STRING FUNCTION TRIM(WS-CURL-CMD) - "' | jq --rawfile b '" - FUNCTION TRIM(WS-BOOTSTRAP-FILE) - "' '. + {bootstrap_content: $b}' | tr -d '\\n' | curl " - "-s -X POST https://api.unsandbox.com/services " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) - "' -d @- | 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 body + STRING FUNCTION TRIM(WS-CURL-CMD) "}'; " + "TS=$(date +%s); " + "SIG=$(echo -n \"$TS:POST:/services:$BODY\" | " + "openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP=$(curl -s -X POST https://api.unsandbox.com/services " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG " + "-d \"$BODY\"); " + "SVC_ID=$(echo \"$RESP\" | jq -r '.id // empty'); " + "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) - 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 + * Add auto-vault logic 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 END-STRING. diff --git a/un.cpp b/un.cpp index 04dae73..83aafb7 100644 --- a/un.cpp +++ b/un.cpp @@ -174,6 +174,140 @@ string build_auth_headers(const string& method, const string& path, const string "-H 'X-Signature: " + signature + "'"; } +string build_env_content(const vector& 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& 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 " << 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 [-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 " << 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 " << 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& envs, const vector& files, bool artifacts, const string& network, int vcpu, const string& public_key, const string& secret_key) { string lang = detect_language(source_file); 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; } -void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector& 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& 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& 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) { string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key); 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' " + auth_headers + " " "-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; } @@ -633,6 +792,8 @@ int main(int argc, char* argv[]) { string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network; int vcpu = 0; vector files; + vector envs; + string env_file, env_action, env_target; for (int i = 2; i < argc; 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-file" && i+1 < argc) bootstrap_file = 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 + 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 == "--info" && i+1 < argc) info = 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]; } - 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; } diff --git a/un.cr b/un.cr index 885c08e..b3b9e1b 100644 --- a/un.cr +++ b/un.cr @@ -69,6 +69,7 @@ RESET = "\033[0m" API_BASE = "https://api.unsandbox.com" PORTAL_BASE = "https://unsandbox.com" +MAX_ENV_CONTENT_SIZE = 65536 def detect_language(filename : String) : String ext = File.extname(filename).downcase @@ -145,6 +146,123 @@ def api_request(endpoint : String, public_key : String, secret_key : String?, me 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 " + exit 1 + end +end + def cmd_execute(args) public_key, secret_key = get_api_keys(args[:api_key]?) @@ -389,6 +507,14 @@ def cmd_key(args) end 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]?) if args[:list]?.as?(Bool) @@ -541,6 +667,20 @@ def cmd_service(args) if url = result["url"]?.try(&.as_s?) puts "URL: #{url}" 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 end @@ -574,15 +714,22 @@ def main service_type: nil, bootstrap: 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) parser = OptionParser.new do |opts| - opts.banner = "Usage: un.cr [options] \n un.cr session [options]\n un.cr service [options]\n un.cr key [options]" + opts.banner = "Usage: un.cr [options] \n un.cr session [options]\n un.cr service [options]\n un.cr service env [options]\n un.cr key [options]\n\nService env commands:\n env status Show vault status\n env set Set vault (-e KEY=VALUE or --env-file FILE)\n env export Export vault contents\n env delete Delete vault" 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("-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("-a", "--artifacts", "Return artifacts") { args[:artifacts] = true } 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("--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("--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.unknown_args do |before, after| @@ -612,6 +760,25 @@ def main args[:command] = "session" when "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" args[:command] = "key" else diff --git a/un.d b/un.d index 7031f42..3f15bff 100644 --- a/un.d +++ b/un.d @@ -60,6 +60,7 @@ immutable string RED = "\033[31m"; immutable string GREEN = "\033[32m"; immutable string YELLOW = "\033[33m"; immutable string RESET = "\033[0m"; +immutable size_t MAX_ENV_CONTENT_SIZE = 65536; string detectLanguage(string filename) { string[string] langMap = [ @@ -164,6 +165,151 @@ string execCurl(string cmd) { 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 "); + exit(1); +} + void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string publicKey, string secretKey) { string lang = detectLanguage(sourceFile); if (lang.empty) { @@ -229,7 +375,13 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu, 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) { string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey); 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); 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); - 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; } @@ -527,7 +694,14 @@ int main(string[] args) { stderr.writefln("Usage: %s [options] ", args[0]); stderr.writefln(" %s session [options]", args[0]); stderr.writefln(" %s service [options]", args[0]); + stderr.writefln(" %s service env [options]", args[0]); stderr.writefln(" %s key [options]", args[0]); + stderr.writeln(""); + stderr.writeln("Service env commands:"); + stderr.writeln(" env status Show vault status"); + stderr.writeln(" env set Set vault (-e KEY=VALUE or --env-file FILE)"); + stderr.writeln(" env export Export vault contents"); + stderr.writeln(" env delete Delete vault"); return 1; } @@ -560,6 +734,22 @@ int main(string[] args) { string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network; int vcpu = 0; 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++) { 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] == "-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] == "-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, 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; } diff --git a/un.dart b/un.dart index ddb8739..05bad2e 100644 --- a/un.dart +++ b/un.dart @@ -98,6 +98,9 @@ class Args { String? serviceDumpBootstrap; String? serviceDumpFile; bool keyExtend = false; + String? envFile; + String? envAction; + String? envTarget; } List getApiKeys(String? argsKey) { @@ -192,6 +195,180 @@ Future> apiRequestCurl(String endpoint, String method, Stri } } +Future?> 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; + } catch (e) { + return {'success': true}; + } + } + return {'success': true}; + } + } + return null; + } finally { + await tempFile.delete(); + } +} + +const int maxEnvContentSize = 65536; + +Future 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 buildEnvContent(List envs, String? envFile) async { + final lines = []; + 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> serviceEnvStatus(String serviceId, String publicKey, String? secretKey) async { + return await apiRequestCurl('/services/$serviceId/env', 'GET', null, publicKey, secretKey); +} + +Future 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> serviceEnvExport(String serviceId, String publicKey, String? secretKey) async { + return await apiRequestCurl('/services/$serviceId/env/export', 'POST', '{}', publicKey, secretKey); +} + +Future 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 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 '); + exit(1); + } +} + Future cmdExecute(Args args) async { final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; @@ -335,6 +512,12 @@ Future cmdService(Args args) async { final publicKey = keys[0]!; final secretKey = keys[1]; + // Handle env subcommand + if (args.envAction != null) { + await cmdServiceEnv(args); + return; + } + if (args.serviceList) { final result = await apiRequestCurl('/services', 'GET', null, publicKey, secretKey); final services = result['services'] as List? ?? []; @@ -480,11 +663,24 @@ Future cmdService(Args args) async { } 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'}'); if (result.containsKey('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; } @@ -655,6 +851,17 @@ Args parseArgs(List argv) { case '--extend': args.keyExtend = true; 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: if (argv[i].startsWith('-')) { stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}'); @@ -695,17 +902,25 @@ Service options: --ports PORTS Comma-separated ports --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --bootstrap CMD Bootstrap command + -e KEY=VALUE Environment variable for vault + --env-file FILE Load vault variables from file --info ID Get service details --logs ID Get all logs --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service + --freeze ID Freeze service + --unfreeze ID Unfreeze service --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script --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: --extend Open browser to extend key '''); diff --git a/un.erl b/un.erl index 846a322..519b9e8 100755 --- a/un.erl +++ b/un.erl @@ -277,6 +277,32 @@ service_command(["--restore", SnapshotId | _Rest]) -> io:format("\033[32mService restored from snapshot\033[0m~n"), io:format("~s~n", [Response]); +%% Service env vault subcommand: service env [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 [options]~n", []), + halt(1); + service_command(Args) -> case get_service_name(Args) of undefined -> @@ -289,6 +315,8 @@ service_command(Args) -> BootstrapFile = get_service_bootstrap_file(Args), Type = get_service_type(Args), InputFiles = get_input_files(Args), + EnvVars = get_env_vars(Args), + EnvFile = get_env_file(Args), PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" @@ -319,7 +347,20 @@ service_command(Args) -> Response = curl_post(ApiKey, "/services", TmpFile), file:delete(TmpFile), 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. %% Snapshot command @@ -647,6 +688,64 @@ curl_delete(ApiKey, Endpoint) -> check_clock_drift_error(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 parse_exec_args([], Opts) -> {maps:get(file, Opts), Opts}; @@ -691,6 +790,16 @@ has_extend_flag([]) -> false; has_extend_flag(["--extend" | _]) -> true; 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) extract_json_field(Json, Field) -> Pattern = "\"" ++ Field ++ "\":\"", diff --git a/un.ex b/un.ex index e650cb7..126c716 100755 --- a/un.ex +++ b/un.ex @@ -86,8 +86,12 @@ defmodule Un do IO.puts("Usage: un.ex [options] ") IO.puts(" un.ex session [options]") IO.puts(" un.ex service [options]") + IO.puts(" un.ex service env ") IO.puts(" un.ex snapshot [options]") 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) end @@ -293,6 +297,56 @@ defmodule Un do 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 #{@reset}") + System.halt(1) + end + defp service_command(args) do name = get_opt(args, "--name", nil, nil) @@ -309,6 +363,8 @@ defmodule Un do vcpu = get_opt(args, "-v", nil, nil) service_type = get_opt(args, "--type", nil, nil) 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: "" bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" @@ -331,6 +387,19 @@ defmodule Un do response = curl_post(api_key, "/services", json) IO.puts("#{@green}Service created#{@reset}") 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 # Snapshot command @@ -681,6 +750,78 @@ defmodule Un do output 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 parse_exec_args(args, nil, %{}) end diff --git a/un.f90 b/un.f90 index a8635a3..69ad5e0 100644 --- a/un.f90 +++ b/un.f90 @@ -317,7 +317,8 @@ contains subroutine handle_service() character(len=8192) :: full_cmd 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 logical :: list_mode @@ -327,20 +328,49 @@ contains service_type = '' service_name = '' input_files = '' + svc_envs = '' + svc_env_file = '' + env_action = '' + env_target = '' ! Parse service arguments - do i = 2, command_argument_count() + i = 2 + do while (i <= command_argument_count()) call get_command_argument(i, arg) if (trim(arg) == '-l' .or. trim(arg) == '--list') then list_mode = .true. + else if (trim(arg) == 'env') then + ! service env + 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 operation = 'create' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_name) + i = i + 1 end if else if (trim(arg) == '--type') then if (i+1 <= command_argument_count()) then 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 else if (trim(arg) == '-f') then if (i+1 <= command_argument_count()) then @@ -350,101 +380,221 @@ contains else input_files = trim(arg) end if + i = i + 1 end if else if (trim(arg) == '--info') then operation = 'info' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_id) + i = i + 1 end if else if (trim(arg) == '--logs') then operation = 'logs' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_id) + i = i + 1 end if else if (trim(arg) == '--freeze') then operation = 'sleep' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_id) + i = i + 1 end if else if (trim(arg) == '--unfreeze') then operation = 'wake' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_id) + i = i + 1 end if else if (trim(arg) == '--destroy') then operation = 'destroy' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_id) + i = i + 1 end if else if (trim(arg) == '--dump-bootstrap') then operation = 'dump-bootstrap' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_id) + i = i + 1 end if else if (trim(arg) == '--dump-file') then operation = 'dump-file' if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_type) + i = i + 1 end if end if + i = i + 1 end do - ! Get API key - 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_API_KEY not set' - stop 1 + ! Get API keys (try new format first, fall back to old) + call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat) + if (stat == 0 .and. len_trim(public_key) > 0) then + call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat) + 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 ' + stop 1 + end if end if if (list_mode) then ! 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 ', & - '-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)"'' ', & '2>/dev/null || echo "No services"' call execute_command_line(trim(full_cmd), wait=.true.) 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/', & 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.) 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/', & 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.) 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/', & 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"' call execute_command_line(trim(full_cmd), wait=.true.) 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/', & 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"' call execute_command_line(trim(full_cmd), wait=.true.) 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/', & 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"' call execute_command_line(trim(full_cmd), wait=.true.) 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; ', & + '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/', & trim(service_id), '/execute ', & '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(api_key), '" ', & - '-d ''{"command":"cat /tmp/bootstrap.sh"}''); ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-d "$BODY"); ', & 'STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); ', & 'if [ -n "$STDOUT" ]; 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' call execute_command_line(trim(full_cmd), wait=.true.) 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 - write(full_cmd, '(30A)') & + write(full_cmd, '(60A)') & 'INPUT_FILES=""; ', & 'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', & 'for f in "${FILES[@]}"; do ', & @@ -465,24 +615,78 @@ contains 'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', & 'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', & '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 "Authorization: Bearer ', trim(api_key), '" ', & - '-d ''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}'' | ', & - 'jq -r ''.id + " created"'' && ', & - 'echo -e "\x1b[32mService created\x1b[0m"' + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-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 - write(full_cmd, '(15A)') & - 'curl -s -X POST https://api.unsandbox.com/services ', & + write(full_cmd, '(60A)') & + '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 "Authorization: Bearer ', trim(api_key), '" ', & - '-d ''{"name":"', trim(service_name), '"}'' | ', & - 'jq -r ''.id + " created"'' && ', & - 'echo -e "\x1b[32mService created\x1b[0m"' + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-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 call execute_command_line(trim(full_cmd), wait=.true.) 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 end if end subroutine handle_service diff --git a/un.forth b/un.forth index 131d9ba..c75e81e 100644 --- a/un.forth +++ b/un.forth @@ -356,6 +356,112 @@ 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-id-addr service-id-len file-addr file-len -- ) 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 ; -\ 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 ( -- ) get-api-key \ Parse arguments (simplified - in real implementation would iterate through args) @@ -414,6 +520,7 @@ get-secret-key r@ write-file throw s" '" 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" while [ $i -lt $# ]; do" 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" --bootstrap) ((i++)); BOOTSTRAP=${!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" if [ -f \"$FILE\" ]; then" r@ write-line throw s" BASENAME=$(basename \"$FILE\")" r@ write-line throw @@ -440,6 +550,13 @@ s" esac" r@ write-line throw s" ((i++))" 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" 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 @@ -456,7 +573,19 @@ s" TIMESTAMP=$(date +%s)" 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" 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 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) then + \ Handle env subcommand: service env [options] + 2dup s" env" compare 0= if + 2drop + argc @ 4 < if + s" Usage: un.forth service env [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 - 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) ; diff --git a/un.fs b/un.fs index 7e029d0..9e15545 100644 --- a/un.fs +++ b/un.fs @@ -118,6 +118,9 @@ type Args = { mutable SnapshotName: string option mutable SnapshotShell: string option mutable SnapshotPorts: string option + mutable EnvFile: string option + mutable EnvAction: string option + mutable EnvTarget: string option mutable KeyExtend: bool } @@ -311,6 +314,164 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op 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) (envFile: string option) = + let lines = ResizeArray() + + // 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 " + exit 1 + | None -> + eprintfn "%sError: env action required%s" red reset + exit 1 + let cmdExecute (args: Args) = let (publicKey, secretKey) = getApiKeys args.ApiKey let code = File.ReadAllText(args.SourceFile.Value) @@ -531,7 +692,10 @@ let cmdSnapshot (args: Args) = let cmdService (args: Args) = 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 = [] if args.ServiceSnapshotName.IsSome then payload <- payload @ [("name", box args.ServiceSnapshotName.Value)] @@ -630,8 +794,9 @@ let cmdService (args: Args) = payload <- payload @ [("vcpu", box args.Vcpu)] let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey - match result.TryFind "id" with - | Some id -> printfn "%sService created: %s%s" green (id.ToString()) reset + let serviceId = match result.TryFind "id" with | Some id -> Some (id.ToString()) | None -> None + match serviceId with + | Some id -> printfn "%sService created: %s%s" green id reset | None -> printfn "%sService created%s" green reset match result.TryFind "name" with | Some name -> printfn "Name: %s" (name.ToString()) @@ -639,6 +804,17 @@ let cmdService (args: Args) = match result.TryFind "url" with | Some url -> printfn "URL: %s" (url.ToString()) | 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 eprintfn "%sError: Specify --name to create a service, or use --list, --info, etc.%s" red reset exit 1 @@ -691,6 +867,9 @@ let parseArgs (argv: string[]) = SnapshotName = None SnapshotShell = None SnapshotPorts = None + EnvFile = None + EnvAction = None + EnvTarget = None KeyExtend = false } @@ -701,10 +880,19 @@ let parseArgs (argv: string[]) = | "service" -> args.Command <- Some "service" | "snapshot" -> args.Command <- Some "snapshot" | "key" -> args.Command <- Some "key" + | "env" when args.Command = Some "service" -> + // Parse: service env + 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] | "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i] | "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int 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]) | "-a" | "--artifacts" -> args.Artifacts <- true | "-o" | "--output-dir" -> i <- i + 1; args.OutputDir <- Some argv.[i] @@ -800,6 +988,7 @@ let printHelp () = printfn "Usage: un [options] " printfn " un session [options]" printfn " un service [options]" + printfn " un service env [options]" printfn " un key [options]" printfn "" printfn "Execute options:" @@ -825,13 +1014,21 @@ let printHelp () = printfn " --info ID Get service details" printfn " --logs ID Get all logs" printfn " --tail ID Get last 9000 lines" - printfn " --freeze ID Freeze service" - printfn " --unfreeze ID Unfreeze service" + printfn " --freeze ID Freeze service" + printfn " --unfreeze ID Unfreeze service" printfn " --destroy ID Destroy service" printfn " --execute ID Execute command in service" printfn " --command CMD Command to execute (with --execute)" printfn " --dump-bootstrap ID Dump bootstrap script" 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 "Key options:" printfn " --extend Open browser to extend key" diff --git a/un.go b/un.go index 2249ab4..bd638d7 100644 --- a/un.go +++ b/un.go @@ -231,6 +231,183 @@ func apiRequest(endpoint, method string, data map[string]interface{}, publicKey, 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) { code, err := os.ReadFile(sourceFile) 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) } -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 != "" { payload := map[string]interface{}{} if serviceSnapshotName != "" { @@ -602,11 +779,18 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB } 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"]) if url, ok := result["url"]; ok { 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 } @@ -863,6 +1047,9 @@ func main() { serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script") var serviceFiles inputFiles 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") serviceInfo := serviceCmd.String("info", "", "Get service info") serviceLogs := serviceCmd.String("logs", "", "Get service logs") @@ -919,6 +1106,33 @@ func main() { return case "service": + // Check for "service env" subcommand + if len(os.Args) > 2 && os.Args[2] == "env" { + // Parse env subcommand: service env [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:]) publicKey, secretKey := getAPIKeys(*serviceKey) net := *serviceNetwork @@ -929,7 +1143,7 @@ func main() { if vc == 0 { 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 case "snapshot": diff --git a/un.groovy b/un.groovy index 15fdfd1..c9098e8 100644 --- a/un.groovy +++ b/un.groovy @@ -55,6 +55,7 @@ def EXT_MAP = [ def API_BASE = 'https://api.unsandbox.com' def PORTAL_BASE = 'https://unsandbox.com' +def MAX_ENV_CONTENT_SIZE = 65536 def BLUE = '\033[34m' def RED = '\033[31m' def GREEN = '\033[32m' @@ -109,6 +110,10 @@ class Args { String snapshotShell = null String snapshotPorts = null Boolean keyExtend = false + List svcEnvs = [] + String svcEnvFile = null + String envAction = null + String envTarget = null } 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 ") + } +} + def cmdExecute(args) { def (publicKey, secretKey) = getApiKeys(args.apiKey) def file = new File(args.sourceFile) @@ -703,8 +819,10 @@ def cmdService(args) { def output = apiRequest('/services', 'POST', json, publicKey, secretKey) def idMatch = output =~ /"id":"([^"]+)"/ + def serviceId = null 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":"([^"]+)"/ if (nameMatch.find()) { @@ -714,6 +832,16 @@ def cmdService(args) { if (urlMatch.find()) { 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 } @@ -732,6 +860,13 @@ def parseArgs(argv) { case 'service': args.command = 'service' break + case 'env': + // service env + if (args.command == 'service' && i + 2 < argv.size()) { + args.envAction = argv[++i] + args.envTarget = argv[++i] + } + break case 'snapshot': args.command = 'snapshot' break @@ -752,7 +887,14 @@ def parseArgs(argv) { break case '-e': 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 case '-f': case '--files': @@ -877,6 +1019,7 @@ def printHelp() { println '''Usage: groovy un.groovy [options] groovy un.groovy session [options] groovy un.groovy service [options] + groovy un.groovy service env [options] groovy un.groovy key [options] Execute options: @@ -899,6 +1042,8 @@ Service options: --ports PORTS Comma-separated ports --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --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 --logs ID Get all logs --tail ID Get last 9000 lines @@ -910,6 +1055,12 @@ Service options: --dump-bootstrap ID Dump bootstrap script --dump-file FILE File to save bootstrap (with --dump-bootstrap) +Vault commands: + service env status Check vault status + service env set Set vault (-e KEY=VAL or --env-file FILE) + service env export Export vault contents + service env delete Delete vault + Key options: --extend Open browser to extend key -k KEY API key to validate @@ -923,7 +1074,12 @@ try { if (args.command == 'session') { cmdSession(args) } 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') { cmdSnapshot(args) } else if (args.command == 'key') { diff --git a/un.hs b/un.hs index 70d5fc4..27b0590 100644 --- a/un.hs +++ b/un.hs @@ -155,12 +155,15 @@ data ServiceOpts = ServiceOpts , svcSnapshotName :: Maybe String , svcSnapshotFrom :: Maybe String , svcHot :: Bool + , svcEnvs :: [(String, String)] + , svcEnvFile :: Maybe String } data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String | ServiceSleep String | ServiceWake String | ServiceDestroy String | ServiceExecute String String | ServiceDumpBootstrap String (Maybe String) | ServiceCreate | ServiceSnapshot String | ServiceRestore String + | ServiceEnv String (Maybe String) -- action, target data SnapshotOpts = SnapshotOpts { snapAction :: SnapshotAction @@ -229,7 +232,7 @@ parseSession args = return $ parseSessionArgs args defaultSessionOpts parseService :: [String] -> IO ServiceOpts parseService args = return $ parseServiceArgs args defaultServiceOpts 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 ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList } 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 ("--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 ("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 ("--ports":p:rest) opts = parseServiceArgs rest opts { svcPorts = Just p } 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 ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) } 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 parseExecute :: [String] -> IO Command @@ -298,6 +307,7 @@ printHelp = do putStrLn " un.hs [options] Execute code" putStrLn " un.hs session [options] Manage sessions" putStrLn " un.hs service [options] Manage services" + putStrLn " un.hs service env Manage service vault" putStrLn " un.hs snapshot [options] Manage snapshots" putStrLn " un.hs key [options] Validate/extend API key" putStrLn "" @@ -309,6 +319,16 @@ printHelp = do putStrLn " -n MODE Network mode (zerotrust|semitrusted)" putStrLn " -v N vCPU count (1-8)" 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 " --snapshot ID Create snapshot of session" putStrLn " --restore ID Restore session from snapshot" @@ -483,6 +503,66 @@ serviceCommand opts = do (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}" putStrLn $ green ++ "Service restored from snapshot" ++ reset 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 " + exitFailure ServiceCreate -> do case svcName opts of Nothing -> do @@ -515,6 +595,18 @@ serviceCommand opts = do putStrLn $ green ++ "Service created" ++ reset 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 checkClockDriftError :: String -> IO () checkClockDriftError response = do @@ -575,6 +667,71 @@ curlDelete apiKey url = do checkClockDriftError stdout 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 getApiKeys :: IO (String, Maybe String) getApiKeys = do diff --git a/un.jl b/un.jl index 3e581de..520153b 100755 --- a/un.jl +++ b/un.jl @@ -159,6 +159,147 @@ function api_request(endpoint::String, public_key::String, secret_key::String; m 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 ") + exit(1) + end +end + function cmd_execute(args) (public_key, secret_key) = get_api_keys(args["api-key"]) @@ -311,6 +452,12 @@ end function cmd_service(args) (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"] result = api_request("/services", public_key, secret_key) services = get(result, "services", []) @@ -449,11 +596,26 @@ function cmd_service(args) end 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"))") if haskey(result, "url") println("URL: $(result["url"])") 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 end @@ -674,6 +836,11 @@ function main() "--files", "-f" help = "Add input file" 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" help = "Network mode" arg_type = String @@ -699,6 +866,30 @@ function main() help = "Dump bootstrap script from service" "--dump-file" 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" help = "API key" end @@ -716,7 +907,18 @@ function main() if args["%COMMAND%"] == "session" cmd_session(args["session"]) 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" cmd_key(args["key"]) elseif args["source_file"] !== nothing diff --git a/un.js b/un.js index d58257d..a926c92 100644 --- a/un.js +++ b/un.js @@ -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) { return new Promise((resolve, reject) => { const url = new URL(PORTAL_BASE + endpoint); @@ -456,6 +607,43 @@ async function cmdSession(args) { async function cmdService(args) { const { publicKey, secretKey } = getApiKeys(args.apiKey); + // Handle env subcommand: un.js service env + 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) { const result = await apiRequest("/services", "GET", null, publicKey, secretKey); const services = result.services || []; @@ -578,9 +766,21 @@ async function cmdService(args) { if (args.vcpu) payload.vcpu = args.vcpu; 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'}`); 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; } @@ -622,6 +822,10 @@ function parseArgs(argv) { dumpFile: null, extend: false, execShell: null, + envFile: null, + envSubcommand: null, + envAction: null, + envTarget: null, }; let i = 2; @@ -631,6 +835,19 @@ function parseArgs(argv) { if (arg === 'session' || arg === 'service' || arg === 'key') { args.command = arg; i++; + // Check for env subcommand: service env + 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) { args.env.push(argv[++i]); i++; @@ -726,6 +943,9 @@ function parseArgs(argv) { } else if (arg === '--dump-file' && i + 1 < argv.length) { args.dumpFile = argv[++i]; i++; + } else if (arg === '--env-file' && i + 1 < argv.length) { + args.envFile = argv[++i]; + i++; } else if (arg === '--extend') { args.extend = true; i++; diff --git a/un.kt b/un.kt index 607af87..7269c77 100644 --- a/un.kt +++ b/un.kt @@ -101,7 +101,10 @@ data class Args( var serviceCommand: String? = null, var serviceDumpBootstrap: 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) { @@ -264,6 +267,12 @@ fun cmdSession(args: Args) { fun cmdService(args: Args) { val (publicKey, secretKey) = getApiKeys(args.apiKey) + // Handle env subcommand + if (args.envAction != null) { + cmdServiceEnv(args) + return + } + if (args.serviceList) { val result = apiRequest("/services", "GET", null, publicKey, secretKey) @Suppress("UNCHECKED_CAST") @@ -412,11 +421,24 @@ fun cmdService(args: Args) { } 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"}") if (result.containsKey("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 } @@ -587,6 +609,153 @@ fun apiRequest(endpoint: String, method: String, data: Map?, public return parseJson(response) } +fun apiRequestText(endpoint: String, method: String, body: String, publicKey: String?, secretKey: String): Pair { + 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, envFile: String?): String { + val lines = mutableListOf() + 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 { + 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 { + 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 ") + exitProcess(1) + } + } +} + fun toJson(obj: Any?): String = when (obj) { null -> "null" is String -> "\"${obj.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")}\"" @@ -748,6 +917,15 @@ fun parseArgs(args: Array): Args { "--dump-bootstrap" -> result.serviceDumpBootstrap = args[++i] "--dump-file" -> result.serviceDumpFile = args[++i] "--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 -> { if (args[i].startsWith("-")) { System.err.println("${RED}Unknown option: ${args[i]}${RESET}") @@ -789,17 +967,25 @@ Service options: --ports PORTS Comma-separated ports --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --bootstrap CMD Bootstrap command + -e KEY=VALUE Environment variable for vault + --env-file FILE Load vault variables from file --info ID Get service details --logs ID Get all logs --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service + --freeze ID Freeze service + --unfreeze ID Unfreeze service --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script --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: --extend Open browser to extend key """.trimIndent()) diff --git a/un.lisp b/un.lisp index f81e268..a39a538 100644 --- a/un.lisp +++ b/un.lisp @@ -186,6 +186,52 @@ response)) (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 () (let ((public-key (uiop:getenv "UNSANDBOX_PUBLIC_KEY")) (secret-key (uiop:getenv "UNSANDBOX_SECRET_KEY")) @@ -252,7 +298,7 @@ (format t "~aSession created (WebSocket required)~a~%" *yellow* *reset*) (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))) (cond ((string= action "list") @@ -294,6 +340,21 @@ (progn (format *error-output* "~aError: Failed to fetch bootstrap (service not running or no bootstrap file)~a~%" *red* *reset*) (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) (let* ((ports-json (if ports (format nil ",\"ports\":[~a]" ports) "")) (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) "")) (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)) - (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 "~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 - (format t "Error: --name required to create service~%") + (format t "Error: --name required to create service, or use env subcommand~%") (uiop:quit 1))))) (defun parse-json-field (json field) @@ -444,23 +511,53 @@ ((string= (first args) "service") (cond ((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")) - (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")) - (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")) - (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")) - (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")) - (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")) - (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")) - (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")) - (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 [options] + ((and (> (length args) 1) (string= (second args) "env")) + (if (< (length args) 4) + (progn + (format *error-output* "Usage: un.lisp service env [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")) (let* ((name (third args)) (rest-args (nthcdr 3 args)) @@ -468,6 +565,8 @@ (bootstrap nil) (bootstrap-file nil) (service-type nil) + (env-vars nil) + (env-file nil) (input-files (parse-input-files rest-args))) (loop for i from 0 below (1- (length rest-args)) do (let ((opt (nth i rest-args)) @@ -476,8 +575,10 @@ ((string= opt "--ports") (setf ports val)) ((string= opt "--bootstrap") (setf bootstrap val)) ((string= opt "--bootstrap-file") (setf bootstrap-file val)) - ((string= opt "--type") (setf service-type val))))) - (service-cmd "create" nil name ports bootstrap bootstrap-file service-type input-files))) + ((string= opt "--type") (setf service-type val)) + ((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 (format t "Error: Invalid service command~%") (uiop:quit 1)))) diff --git a/un.lua b/un.lua index a41a0a8..c0c8178 100644 --- a/un.lua +++ b/un.lua @@ -201,6 +201,178 @@ local function api_request(endpoint, method, data, keys) return json.decode(response) 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 file, err = io.open(filename, "rb") if not file then @@ -648,9 +820,16 @@ local function cmd_service(options) if options.vcpu then payload.vcpu = options.vcpu end 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")) 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 end @@ -693,7 +872,10 @@ local function main() dump_bootstrap = nil, dump_file = nil, extend = false, - exec_shell = nil + exec_shell = nil, + env_file = nil, + env_action = nil, + env_target = nil } local i = 1 @@ -762,6 +944,23 @@ local function main() elseif a == "--bootstrap-file" then i = i + 1 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 " 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 i = i + 1 options.info = arg[i] @@ -807,7 +1006,13 @@ local function main() if options.command == "session" then cmd_session(options) 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 cmd_key(options) elseif options.source_file then diff --git a/un.m b/un.m index 73174dd..42e0f1f 100644 --- a/un.m +++ b/un.m @@ -203,6 +203,93 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat 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) { NSString* publicKey, *secretKey; getApiKeys(&publicKey, &secretKey); @@ -586,12 +673,54 @@ void cmdService(NSArray* args) { NSString* network = nil; int vcpu = 0; NSMutableArray* inputFiles = [NSMutableArray array]; + NSMutableArray* envVars = [NSMutableArray array]; + NSString* envFile = nil; NSString* snapshotId = nil; NSString* restoreId = nil; NSString* fromSnapshot = nil; NSString* snapshotName = nil; 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 [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 for (NSUInteger i = 0; i < [args count]; i++) { NSString* arg = args[i]; @@ -633,6 +762,10 @@ void cmdService(NSArray* args) { bootstrapFile = args[++i]; } else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) { [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]) { network = args[++i]; } else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) { @@ -815,10 +948,17 @@ void cmdService(NSArray* args) { if (result[@"url"]) { 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; } - 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]); exit(1); } diff --git a/un.ml b/un.ml index 8d991e8..2e5b82c 100755 --- a/un.ml +++ b/un.ml @@ -231,6 +231,151 @@ let curl_delete api_key endpoint = check_clock_drift 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 \n"; + exit 1 + (* Extract JSON value - simple regex-based parser *) let extract_json_value json_str key = let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in @@ -465,9 +610,17 @@ let session_command action shell network vcpu input_files = | _ -> () (* 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 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" -> let response = curl_get api_key "/services" in 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 Sys.remove tmp_file; 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 -> Printf.fprintf stderr "Error: --name required to create service\n"; exit 1) @@ -649,7 +812,10 @@ let () = Printf.printf "Usage: un.ml [options] \n"; Printf.printf " un.ml session [options]\n"; Printf.printf " un.ml service [options]\n"; - Printf.printf " un.ml key [--extend]\n"; + Printf.printf " un.ml service env \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 | "key" :: rest -> let extend = List.mem "--extend" rest in @@ -675,28 +841,40 @@ let () = parse_session "create" None None None rest | "service" :: rest -> let input_files = parse_input_files [] rest in - let rec parse_service action name ports bootstrap bootstrap_file service_type network vcpu = function - | [] -> service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files - | "--list" :: rest -> parse_service "list" name ports bootstrap bootstrap_file service_type network vcpu rest - | "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap bootstrap_file service_type network vcpu 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 + let rec parse_envs acc = function + | [] -> List.rev acc + | "-e" :: kv :: rest -> parse_envs (kv :: acc) rest + | _ :: rest -> parse_envs acc rest 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 -> let rec parse_execute file env_vars artifacts out_dir network vcpu = function | [] -> execute_command file env_vars artifacts out_dir network vcpu diff --git a/un.nim b/un.nim index f92a325..01ef5eb 100644 --- a/un.nim +++ b/un.nim @@ -123,6 +123,130 @@ proc execCurl(cmd: string): string = stderr.writeLine(" Windows: w32tm /resync") 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.. ") + quit(1) + proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, publicKey: string, secretKey: string) = let lang = detectLanguage(sourceFile) 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}'""" 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: let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey) 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 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}'""" - 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 stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET) @@ -422,7 +562,18 @@ proc main() = stderr.writeLine("Usage: un.nim [options] ") stderr.writeLine(" un.nim session [options]") stderr.writeLine(" un.nim service [options]") + stderr.writeLine(" un.nim service env [options]") stderr.writeLine(" un.nim key [options]") + stderr.writeLine("") + stderr.writeLine("Service env commands:") + stderr.writeLine(" env status Show vault status") + stderr.writeLine(" env set Set vault (-e KEY=VALUE or --env-file FILE)") + stderr.writeLine(" env export Export vault contents") + stderr.writeLine(" env delete 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) if args[0] == "key": @@ -472,7 +623,28 @@ proc main() = var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = "" var vcpu = 0 var inputFiles: seq[string] = @[] + var svcEnvs: seq[string] = @[] + var svcEnvFile = "" + var envAction, envTarget = "" 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: case args[i] of "--name": name = args[i+1]; inc i @@ -494,6 +666,8 @@ proc main() = of "-n": network = args[i+1]; inc i of "-v": vcpu = parseInt(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": let file = args[i+1] if fileExists(file): @@ -504,7 +678,7 @@ proc main() = 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, 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 # Execute mode diff --git a/un.php b/un.php index 2626c3a..b409781 100755 --- a/un.php +++ b/un.php @@ -178,6 +178,172 @@ function api_request($endpoint, $method = 'GET', $data = null, $keys = null) { 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) { $keys = get_api_keys($options['api_key']); @@ -568,9 +734,16 @@ function cmd_service($options) { if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; $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"; 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; } @@ -614,7 +787,10 @@ function main() { 'command' => null, 'dump_bootstrap' => null, 'dump_file' => null, - 'extend' => false + 'extend' => false, + 'env_file' => null, + 'env_action' => null, + 'env_target' => null ]; for ($i = 1; $i < count($argv); $i++) { @@ -688,6 +864,20 @@ function main() { case '--bootstrap-file': $options['bootstrap_file'] = $argv[++$i]; break; + case '--env-file': + $options['env_file'] = $argv[++$i]; + break; + case 'env': + // Handle "service env " 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': $options['info'] = $argv[++$i]; break; @@ -735,7 +925,13 @@ function main() { if ($options['command'] === 'session') { cmd_session($options); } 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') { cmd_key($options); } elseif ($options['source_file']) { diff --git a/un.pl b/un.pl index 7b7e13b..4b5c2db 100644 --- a/un.pl +++ b/un.pl @@ -169,6 +169,164 @@ sub api_request { 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 { my ($options) = @_; 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}; 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 "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; } @@ -573,7 +738,10 @@ sub main { command => undef, dump_bootstrap => undef, dump_file => undef, - extend => 0 + extend => 0, + env_file => undef, + env_action => undef, + env_target => undef ); for (my $i = 0; $i < @ARGV; $i++) { @@ -621,6 +789,16 @@ sub main { $options{bootstrap} = $ARGV[++$i]; } elsif ($arg eq '--bootstrap-file') { $options{bootstrap_file} = $ARGV[++$i]; + } elsif ($arg eq '--env-file') { + $options{env_file} = $ARGV[++$i]; + } elsif ($arg eq 'env') { + # Handle "service env " 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') { $options{info} = $ARGV[++$i]; } elsif ($arg eq '--logs') { @@ -654,7 +832,13 @@ sub main { if ($options{command} && $options{command} eq 'session') { cmd_session(\%options); } 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') { cmd_key(\%options); } elsif ($options{source_file}) { diff --git a/un.pro b/un.pro index 6eef65e..c74ae79 100644 --- a/un.pro +++ b/un.pro @@ -230,6 +230,42 @@ service_destroy(ServiceId) :- [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), 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(ServiceId, DumpFile) :- get_public_key(PublicKey), @@ -330,58 +366,126 @@ parse_session_args([Arg|Rest], Shell, Files, ShellOut, InputFiles) :- ). % 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) :- - parse_service_args(Args, '', '', '', '', '', [], Action, InputFiles), + parse_service_args(Args, '', '', '', '', '', [], '', '', 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 \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_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFiles) :- +parse_service_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, create, InputFiles) :- ( 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'), halt(1) ). -parse_service_args([], _, _, _, _, _, InputFiles, Action, InputFiles) :- +parse_service_args([], _, _, _, _, _, InputFiles, _, _, Action, InputFiles) :- ( Action = 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) ). -parse_service_args(['--list'|_], _, _, _, _, _, _, _, _) :- service_list. -parse_service_args(['-l'|_], _, _, _, _, _, _, _, _) :- service_list. -parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _) :- service_info(ServiceId). -parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _) :- service_logs(ServiceId). -parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_sleep(ServiceId). -parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_wake(ServiceId). -parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _) :- service_destroy(ServiceId). -parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _) :- +parse_service_args(['--list'|_], _, _, _, _, _, _, _, _, _, _) :- service_list. +parse_service_args(['-l'|_], _, _, _, _, _, _, _, _, _, _) :- service_list. +parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_info(ServiceId). +parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_logs(ServiceId). +parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_sleep(ServiceId). +parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_wake(ServiceId). +parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_destroy(ServiceId). +parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _, _, _) :- ( Rest = ['--dump-file', DumpFile|_] -> service_dump_bootstrap(ServiceId, DumpFile) ; service_dump_bootstrap(ServiceId, '') ). -parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, _, InputFilesOut) :- - parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFilesOut). -parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- - parse_service_args(Rest, Name, PortsList, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). -parse_service_args(['--bootstrap', BootstrapVal|Rest], Name, Ports, _, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- - parse_service_args(Rest, Name, Ports, BootstrapVal, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). -parse_service_args(['--bootstrap-file', BootstrapFileVal|Rest], Name, Ports, Bootstrap, _, ServiceType, InputFiles, Action, InputFilesOut) :- - parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFileVal, ServiceType, InputFiles, Action, InputFilesOut). -parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, BootstrapFile, _, InputFiles, Action, InputFilesOut) :- - parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, Type, InputFiles, Action, InputFilesOut). -parse_service_args(['-f', FilePath|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, 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, Envs, EnvFile, create, 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, Envs, EnvFile, 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, Envs, EnvFile, 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, Envs, EnvFile, 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, Envs, EnvFile, 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) -> 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]), halt(1) ). -parse_service_args([_|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, 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) :- + 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(_, _). +% 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(Argv) :- % Check arguments diff --git a/un.ps1 b/un.ps1 index 53050cc..91005fe 100644 --- a/un.ps1 +++ b/un.ps1 @@ -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 " + exit 1 + } + } +} + function Invoke-Execute { param($SourceFile, $EnvVars = @{}, $Network = $null) @@ -283,6 +458,41 @@ function Invoke-Key { function Invoke-Service { 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") { $result = Invoke-Api -Endpoint "/services" $result | ConvertTo-Json -Depth 5 @@ -417,8 +627,21 @@ function Invoke-Service { $body = $payload | ConvertTo-Json -Depth 10 $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 + + # 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 } @@ -450,6 +673,8 @@ Service options: --type TYPE Service type (minecraft, mumble, teamspeak, source, tcp, udp) --bootstrap CMD Bootstrap command -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 --info ID Get service info --logs ID Get logs @@ -459,6 +684,12 @@ Service options: --dump-bootstrap ID Dump bootstrap script from service --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: --extend Open browser to extend key "@ diff --git a/un.py b/un.py index 4924557..d6049f2 100644 --- a/un.py +++ b/un.py @@ -196,6 +196,149 @@ def api_request(endpoint, method="GET", data=None, public_key=None, secret_key=N 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): """Execute source code""" public_key, secret_key = get_api_keys(args.api_key) @@ -521,6 +664,43 @@ def cmd_service(args): """Manage persistent services""" public_key, secret_key = get_api_keys(args.api_key) + # Handle env subcommand: un.py service env + 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: result = api_request("/services", public_key=public_key, secret_key=secret_key) services = result.get("services", []) @@ -658,10 +838,19 @@ def cmd_service(args): payload["vcpu"] = args.vcpu 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')}") if 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 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_parser = subparsers.add_parser("service", help="Persistent services") + # For env subcommand: un.py service env + 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("--ports", help="Comma-separated ports") 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-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("-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("--info", metavar="ID", help="Get service details") service_parser.add_argument("--tail", metavar="ID", help="Get last 9000 lines of logs") diff --git a/un.r b/un.r index 2a0842a..ac7a740 100644 --- a/un.r +++ b/un.r @@ -66,6 +66,7 @@ RESET <- "\033[0m" API_BASE <- "https://api.unsandbox.com" PORTAL_BASE <- "https://unsandbox.com" +MAX_ENV_CONTENT_SIZE <- 65536 detect_language <- function(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 \n", file = stderr()) + quit(status = 1) +} + cmd_execute <- function(args) { keys <- get_api_keys(args$api_key) public_key <- keys$public_key @@ -517,6 +650,12 @@ cmd_snapshot <- 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) public_key <- keys$public_key secret_key <- keys$secret_key @@ -686,6 +825,19 @@ cmd_service <- function(args) { if (!is.null(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() } @@ -732,7 +884,11 @@ parse_args <- function() { bootstrap = NULL, bootstrap_file = NULL, vcpu = NULL, - extend = FALSE + extend = FALSE, + svc_envs = NULL, + svc_env_file = NULL, + env_action = NULL, + env_target = NULL ) i <- 1 @@ -745,6 +901,18 @@ parse_args <- function() { } else if (arg == "service") { result$command <- "service" 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") { result$command <- "key" i <- i + 1 @@ -761,7 +929,15 @@ parse_args <- function() { i <- i + 1 } else if (arg %in% c("-e", "--env")) { 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 } else if (arg %in% c("-f", "--files")) { i <- i + 1 @@ -887,8 +1063,14 @@ parse_args <- function() { cat("Usage: un.r [options] \n", file = stderr()) cat(" un.r session [options]\n", file = stderr()) cat(" un.r service [options]\n", file = stderr()) + cat(" un.r service env [options]\n", file = stderr()) cat(" un.r snapshot [options]\n", file = stderr()) cat(" un.r key [options]\n", file = stderr()) + cat("\nService env commands:\n", file = stderr()) + cat(" env status Show vault status\n", file = stderr()) + cat(" env set Set vault (-e KEY=VALUE or --env-file FILE)\n", file = stderr()) + cat(" env export Export vault contents\n", file = stderr()) + cat(" env delete Delete vault\n", file = stderr()) quit(status = 1) } } @@ -913,8 +1095,14 @@ main <- function() { cat("Usage: un.r [options] \n", file = stderr()) cat(" un.r session [options]\n", file = stderr()) cat(" un.r service [options]\n", file = stderr()) + cat(" un.r service env [options]\n", file = stderr()) cat(" un.r snapshot [options]\n", file = stderr()) cat(" un.r key [options]\n", file = stderr()) + cat("\nService env commands:\n", file = stderr()) + cat(" env status Show vault status\n", file = stderr()) + cat(" env set Set vault (-e KEY=VALUE or --env-file FILE)\n", file = stderr()) + cat(" env export Export vault contents\n", file = stderr()) + cat(" env delete Delete vault\n", file = stderr()) quit(status = 1) } } diff --git a/un.raku b/un.raku index d4f41e6..84af4eb 100644 --- a/un.raku +++ b/un.raku @@ -139,7 +139,7 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec @args.append: $url; my $proc = run |@args, :out, :err; - my $body = $proc.out.slurp; + my $resp-body = $proc.out.slurp; my $err = $proc.err.slurp; 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 - 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 "{$YELLOW}Your computer's clock may have drifted.{$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; } - 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) { @@ -370,6 +395,41 @@ sub cmd-session(@args) { 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 if %result; +} + +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) { my ($public-key, $secret-key) = get-api-keys(); my $list-mode = False; @@ -388,6 +448,61 @@ sub cmd-service(@args) { my $network = ''; my $vcpu = 0; 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 [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 my $i = 0; @@ -456,6 +571,14 @@ sub cmd-service(@args) { $i++; @input-files.push(@args[$i]); } + when '-e' { + $i++; + @env-vars.push(@args[$i]); + } + when '--env-file' { + $i++; + $env-file = @args[$i]; + } } $i++; } @@ -579,10 +702,17 @@ sub cmd-service(@args) { say "{$GREEN}Service created: {%result}{$RESET}"; say "Name: {%result}"; say "URL: {%result}" if %result; + + # Auto-set vault if -e or --env-file were provided + my $env-content = build-env-content(@env-vars, $env-file); + if $env-content && %result { + say "{$YELLOW}Setting vault for service...{$RESET}"; + service-env-set(%result, $env-content, :$public-key, :$secret-key); + } 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; } diff --git a/un.rb b/un.rb index 834e505..fcbeeb4 100644 --- a/un.rb +++ b/un.rb @@ -169,6 +169,152 @@ rescue => e exit 1 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) keys = get_api_keys(options[:api_key]) @@ -620,9 +766,16 @@ def cmd_service(options) payload[:vcpu] = options[:vcpu] if options[:vcpu] 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 "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 end @@ -678,7 +831,10 @@ def main dump_file: nil, extend: false, bootstrap_file: nil, - exec_shell: nil + exec_shell: nil, + env_file: nil, + env_action: nil, + env_target: nil } # Manual argument parsing @@ -749,6 +905,21 @@ def main when '--bootstrap-file' i += 1 options[:bootstrap_file] = ARGV[i] + when '--env-file' + i += 1 + options[:env_file] = ARGV[i] + when 'env' + # Handle "service env " 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' i += 1 options[:info] = ARGV[i] @@ -846,7 +1017,13 @@ def main when 'session' cmd_session(options) 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' cmd_snapshot(options) when 'key' diff --git a/un.rs b/un.rs index 57031a7..aea0783 100644 --- a/un.rs +++ b/un.rs @@ -244,6 +244,153 @@ fn api_request(endpoint: &str, method: &str, body: Option<&str>, public_key: &st 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, ×tamp, 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 = 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 {}", 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 [-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 {}", 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 {}", 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( source_file: &str, envs: Vec, @@ -430,9 +577,19 @@ fn cmd_service( dump_file: Option<&str>, network: Option<&str>, vcpu: Option, + envs: &[String], + env_file: Option<&str>, + env_action: Option<&str>, + env_target: Option<&str>, public_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 { let result = api_request("/services", "GET", None, public_key, secret_key); println!("{}", result); @@ -601,6 +758,16 @@ fn cmd_service( let result = api_request("/services", "POST", Some(&json), public_key, secret_key); let id = extract_json_string(&result, "id"); 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; } @@ -778,13 +945,36 @@ fn main() { } "service" => { 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 = Vec::new(); + let mut service_envs: Vec = Vec::new(); + let mut env_file_opt: Option = None; + let mut env_action: Option = None; + let mut env_target: Option = None; let mut j = i + 1; while j < args.len() { if args[j] == "-f" && j + 1 < args.len() { service_files.push(args[j + 1].clone()); 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 + 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 { 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()), network.as_deref(), vcpu, + &service_envs, + env_file_opt.as_deref(), + env_action.as_deref(), + env_target.as_deref(), &public_key, &secret_key, ); diff --git a/un.scm b/un.scm index c4a7729..bfb283c 100644 --- a/un.scm +++ b/un.scm @@ -243,6 +243,58 @@ (exit 1)) 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) (let ((public-key (getenv "UNSANDBOX_PUBLIC_KEY")) (secret-key (getenv "UNSANDBOX_SECRET_KEY")) @@ -382,7 +434,7 @@ (display response) (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))) (cond ((equal? action "list") @@ -403,6 +455,19 @@ ((equal? action "destroy") (curl-delete api-key (format #f "/services/~a" id)) (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") (when (and id bootstrap) (let* ((json (format #f "{\"command\":\"~a\"}" (escape-json bootstrap))) @@ -436,12 +501,18 @@ (type-json (if type (format #f ",\"service_type\":\"~a\"" type) "")) (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)) - (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) (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 - (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))))) (define (parse-input-files args) @@ -497,23 +568,53 @@ ((equal? (car args) "service") (cond ((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")) - (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")) - (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")) - (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")) - (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")) - (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")) - (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")) - (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")) - (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 [options] + ((and (> (length args) 1) (equal? (cadr args) "env")) + (if (< (length args) 4) + (begin + (display "Usage: un.scm service env [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")) (let* ((name (caddr args)) (rest-args (cdddr args)) @@ -521,6 +622,8 @@ (bootstrap #f) (bootstrap-file #f) (type #f) + (env-vars '()) + (env-file #f) (input-files (parse-input-files rest-args))) ;; Parse remaining args (let loop ((args rest-args)) @@ -538,10 +641,16 @@ ((equal? (car args) "--type") (set! type (cadr 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") (loop (cddr args))) ; skip -f, already parsed (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 (display "Error: Invalid service command\n" (current-error-port)) (exit 1)))) diff --git a/un.sh b/un.sh index da83b37..7cd7462 100644 --- a/un.sh +++ b/un.sh @@ -206,6 +206,177 @@ api_request() { 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() { local source_file="" local -a env_vars=() @@ -523,6 +694,71 @@ cmd_session() { 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() { local name="" local ports="" @@ -543,6 +779,8 @@ cmd_service() { local vcpu="" local api_key="${UNSANDBOX_API_KEY:-}" local -a input_files=() + local -a env_vars=() + local env_file="" local snapshot="" local restore="" local from_snapshot="" @@ -551,6 +789,14 @@ cmd_service() { while [[ $# -gt 0 ]]; do case "$1" in + -e) + env_vars+=("$2") + shift 2 + ;; + --env-file) + env_file="$2" + shift 2 + ;; --name) name="$2" shift 2 @@ -826,6 +1072,17 @@ cmd_service() { echo -e "${GREEN}Service created: $service_id${RESET}" echo "Name: $service_name" [[ -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 fi @@ -1198,7 +1455,13 @@ if [[ "$1" == "session" ]]; then cmd_session "$@" elif [[ "$1" == "service" ]]; then shift - cmd_service "$@" + # Check for env subcommand: service env + if [[ "${1:-}" == "env" ]]; then + shift + cmd_service_env "$@" + else + cmd_service "$@" + fi elif [[ "$1" == "snapshot" ]]; then shift cmd_snapshot "$@" diff --git a/un.tcl b/un.tcl index 3ac210b..255aa77 100755 --- a/un.tcl +++ b/un.tcl @@ -168,6 +168,158 @@ proc api_request {endpoint method data public_key secret_key} { 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 " + exit 1 + } + } +} + proc cmd_execute {args} { lassign [get_api_keys] public_key secret_key set source_file "" @@ -533,11 +685,32 @@ proc cmd_service {args} { set network "" set vcpu 0 set input_files [list] + set envs [list] + set env_file "" + set env_action "" + set env_target "" # Parse arguments for {set i 0} {$i < [llength $args]} {incr i} { set arg [lindex $args $i] switch -exact -- $arg { + env { + # Parse: env [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 { set list_mode 1 } @@ -601,9 +774,23 @@ proc cmd_service {args} { incr 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} { set result [api_request "/services" "GET" {} $public_key $secret_key] 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] - 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]" if {[dict exists $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 } @@ -757,7 +957,18 @@ proc main {argv} { puts stderr "Usage: un.tcl \[options\] " puts stderr " un.tcl session \[options\]" puts stderr " un.tcl service \[options\]" + puts stderr " un.tcl service env \[options\]" 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 } diff --git a/un.ts b/un.ts index b4d8d42..10f7401 100644 --- a/un.ts +++ b/un.ts @@ -125,6 +125,9 @@ interface Args { clonePorts: string | null; dumpBootstrap: string | null; dumpFile: string | null; + envFile: string | null; + envAction: string | null; + envTarget: string | null; } 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const keys = getApiKeys(args.apiKey); @@ -524,9 +696,16 @@ async function cmdService(args: Args): Promise { if (args.vcpu) payload.vcpu = args.vcpu; 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'}`); 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; } @@ -639,6 +818,9 @@ function parseArgs(argv: string[]): Args { dumpBootstrap: null, dumpFile: null, extend: false, + envFile: null, + envAction: null, + envTarget: null, }; let i = 2; @@ -708,6 +890,20 @@ function parseArgs(argv: string[]): Args { } else if (arg === '--bootstrap-file' && i + 1 < argv.length) { args.bootstrapFile = argv[++i]; i++; + } else if (arg === '--env-file' && i + 1 < argv.length) { + args.envFile = argv[++i]; + i++; + } else if (arg === 'env') { + // Handle "service env " 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) { args.info = argv[++i]; i++; @@ -759,7 +955,13 @@ async function main(): Promise { if (args.command === 'session') { await cmdSession(args); } 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') { await cmdKey(args); } else if (args.sourceFile) { diff --git a/un.v b/un.v index 9e91ea8..49a03f2 100644 --- a/un.v +++ b/un.v @@ -47,6 +47,7 @@ import os const api_base = 'https://api.unsandbox.com' const portal_base = 'https://unsandbox.com' +const max_env_content_size = 65536 const blue = '\x1b[34m' const red = '\x1b[31m' const green = '\x1b[32m' @@ -156,6 +157,97 @@ fn extract_json_string(json string, key string) string { 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 ') + } + } +} + fn cmd_key(extend bool, api_key string) { pub_key := get_public_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)) } -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() 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}') 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 } @@ -482,7 +588,14 @@ fn main() { eprintln('Usage: ${os.args[0]} [options] ') eprintln(' ${os.args[0]} session [options]') eprintln(' ${os.args[0]} service [options]') + eprintln(' ${os.args[0]} service env [options]') eprintln(' ${os.args[0]} key [--extend]') + eprintln('') + eprintln('Vault commands:') + eprintln(' service env status Check vault status') + eprintln(' service env set Set vault (-e KEY=VAL or --env-file FILE)') + eprintln(' service env export Export vault contents') + eprintln(' service env delete Delete vault') exit(1) } @@ -561,10 +674,23 @@ fn main() { mut network := '' mut vcpu := 0 mut input_files := []string{} + mut svc_envs := []string{} + mut svc_env_file := '' + mut env_action := '' + mut env_target := '' mut i := 2 for i < os.args.len { match os.args[i] { + 'env' { + // service env + if i + 2 < os.args.len { + i++ + env_action = os.args[i] + i++ + env_target = os.args[i] + } + } '--name' { i++ name = os.args[i] @@ -626,6 +752,14 @@ fn main() { i++ dump_file = os.args[i] } + '-e' { + i++ + svc_envs << os.args[i] + } + '--env-file' { + i++ + svc_env_file = os.args[i] + } '-n' { i++ network = os.args[i] @@ -653,8 +787,14 @@ fn main() { 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, - vcpu, input_files, api_key) + vcpu, input_files, svc_envs, svc_env_file, api_key) return } diff --git a/un.zig b/un.zig index b67e73f..250a2ff 100644 --- a/un.zig +++ b/un.zig @@ -54,6 +54,11 @@ const time = std.time; const API_BASE = "https://api.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 { 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); } +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 \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 { if (files.items.len == 0) { return try allocator.dupe(u8, ""); @@ -162,7 +297,13 @@ pub fn main() !u8 { std.debug.print("Usage: {s} [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 env [options]\n", .{args[0]}); std.debug.print(" {s} key [--extend]\n", .{args[0]}); + std.debug.print("\nVault commands:\n", .{}); + std.debug.print(" service env status Check vault status\n", .{}); + std.debug.print(" service env set Set vault (-e KEY=VAL or --env-file FILE)\n", .{}); + std.debug.print(" service env export Export vault contents\n", .{}); + std.debug.print(" service env delete Delete vault\n", .{}); return 1; } @@ -258,10 +399,21 @@ pub fn main() !u8 { var dump_file: ?[]const u8 = null; var input_files = std.ArrayList([]const u8).init(allocator); 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; while (i < args.len) : (i += 1) { if (mem.eql(u8, args[i], "--list")) { list = true; + } else if (mem.eql(u8, args[i], "env") and i + 2 < args.len) { + // service env + 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) { i += 1; 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) { i += 1; 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) { i += 1; 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) { const auth_headers = try buildAuthCmd(allocator, "GET", "/services", "", public_key, secret_key); 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); 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); - std.debug.print("\x1b[33mCreating service...\x1b[0m\n", .{}); - _ = std.c.system(cmd.ptr); - std.debug.print("\n", .{}); + + // Check if we need auto-vault + const has_env = svc_envs.items.len > 0 or svc_env_file != null; + + 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; }