diff --git a/.gitignore b/.gitignore index 9eb8c40..42753b7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,14 @@ *.class *.beam /un +/un_go +/un_rust +/un_cpp +/un_d +/un_nim +/un_v +/un_zig +/Un.class # Build directories _build/ diff --git a/README.md b/README.md index 733b6d4..d4f82ad 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Each implementation supports: - **Artifact collection** (`-a -o ./output`) - **Interactive sessions** (`session` subcommand) - **Persistent services** (`service` subcommand) +- **API key management** (`key` subcommand) - **Network modes** (`-n zerotrust` or `-n semitrusted`) ## Usage @@ -104,6 +105,12 @@ Each implementation supports: # Persistent service ./un.py service --name myapp --ports 8080 --bootstrap "python3 -m http.server 8080" + +# Check API key status +./un.py key + +# Extend/renew API key (opens browser) +./un.py key --extend ``` ## Testing diff --git a/Un.cs b/Un.cs index bd556a6..ca2a741 100644 --- a/Un.cs +++ b/Un.cs @@ -49,6 +49,7 @@ using System.Text; class Un { private const string API_BASE = "https://api.unsandbox.com"; + private const string PORTAL_BASE = "https://unsandbox.com"; private const string BLUE = "\x1B[34m"; private const string RED = "\x1B[31m"; private const string GREEN = "\x1B[32m"; @@ -86,6 +87,10 @@ class Un { CmdService(parsedArgs); } + else if (parsedArgs.Command == "key") + { + CmdKey(parsedArgs); + } else if (parsedArgs.SourceFile != null) { CmdExecute(parsedArgs); @@ -243,6 +248,91 @@ class Un Console.WriteLine($"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}"); } + static void CmdKey(Args args) + { + string apiKey = GetApiKey(args.ApiKey); + + var result = ApiRequest("/keys/validate", "POST", null, apiKey); + + if (!result.ContainsKey("valid")) + { + Console.Error.WriteLine($"{RED}Error: Invalid response from server{RESET}"); + Environment.Exit(1); + } + + bool isValid = (bool)result["valid"]; + bool isExpired = result.ContainsKey("expired") && (bool)result["expired"]; + + if (isValid && !isExpired) + { + Console.WriteLine($"{GREEN}Valid{RESET}"); + if (result.ContainsKey("public_key")) + { + Console.WriteLine($"Public Key: {result["public_key"]}"); + } + if (result.ContainsKey("tier")) + { + Console.WriteLine($"Tier: {result["tier"]}"); + } + if (result.ContainsKey("expires_at")) + { + Console.WriteLine($"Expires: {result["expires_at"]}"); + } + } + else if (isExpired) + { + Console.WriteLine($"{RED}Expired{RESET}"); + if (result.ContainsKey("public_key")) + { + Console.WriteLine($"Public Key: {result["public_key"]}"); + } + if (result.ContainsKey("tier")) + { + Console.WriteLine($"Tier: {result["tier"]}"); + } + if (result.ContainsKey("expired_at")) + { + Console.WriteLine($"Expired: {result["expired_at"]}"); + } + Console.WriteLine($"{YELLOW}To renew: Visit {PORTAL_BASE}/keys/extend{RESET}"); + + if (args.KeyExtend && result.ContainsKey("public_key")) + { + string publicKey = (string)result["public_key"]; + string url = $"{PORTAL_BASE}/keys/extend?pk={publicKey}"; + Console.WriteLine($"{YELLOW}Opening: {url}{RESET}"); + OpenBrowser(url); + } + } + else + { + Console.WriteLine($"{RED}Invalid{RESET}"); + } + } + + static void OpenBrowser(string url) + { + try + { + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true }); + } + else if (Environment.OSVersion.Platform == PlatformID.Unix) + { + System.Diagnostics.Process.Start("xdg-open", url); + } + else if (Environment.OSVersion.Platform == PlatformID.MacOSX) + { + System.Diagnostics.Process.Start("open", url); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"{RED}Failed to open browser: {ex.Message}{RESET}"); + } + } + static void CmdService(Args args) { string apiKey = GetApiKey(args.ApiKey); @@ -680,6 +770,7 @@ class Un public string ServiceWake = null; public string ServiceDestroy = null; public string ServiceType = null; + public bool KeyExtend = false; } static Args ParseArgs(string[] args) @@ -690,6 +781,7 @@ class Un string arg = args[i]; if (arg == "session") result.Command = "session"; else if (arg == "service") result.Command = "service"; + else if (arg == "key") result.Command = "key"; 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]); @@ -714,6 +806,7 @@ class Un else if (arg == "--sleep") result.ServiceSleep = args[++i]; else if (arg == "--wake") result.ServiceWake = args[++i]; else if (arg == "--destroy") result.ServiceDestroy = args[++i]; + else if (arg == "--extend") result.KeyExtend = true; else if (!arg.StartsWith("-")) result.SourceFile = arg; } return result; @@ -724,6 +817,7 @@ class Un Console.WriteLine(@"Usage: Un [options] Un session [options] Un service [options] + Un key [options] Execute options: -e KEY=VALUE Set environment variable @@ -750,6 +844,9 @@ Service options: --tail ID Get last 9000 lines --sleep ID Freeze service --wake ID Unfreeze service - --destroy ID Destroy service"); + --destroy ID Destroy service + +Key options: + --extend Open browser to extend expired key"); } } diff --git a/Un.java b/Un.java index fa3ac3c..bc6837f 100644 --- a/Un.java +++ b/Un.java @@ -48,6 +48,7 @@ import java.util.Base64; public class Un { private static final String API_BASE = "https://api.unsandbox.com"; + private static final String PORTAL_BASE = "https://unsandbox.com"; private static final String BLUE = "\033[34m"; private static final String RED = "\033[31m"; private static final String GREEN = "\033[32m"; @@ -78,6 +79,8 @@ public class Un { cmdSession(parsedArgs); } else if (parsedArgs.command.equals("service")) { cmdService(parsedArgs); + } else if (parsedArgs.command.equals("key")) { + cmdKey(parsedArgs); } else if (parsedArgs.sourceFile != null) { cmdExecute(parsedArgs); } else { @@ -307,6 +310,108 @@ public class Un { System.exit(1); } + private static void cmdKey(Args args) throws Exception { + String apiKey = getApiKey(args.apiKey); + + if (args.keyExtend) { + // First validate to get public_key + Map result = validateKey(apiKey); + String publicKey = (String) result.get("public_key"); + if (publicKey == null || publicKey.isEmpty()) { + System.err.println(RED + "Error: Could not retrieve public key" + RESET); + System.exit(1); + } + + String extendUrl = PORTAL_BASE + "/keys/extend?pk=" + urlEncode(publicKey); + System.out.println(YELLOW + "Opening browser to extend key:" + RESET); + System.out.println(extendUrl); + + // Try to open browser + try { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("mac")) { + Runtime.getRuntime().exec(new String[]{"open", extendUrl}); + } else if (os.contains("nix") || os.contains("nux")) { + Runtime.getRuntime().exec(new String[]{"xdg-open", extendUrl}); + } else if (os.contains("win")) { + Runtime.getRuntime().exec(new String[]{"rundll32", "url.dll,FileProtocolHandler", extendUrl}); + } + } catch (Exception e) { + // Browser opening failed, URL already printed + } + return; + } + + // Default: validate key + Map result = validateKey(apiKey); + Boolean expired = (Boolean) result.get("expired"); + String publicKey = (String) result.get("public_key"); + String tier = (String) result.get("tier"); + String status = (String) result.get("status"); + String expiresAt = (String) result.get("expires_at"); + String timeRemaining = (String) result.get("time_remaining"); + Object rateLimit = result.get("rate_limit"); + Object burst = result.get("burst"); + Object concurrency = result.get("concurrency"); + + if (expired != null && expired) { + System.out.println(RED + "Expired" + RESET); + System.out.println("Public Key: " + (publicKey != null ? publicKey : "N/A")); + System.out.println("Tier: " + (tier != null ? tier : "N/A")); + System.out.println("Expired: " + (expiresAt != null ? expiresAt : "N/A")); + System.out.println(YELLOW + "To renew: Visit " + PORTAL_BASE + "/keys/extend" + RESET); + System.exit(1); + } + + // Valid key + System.out.println(GREEN + "Valid" + RESET); + System.out.println("Public Key: " + (publicKey != null ? publicKey : "N/A")); + System.out.println("Tier: " + (tier != null ? tier : "N/A")); + System.out.println("Status: " + (status != null ? status : "N/A")); + System.out.println("Expires: " + (expiresAt != null ? expiresAt : "N/A")); + System.out.println("Time Remaining: " + (timeRemaining != null ? timeRemaining : "N/A")); + System.out.println("Rate Limit: " + (rateLimit != null ? rateLimit : "N/A")); + System.out.println("Burst: " + (burst != null ? burst : "N/A")); + System.out.println("Concurrency: " + (concurrency != null ? concurrency : "N/A")); + } + + private static Map validateKey(String apiKey) throws Exception { + URL url = new URL(PORTAL_BASE + "/keys/validate"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Authorization", "Bearer " + apiKey); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(30000); + conn.setReadTimeout(30000); + + int status = conn.getResponseCode(); + if (status < 200 || status >= 300) { + String error = readStream(conn.getErrorStream()); + // Try to parse error JSON + try { + Map errorJson = parseJson(error); + String reason = (String) errorJson.getOrDefault("error", error); + System.out.println(RED + "Invalid" + RESET); + System.out.println("Reason: " + reason); + } catch (Exception e) { + System.out.println(RED + "Invalid" + RESET); + System.out.println("Reason: " + error); + } + System.exit(1); + } + + String response = readStream(conn.getInputStream()); + return parseJson(response); + } + + private static String urlEncode(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return s; + } + } + private static String getApiKey(String argsKey) { String key = argsKey != null ? argsKey : System.getenv("UNSANDBOX_API_KEY"); if (key == null || key.isEmpty()) { @@ -562,6 +667,9 @@ public class Un { String serviceSleep = null; String serviceWake = null; String serviceDestroy = null; + + // Key args + boolean keyExtend = false; } private static Args parseArgs(String[] args) { @@ -572,6 +680,8 @@ public class Un { result.command = "session"; } else if (arg.equals("service")) { result.command = "service"; + } else if (arg.equals("key")) { + result.command = "key"; } else if (arg.equals("-k") || arg.equals("--api-key")) { result.apiKey = args[++i]; } else if (arg.equals("-n") || arg.equals("--network")) { @@ -613,6 +723,8 @@ public class Un { result.serviceWake = args[++i]; } else if (arg.equals("--destroy")) { result.serviceDestroy = args[++i]; + } else if (arg.equals("--extend")) { + result.keyExtend = true; } else if (!arg.startsWith("-")) { result.sourceFile = arg; } @@ -624,6 +736,7 @@ public class Un { System.out.println("Usage: java Un [options] "); System.out.println(" java Un session [options]"); System.out.println(" java Un service [options]"); + System.out.println(" java Un key [options]"); System.out.println(); System.out.println("Execute options:"); System.out.println(" -e KEY=VALUE Set environment variable"); @@ -651,5 +764,8 @@ public class Un { System.out.println(" --sleep ID Freeze service"); System.out.println(" --wake ID Unfreeze service"); System.out.println(" --destroy ID Destroy service"); + System.out.println(); + System.out.println("Key options:"); + System.out.println(" --extend Open browser to extend key"); } } diff --git a/un.clj b/un.clj index a31bb7b..9076a6c 100644 --- a/un.clj +++ b/un.clj @@ -63,6 +63,8 @@ (def yellow "\u001b[33m") (def reset "\u001b[0m") +(def portal-base "https://unsandbox.com") + (def ext-map {".hs" "haskell" ".ml" "ocaml" ".clj" "clojure" ".scm" "scheme" ".lisp" "commonlisp" ".erl" "erlang" ".ex" "elixir" ".exs" "elixir" @@ -126,6 +128,17 @@ (str "https://api.unsandbox.com" endpoint) "-H" (str "Authorization: Bearer " api-key)))) +(defn curl-portal-post [api-key endpoint json-data] + (let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json")] + (spit tmp-file json-data) + (let [{:keys [out]} (sh "curl" "-s" "-X" "POST" + (str portal-base endpoint) + "-H" "Content-Type: application/json" + "-H" (str "Authorization: Bearer " api-key) + "-d" (str "@" tmp-file))] + (io/delete-file tmp-file true) + out))) + (defn execute-command [file env-vars artifacts out-dir network vcpu] (let [api-key (get-api-key) ext (get-extension file) @@ -198,6 +211,43 @@ (println (str green "Service created" reset)) (println (curl-post api-key "/services" json))))))) +(defn validate-key [api-key extend?] + (let [response (curl-portal-post api-key "/keys/validate" "{}") + status (extract-field "status" response) + public-key (extract-field "public_key" response) + tier (extract-field "tier" response) + expires-at (extract-field "expires_at" response)] + (cond + (= status "valid") + (do + (println (str green "Valid" reset)) + (when public-key (println (str "Public Key: " public-key))) + (when tier (println (str "Tier: " tier))) + (when expires-at (println (str "Expires: " expires-at))) + (when extend? + (let [url (str portal-base "/keys/extend?pk=" public-key)] + (sh "xdg-open" url)))) + + (= status "expired") + (do + (println (str red "Expired" reset)) + (when public-key (println (str "Public Key: " public-key))) + (when tier (println (str "Tier: " tier))) + (when expires-at (println (str "Expired: " expires-at))) + (println (str yellow "To renew: Visit " portal-base "/keys/extend" reset)) + (when extend? + (let [url (str portal-base "/keys/extend?pk=" public-key)] + (sh "xdg-open" url)))) + + :else + (do + (println (str red "Invalid" reset)) + (println response))))) + +(defn key-command [extend?] + (let [api-key (get-api-key)] + (validate-key api-key extend?))) + (defn parse-args [args] (loop [args args file nil @@ -215,110 +265,122 @@ service-ports nil service-bootstrap nil service-type nil + key-extend false mode :execute] (cond (empty? args) (case mode :session (session-command (or session-action :create) session-id session-shell network vcpu) :service (service-command (or service-action :create) service-id service-name service-ports service-bootstrap service-type network vcpu) + :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 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 - service-action service-id service-name service-ports service-bootstrap service-type :session) + service-action service-id service-name service-ports service-bootstrap service-type key-extend :session) (= (first args) "service") (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - service-action service-id service-name service-ports service-bootstrap service-type :service) + service-action service-id service-name service-ports service-bootstrap service-type key-extend :service) + + (= (first args) "key") + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell + service-action service-id service-name service-ports service-bootstrap service-type key-extend :key) + + ;; Key options + (and (= mode :key) (= (first args) "--extend")) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell + service-action service-id service-name service-ports service-bootstrap service-type true mode) ;; Session options (and (= mode :session) (= (first args) "--list")) (recur (rest args) file env-vars artifacts out-dir network vcpu :list session-id session-shell - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type 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 - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type 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) - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type 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 - :list service-id service-name service-ports service-bootstrap service-type mode) + :list service-id service-name service-ports service-bootstrap service-type 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 - :info (second args) service-name service-ports service-bootstrap service-type mode) + :info (second args) service-name service-ports service-bootstrap service-type 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 - :logs (second args) service-name service-ports service-bootstrap service-type mode) + :logs (second args) service-name service-ports service-bootstrap service-type key-extend mode) (and (= mode :service) (= (first args) "--sleep")) (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - :sleep (second args) service-name service-ports service-bootstrap service-type mode) + :sleep (second args) service-name service-ports service-bootstrap service-type key-extend mode) (and (= mode :service) (= (first args) "--wake")) (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - :wake (second args) service-name service-ports service-bootstrap service-type mode) + :wake (second args) service-name service-ports service-bootstrap service-type 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 - :destroy (second args) service-name service-ports service-bootstrap service-type mode) + :destroy (second args) service-name service-ports service-bootstrap service-type 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 - :create service-id (second args) service-ports service-bootstrap service-type mode) + :create service-id (second args) service-ports service-bootstrap service-type 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 - service-action service-id service-name (second args) service-bootstrap service-type mode) + service-action service-id service-name (second args) service-bootstrap service-type 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 - service-action service-id service-name service-ports (second args) service-type mode) + service-action service-id service-name service-ports (second args) service-type 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 - service-action service-id service-name service-ports service-bootstrap (second args) mode) + service-action service-id service-name service-ports service-bootstrap (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 service-action service-id service-name service-ports service-bootstrap service-type mode)) + session-action session-id session-shell service-action service-id service-name service-ports service-bootstrap service-type key-extend mode)) (= (first args) "-a") (recur (rest args) file env-vars true out-dir network vcpu session-action session-id session-shell - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type key-extend mode) (= (first args) "-o") (recur (rest (rest args)) file env-vars artifacts (second args) network vcpu session-action session-id session-shell - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type 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 - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type 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 - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type 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 - service-action service-id service-name service-ports service-bootstrap service-type mode) + service-action service-id service-name service-ports service-bootstrap service-type key-extend mode) :else (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - service-action service-id service-name service-ports service-bootstrap service-type mode)))) + service-action service-id service-name service-ports service-bootstrap service-type key-extend mode)))) (parse-args *command-line-args*) diff --git a/un.cpp b/un.cpp index 54adc2e..749fc2a 100644 --- a/un.cpp +++ b/un.cpp @@ -55,6 +55,7 @@ using namespace std; const string API_BASE = "https://api.unsandbox.com"; +const string PORTAL_BASE = "https://unsandbox.com"; const string BLUE = "\033[34m"; const string RED = "\033[31m"; const string GREEN = "\033[32m"; @@ -294,6 +295,87 @@ void cmd_service(const string& name, const string& ports, const string& type, co exit(1); } +void cmd_validate_key(bool extend, const string& api_key) { + string cmd = "curl -s -X POST '" + PORTAL_BASE + "/keys/validate' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + api_key + "'"; + + string result = exec_curl(cmd); + + // Parse JSON response + size_t status_pos = result.find("\"status\":\""); + size_t public_key_pos = result.find("\"public_key\":\""); + size_t tier_pos = result.find("\"tier\":\""); + size_t expires_pos = result.find("\"expires_at\":\""); + + if (status_pos == string::npos) { + cerr << RED << "Error: Invalid API response" << RESET << endl; + exit(1); + } + + // Extract status + status_pos += 10; + size_t status_end = result.find("\"", status_pos); + string status = result.substr(status_pos, status_end - status_pos); + + // Extract public_key + string public_key; + if (public_key_pos != string::npos) { + public_key_pos += 14; + size_t pk_end = result.find("\"", public_key_pos); + public_key = result.substr(public_key_pos, pk_end - public_key_pos); + } + + // Extract tier + string tier; + if (tier_pos != string::npos) { + tier_pos += 8; + size_t tier_end = result.find("\"", tier_pos); + tier = result.substr(tier_pos, tier_end - tier_pos); + } + + // Extract expires_at + string expires_at; + if (expires_pos != string::npos) { + expires_pos += 14; + size_t expires_end = result.find("\"", expires_pos); + expires_at = result.substr(expires_pos, expires_end - expires_pos); + } + + if (status == "valid") { + cout << GREEN << "Valid" << RESET << endl; + if (!public_key.empty()) { + cout << "Public Key: " << public_key << endl; + } + if (!tier.empty()) { + cout << "Tier: " << tier << endl; + } + if (!expires_at.empty()) { + cout << "Expires: " << expires_at << endl; + } + } else if (status == "expired") { + cout << RED << "Expired" << RESET << endl; + if (!public_key.empty()) { + cout << "Public Key: " << public_key << endl; + } + if (!tier.empty()) { + cout << "Tier: " << tier << endl; + } + if (!expires_at.empty()) { + cout << "Expired: " << expires_at << endl; + } + cout << YELLOW << "To renew: Visit " << PORTAL_BASE << "/keys/extend" << RESET << endl; + + if (extend && !public_key.empty()) { + string url = PORTAL_BASE + "/keys/extend?pk=" + public_key; + string browser_cmd = "xdg-open '" + url + "' 2>/dev/null || open '" + url + "' 2>/dev/null"; + system(browser_cmd.c_str()); + } + } else { + cout << RED << "Invalid" << RESET << endl; + } +} + int main(int argc, char* argv[]) { string api_key = getenv("UNSANDBOX_API_KEY") ? getenv("UNSANDBOX_API_KEY") : ""; @@ -301,6 +383,7 @@ int main(int argc, char* argv[]) { cerr << "Usage: " << argv[0] << " [options] " << endl; cerr << " " << argv[0] << " session [options]" << endl; cerr << " " << argv[0] << " service [options]" << endl; + cerr << " " << argv[0] << " key [options]" << endl; return 1; } @@ -356,6 +439,19 @@ int main(int argc, char* argv[]) { return 0; } + if (cmd_type == "key") { + bool extend = false; + + for (int i = 2; i < argc; i++) { + string arg = argv[i]; + if (arg == "--extend") extend = true; + else if (arg == "-k" && i+1 < argc) api_key = argv[++i]; + } + + cmd_validate_key(extend, api_key); + return 0; + } + // Execute mode vector envs, files; bool artifacts = false; diff --git a/un.cr b/un.cr index bde4c6f..5b5ff5f 100644 --- a/un.cr +++ b/un.cr @@ -67,6 +67,7 @@ YELLOW = "\033[33m" RESET = "\033[0m" API_BASE = "https://api.unsandbox.com" +PORTAL_BASE = "https://unsandbox.com" def detect_language(filename : String) : String ext = File.extname(filename).downcase @@ -231,6 +232,84 @@ def cmd_session(args) exit 1 end +def cmd_key(args) + api_key = get_api_key(args[:api_key]?) + + # Validate key + url = URI.parse(PORTAL_BASE + "/keys/validate") + headers = HTTP::Headers{ + "Content-Type" => "application/json", + "Authorization" => "Bearer #{api_key}" + } + + begin + response = HTTP::Client.post(url, headers: headers, body: "{}") + result = JSON.parse(response.body) + + status = result["status"]?.try(&.as_s?) || "unknown" + public_key = result["public_key"]?.try(&.as_s?) || "N/A" + tier = result["tier"]?.try(&.as_s?) || "N/A" + + case status + when "valid" + puts "#{GREEN}Valid#{RESET}" + puts "Public Key: #{public_key}" + puts "Tier: #{tier}" + if expires_at = result["expires_at"]?.try(&.as_s?) + puts "Expires: #{expires_at}" + end + + # Handle --extend flag + if args[:extend]?.as?(Bool) + extend_url = "#{PORTAL_BASE}/keys/extend?pk=#{public_key}" + puts "\n#{BLUE}Opening browser to extend key...#{RESET}" + # Try common browser commands + ["xdg-open", "open", "firefox", "chromium", "google-chrome"].each do |browser| + if system("which #{browser} > /dev/null 2>&1") + system("#{browser} '#{extend_url}' > /dev/null 2>&1 &") + break + end + end + puts extend_url + end + + when "expired" + puts "#{RED}Expired#{RESET}" + puts "Public Key: #{public_key}" + puts "Tier: #{tier}" + if expired_at = result["expires_at"]?.try(&.as_s?) + puts "Expired: #{expired_at}" + end + puts "#{YELLOW}To renew: Visit #{PORTAL_BASE}/keys/extend#{RESET}" + + # Handle --extend flag for expired keys + if args[:extend]?.as?(Bool) + extend_url = "#{PORTAL_BASE}/keys/extend?pk=#{public_key}" + puts "\n#{BLUE}Opening browser to renew key...#{RESET}" + ["xdg-open", "open", "firefox", "chromium", "google-chrome"].each do |browser| + if system("which #{browser} > /dev/null 2>&1") + system("#{browser} '#{extend_url}' > /dev/null 2>&1 &") + break + end + end + puts extend_url + end + + when "invalid" + puts "#{RED}Invalid#{RESET}" + STDERR.puts "#{RED}Error: API key is not valid#{RESET}" + exit 1 + + else + puts "#{YELLOW}Unknown status: #{status}#{RESET}" + end + + rescue ex + STDERR.puts "#{RED}Error: Failed to validate key: #{ex.message}#{RESET}" + exit 1 + end +end + def cmd_service(args) api_key = get_api_key(args[:api_key]?) @@ -355,11 +434,12 @@ def main ports: nil, domains: nil, service_type: nil, - bootstrap: nil + bootstrap: nil, + extend: false } 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]" + opts.banner = "Usage: un.cr [options] \n un.cr session [options]\n un.cr service [options]\n un.cr key [options]" 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 } @@ -379,6 +459,7 @@ def main opts.on("--domains=DOMAINS", "Comma-separated domains") { |d| args[:domains] = d } opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t } opts.on("--bootstrap=CMD", "Bootstrap command/file") { |b| args[:bootstrap] = b } + opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true } opts.unknown_args do |before, after| if before.size > 0 @@ -387,6 +468,8 @@ def main args[:command] = "session" when "service" args[:command] = "service" + when "key" + args[:command] = "key" else args[:source_file] = before[0] end @@ -400,6 +483,8 @@ def main cmd_session(args) elsif args[:command] == "service" cmd_service(args) + elsif args[:command] == "key" + cmd_key(args) elsif args[:source_file] cmd_execute(args) else diff --git a/un.d b/un.d index f82a3a6..7842b45 100644 --- a/un.d +++ b/un.d @@ -54,6 +54,7 @@ import std.array; import std.algorithm; immutable string API_BASE = "https://api.unsandbox.com"; +immutable string PORTAL_BASE = "https://unsandbox.com"; immutable string BLUE = "\033[34m"; immutable string RED = "\033[31m"; immutable string GREEN = "\033[32m"; @@ -223,6 +224,126 @@ void cmdService(string name, string ports, string bootstrap, string type, bool l exit(1); } +void openBrowser(string url) { + version(linux) { + executeShell("xdg-open \"" ~ url ~ "\" 2>/dev/null &"); + } else version(OSX) { + executeShell("open \"" ~ url ~ "\""); + } else version(Windows) { + executeShell("start \"\" \"" ~ url ~ "\""); + } else { + stderr.writefln("%sError: Unsupported platform for browser opening%s", RED, RESET); + } +} + +string formatDuration(long totalMinutes) { + long days = totalMinutes / (24 * 60); + long hours = (totalMinutes % (24 * 60)) / 60; + long minutes = totalMinutes % 60; + + if (days > 0) { + return format("%dd %dh %dm", days, hours, minutes); + } else if (hours > 0) { + return format("%dh %dm", hours, minutes); + } else { + return format("%dm", minutes); + } +} + +void validateKey(string apiKey, bool extend) { + import std.json; + import std.datetime; + + string cmd = format(`curl -s -w '\n%%{http_code}' -X POST '%s/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s'`, PORTAL_BASE, apiKey); + string response = execCurl(cmd); + + auto lines = response.split("\n"); + string body = lines.length > 1 ? lines[0..$-1].join("\n") : response; + string statusCode = lines.length > 1 ? lines[$-1] : "200"; + + JSONValue result; + try { + result = parseJSON(body); + } catch (Exception e) { + stderr.writefln("%sError parsing response: %s%s", RED, e.msg, RESET); + exit(1); + } + + if (statusCode[0] == '4' || statusCode[0] == '5') { + // Invalid key + writefln("%sInvalid%s", RED, RESET); + if ("error" in result) { + writefln("Reason: %s", result["error"].str); + } else if ("message" in result) { + writefln("Reason: %s", result["message"].str); + } + exit(1); + } + + bool valid = result["valid"].type == JSONType.true_; + bool expired = result["expired"].type == JSONType.true_; + string publicKey = "public_key" in result ? result["public_key"].str : ""; + string tier = "tier" in result ? result["tier"].str : ""; + string status = "status" in result ? result["status"].str : ""; + + if (expired) { + // Expired key + writefln("%sExpired%s", RED, RESET); + writefln("Public Key: %s", publicKey); + writefln("Tier: %s", tier); + if ("expires_at" in result) { + writefln("Expired: %s", result["expires_at"].str); + } + writefln("%sTo renew: Visit https://unsandbox.com/keys/extend%s", YELLOW, RESET); + + if (extend) { + string extendURL = PORTAL_BASE ~ "/keys/extend?pk=" ~ publicKey; + writefln("\n%sOpening browser to extend key...%s", GREEN, RESET); + openBrowser(extendURL); + } + exit(1); + } + + if (valid) { + // Valid key + writefln("%sValid%s", GREEN, RESET); + writefln("Public Key: %s", publicKey); + writefln("Tier: %s", tier); + writefln("Status: %s", status); + + if ("expires_at" in result) { + string expiresAt = result["expires_at"].str; + writefln("Expires: %s", expiresAt); + + // Calculate time remaining (simplified - just show the date) + // Full datetime parsing would require additional complexity + } + + if ("rate_limit" in result && result["rate_limit"].type != JSONType.null_) { + writefln("Rate Limit: %.0f req/min", result["rate_limit"].floating); + } + if ("burst" in result && result["burst"].type != JSONType.null_) { + writefln("Burst: %.0f req", result["burst"].floating); + } + if ("concurrency" in result && result["concurrency"].type != JSONType.null_) { + writefln("Concurrency: %.0f", result["concurrency"].floating); + } + + if (extend) { + string extendURL = PORTAL_BASE ~ "/keys/extend?pk=" ~ publicKey; + writefln("\n%sOpening browser to extend key...%s", GREEN, RESET); + openBrowser(extendURL); + } + } else { + // Invalid key + writefln("%sInvalid%s", RED, RESET); + if ("error" in result) { + writefln("Reason: %s", result["error"].str); + } + exit(1); + } +} + int main(string[] args) { string apiKey = environment.get("UNSANDBOX_API_KEY", ""); @@ -230,6 +351,7 @@ 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 key [options]", args[0]); return 1; } @@ -281,6 +403,23 @@ int main(string[] args) { return 0; } + if (args[1] == "key") { + bool extend = false; + + for (size_t i = 2; i < args.length; i++) { + if (args[i] == "--extend") extend = true; + else if (args[i] == "-k" && i+1 < args.length) apiKey = args[++i]; + } + + if (apiKey.empty) { + stderr.writefln("%sError: UNSANDBOX_API_KEY not set%s", RED, RESET); + return 1; + } + + validateKey(apiKey, extend); + return 0; + } + // Execute mode string[] envs; bool artifacts = false; diff --git a/un.dart b/un.dart index 7b05549..2fe1b14 100644 --- a/un.dart +++ b/un.dart @@ -44,6 +44,7 @@ import 'dart:io'; import 'dart:convert'; const String apiBase = 'https://api.unsandbox.com'; +const String portalBase = 'https://unsandbox.com'; const String blue = '\x1B[34m'; const String red = '\x1B[31m'; const String green = '\x1B[32m'; @@ -90,6 +91,7 @@ class Args { String? serviceSleep; String? serviceWake; String? serviceDestroy; + bool keyExtend = false; } String getApiKey(String? argsKey) { @@ -114,7 +116,8 @@ String detectLanguage(String filename) { return lang; } -Future> apiRequestCurl(String endpoint, String method, String? jsonData, String apiKey) async { +Future> apiRequestCurl(String endpoint, String method, String? jsonData, String apiKey, {String? baseUrl}) async { + final base = baseUrl ?? apiBase; final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.json').create(); try { @@ -122,7 +125,7 @@ Future> apiRequestCurl(String endpoint, String method, Stri await tempFile.writeAsString(jsonData); } - final args = ['curl', '-s', '-X', method, '$apiBase$endpoint', + final args = ['curl', '-s', '-X', method, '$base$endpoint', '-H', 'Content-Type: application/json', '-H', 'Authorization: Bearer $apiKey']; @@ -348,6 +351,51 @@ Future cmdService(Args args) async { exit(1); } +Future cmdKey(Args args) async { + final apiKey = getApiKey(args.apiKey); + + try { + final result = await apiRequestCurl('/keys/validate', 'POST', null, apiKey, baseUrl: portalBase); + + final status = result['status'] as String?; + final publicKey = result['public_key'] as String?; + final tier = result['tier'] as String?; + final expiresAt = result['expires_at'] as String?; + + if (status == 'valid') { + print('${green}Valid$reset'); + if (publicKey != null) print('Public Key: $publicKey'); + if (tier != null) print('Tier: $tier'); + if (expiresAt != null) print('Expires: $expiresAt'); + } else if (status == 'expired') { + print('${red}Expired$reset'); + if (publicKey != null) print('Public Key: $publicKey'); + if (tier != null) print('Tier: $tier'); + if (expiresAt != null) print('Expired: $expiresAt'); + print('${yellow}To renew: Visit $portalBase/keys/extend$reset'); + } else { + print('${red}Invalid$reset'); + } + + if (args.keyExtend && publicKey != null) { + final url = '$portalBase/keys/extend?pk=$publicKey'; + print('${yellow}Opening: $url$reset'); + if (Platform.isMacOS) { + await Process.run('open', [url]); + } else if (Platform.isLinux) { + await Process.run('xdg-open', [url]); + } else if (Platform.isWindows) { + await Process.run('cmd', ['/c', 'start', url]); + } else { + print('${yellow}Please open manually: $url$reset'); + } + } + } catch (e) { + stderr.writeln('${red}Error validating key: $e$reset'); + exit(1); + } +} + Args parseArgs(List argv) { final args = Args(); var i = 0; @@ -359,6 +407,9 @@ Args parseArgs(List argv) { case 'service': args.command = 'service'; break; + case 'key': + args.command = 'key'; + break; case '-k': case '--api-key': args.apiKey = argv[++i]; @@ -432,6 +483,9 @@ Args parseArgs(List argv) { case '--destroy': args.serviceDestroy = argv[++i]; break; + case '--extend': + args.keyExtend = true; + break; default: if (!argv[i].startsWith('-')) { args.sourceFile = argv[i]; @@ -447,6 +501,7 @@ void printHelp() { Usage: dart un.dart [options] dart un.dart session [options] dart un.dart service [options] + dart un.dart key [options] Execute options: -e KEY=VALUE Set environment variable @@ -474,6 +529,9 @@ Service options: --sleep ID Freeze service --wake ID Unfreeze service --destroy ID Destroy service + +Key options: + --extend Open browser to extend key '''); } @@ -485,6 +543,8 @@ void main(List arguments) async { await cmdSession(args); } else if (args.command == 'service') { await cmdService(args); + } else if (args.command == 'key') { + await cmdKey(args); } else if (args.sourceFile != null) { await cmdExecute(args); } else { diff --git a/un.erl b/un.erl old mode 100644 new mode 100755 index e77111c..8cac9a5 --- a/un.erl +++ b/un.erl @@ -46,6 +46,7 @@ main([]) -> io:format("Usage: un.erl [options] ~n"), io:format(" un.erl session [options]~n"), io:format(" un.erl service [options]~n"), + io:format(" un.erl key [options]~n"), halt(1); main(["session" | Rest]) -> @@ -54,6 +55,9 @@ main(["session" | Rest]) -> main(["service" | Rest]) -> service_command(Rest); +main(["key" | Rest]) -> + key_command(Rest); + main(Args) -> execute_command(Args). @@ -173,6 +177,70 @@ service_command(Args) -> io:format("~s~n", [Response]) end. +%% Key command +key_command(Args) -> + ApiKey = get_api_key(), + case has_extend_flag(Args) of + true -> + validate_and_extend_key(ApiKey); + false -> + validate_key(ApiKey) + end. + +validate_key(ApiKey) -> + Response = curl_post_portal(ApiKey, "/keys/validate", "{}"), + parse_and_display_key_status(Response, false). + +validate_and_extend_key(ApiKey) -> + Response = curl_post_portal(ApiKey, "/keys/validate", "{}"), + parse_and_display_key_status(Response, true). + +parse_and_display_key_status(Response, ShouldExtend) -> + %% Parse JSON response (simple extraction for fields we need) + Status = extract_json_field(Response, "status"), + PublicKey = extract_json_field(Response, "public_key"), + Tier = extract_json_field(Response, "tier"), + ExpiresAt = extract_json_field(Response, "expires_at"), + + case Status of + "valid" -> + io:format("\033[32mValid\033[0m~n"), + io:format("Public Key: ~s~n", [PublicKey]), + io:format("Tier: ~s~n", [Tier]), + io:format("Expires: ~s~n", [ExpiresAt]), + if ShouldExtend -> + open_extend_page(PublicKey); + true -> ok + end; + "expired" -> + io:format("\033[31mExpired\033[0m~n"), + io:format("Public Key: ~s~n", [PublicKey]), + io:format("Tier: ~s~n", [Tier]), + io:format("Expired: ~s~n", [ExpiresAt]), + io:format("\033[33mTo renew: Visit https://unsandbox.com/keys/extend\033[0m~n"), + if ShouldExtend -> + open_extend_page(PublicKey); + true -> ok + end; + "invalid" -> + io:format("\033[31mInvalid\033[0m~n"), + io:format("The API key is not valid.~n"); + _ -> + io:format("~s~n", [Response]) + end. + +open_extend_page(PublicKey) -> + Url = "https://unsandbox.com/keys/extend?pk=" ++ PublicKey, + io:format("\033[33mOpening browser to extend key...\033[0m~n"), + case os:type() of + {unix, darwin} -> + os:cmd("open '" ++ Url ++ "'"); + {unix, _} -> + os:cmd("xdg-open '" ++ Url ++ "' 2>/dev/null || sensible-browser '" ++ Url ++ "' 2>/dev/null &"); + {win32, _} -> + os:cmd("start " ++ Url) + end. + %% Helpers get_api_key() -> case os:getenv("UNSANDBOX_API_KEY") of @@ -250,6 +318,16 @@ curl_post(ApiKey, Endpoint, TmpFile) -> " -d @" ++ TmpFile, os:cmd(Cmd). +curl_post_portal(ApiKey, Endpoint, Data) -> + TmpFile = write_temp_file(Data), + Cmd = "curl -s -X POST https://unsandbox.com" ++ Endpoint ++ + " -H 'Content-Type: application/json'" ++ + " -H 'Authorization: Bearer " ++ ApiKey ++ "'" ++ + " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + file:delete(TmpFile), + Result. + curl_get(ApiKey, Endpoint) -> Cmd = "curl -s https://api.unsandbox.com" ++ Endpoint ++ " -H 'Authorization: Bearer " ++ ApiKey ++ "'", @@ -289,3 +367,30 @@ get_service_bootstrap([_ | Rest]) -> get_service_bootstrap(Rest). get_service_type([]) -> undefined; get_service_type(["--type", Type | _]) -> Type; get_service_type([_ | Rest]) -> get_service_type(Rest). + +has_extend_flag([]) -> false; +has_extend_flag(["--extend" | _]) -> true; +has_extend_flag([_ | Rest]) -> has_extend_flag(Rest). + +%% Simple JSON field extraction (works for simple string fields) +extract_json_field(Json, Field) -> + Pattern = "\"" ++ Field ++ "\":\"", + case string:str(Json, Pattern) of + 0 -> ""; + Pos -> + Start = Pos + length(Pattern), + Rest = lists:nthtail(Start - 1, Json), + extract_until_quote(Rest) + end. + +extract_until_quote(Str) -> + extract_until_quote(Str, []). + +extract_until_quote([], Acc) -> + lists:reverse(Acc); +extract_until_quote([$\" | _], Acc) -> + lists:reverse(Acc); +extract_until_quote([$\\, $\" | Rest], Acc) -> + extract_until_quote(Rest, [$\" | Acc]); +extract_until_quote([C | Rest], Acc) -> + extract_until_quote(Rest, [C | Acc]). diff --git a/un.ex b/un.ex old mode 100644 new mode 100755 index d060b84..98d558a --- a/un.ex +++ b/un.ex @@ -58,6 +58,8 @@ defmodule Un do @yellow "\e[33m" @reset "\e[0m" + @portal_base "https://unsandbox.com" + @ext_map %{ ".ex" => "elixir", ".exs" => "elixir", ".erl" => "erlang", ".py" => "python", ".js" => "javascript", ".ts" => "typescript", @@ -76,12 +78,14 @@ defmodule Un do def main([]), do: print_usage() def main(["session" | rest]), do: session_command(rest) def main(["service" | rest]), do: service_command(rest) + def main(["key" | rest]), do: key_command(rest) def main(args), do: execute_command(args) defp print_usage do IO.puts("Usage: un.ex [options] ") IO.puts(" un.ex session [options]") IO.puts(" un.ex service [options]") + IO.puts(" un.ex key [--extend]") System.halt(1) end @@ -207,6 +211,134 @@ defmodule Un do IO.puts(response) end + # Key command + defp key_command(args) do + api_key = get_api_key() + + if "--extend" in args do + validate_key(api_key, extend: true) + else + validate_key(api_key, extend: false) + end + end + + defp validate_key(api_key, extend: extend) do + json = "{}" + response = portal_curl_post(api_key, "/keys/validate", json) + + # Try to use Jason if available, otherwise fall back to manual parsing + try do + case Jason.decode(response) do + {:ok, data} -> + display_key_info(data, extend) + + {:error, _} -> + # Fallback if Jason is not available, parse manually + display_key_info_manual(response, extend) + end + rescue + UndefinedFunctionError -> + # If Jason module doesn't exist, use manual parsing + display_key_info_manual(response, extend) + end + end + + defp display_key_info(data, extend) do + status = Map.get(data, "status") + public_key = Map.get(data, "public_key") + tier = Map.get(data, "tier") + expires_at = Map.get(data, "expires_at") + + case status do + "valid" -> + IO.puts("#{@green}Valid#{@reset}") + IO.puts("Public Key: #{public_key}") + IO.puts("Tier: #{tier}") + IO.puts("Expires: #{expires_at}") + + if extend do + open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") + end + + "expired" -> + IO.puts("#{@red}Expired#{@reset}") + IO.puts("Public Key: #{public_key}") + IO.puts("Tier: #{tier}") + IO.puts("Expired: #{expires_at}") + IO.puts("#{@yellow}To renew: Visit #{@portal_base}/keys/extend#{@reset}") + + if extend do + open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") + end + + "invalid" -> + IO.puts("#{@red}Invalid#{@reset}") + + _ -> + IO.puts("#{@red}Unknown status: #{status}#{@reset}") + end + end + + defp display_key_info_manual(response, extend) do + # Simple manual parsing for JSON response + status = extract_json_value(response, "status") + public_key = extract_json_value(response, "public_key") + tier = extract_json_value(response, "tier") + expires_at = extract_json_value(response, "expires_at") + + case status do + "valid" -> + IO.puts("#{@green}Valid#{@reset}") + IO.puts("Public Key: #{public_key}") + IO.puts("Tier: #{tier}") + IO.puts("Expires: #{expires_at}") + + if extend do + open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") + end + + "expired" -> + IO.puts("#{@red}Expired#{@reset}") + IO.puts("Public Key: #{public_key}") + IO.puts("Tier: #{tier}") + IO.puts("Expired: #{expires_at}") + IO.puts("#{@yellow}To renew: Visit #{@portal_base}/keys/extend#{@reset}") + + if extend do + open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") + end + + "invalid" -> + IO.puts("#{@red}Invalid#{@reset}") + + _ -> + IO.puts("#{@red}Unknown status: #{status}#{@reset}") + IO.puts(response) + end + end + + defp extract_json_value(json_str, key) do + case Regex.run(~r/"#{key}"\s*:\s*"([^"]*)"/, json_str) do + [_, value] -> value + _ -> nil + end + end + + defp open_browser(url) do + IO.puts("#{@blue}Opening browser: #{url}#{@reset}") + + case :os.type() do + {:unix, :linux} -> + System.cmd("xdg-open", [url], stderr_to_stdout: true) + {:unix, :darwin} -> + System.cmd("open", [url], stderr_to_stdout: true) + {:win32, _} -> + System.cmd("cmd", ["/c", "start", url], stderr_to_stdout: true) + _ -> + IO.puts("#{@yellow}Please open manually: #{url}#{@reset}") + end + end + # Helpers defp get_api_key do case System.get_env("UNSANDBOX_API_KEY") do @@ -246,6 +378,22 @@ defmodule Un do output end + defp portal_curl_post(api_key, endpoint, json) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, json) + + {output, _exit} = System.cmd("curl", [ + "-s", "-X", "POST", + "#{@portal_base}#{endpoint}", + "-H", "Content-Type: application/json", + "-H", "Authorization: Bearer #{api_key}", + "-d", "@#{tmp_file}" + ], stderr_to_stdout: true) + + File.rm(tmp_file) + output + end + defp curl_get(api_key, endpoint) do {output, _exit} = System.cmd("curl", [ "-s", diff --git a/un.go b/un.go index 3b9c794..a97e351 100644 --- a/un.go +++ b/un.go @@ -54,18 +54,22 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" + "runtime" "strconv" "strings" + "time" ) const ( - APIBase = "https://api.unsandbox.com" - Blue = "\033[34m" - Red = "\033[31m" - Green = "\033[32m" - Yellow = "\033[33m" - Reset = "\033[0m" + APIBase = "https://api.unsandbox.com" + PortalBase = "https://unsandbox.com" + Blue = "\033[34m" + Red = "\033[31m" + Green = "\033[32m" + Yellow = "\033[33m" + Reset = "\033[0m" ) var extMap = map[string]string{ @@ -454,6 +458,154 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB os.Exit(1) } +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return fmt.Errorf("unsupported platform") + } + return cmd.Start() +} + +func formatDuration(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } else if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } else { + return fmt.Sprintf("%dm", minutes) + } +} + +func validateKey(apiKey string, extend bool) { + url := PortalBase + "/keys/validate" + reqBody := bytes.NewBuffer(nil) + + req, err := http.NewRequest("POST", url, reqBody) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError creating request: %v%s\n", Red, err, Reset) + os.Exit(1) + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError making request: %v%s\n", Red, err, Reset) + os.Exit(1) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError reading response: %v%s\n", Red, err, Reset) + os.Exit(1) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + fmt.Fprintf(os.Stderr, "%sError parsing response: %v%s\n", Red, err, Reset) + os.Exit(1) + } + + if resp.StatusCode >= 400 { + // Invalid key + fmt.Printf("%sInvalid%s\n", Red, Reset) + if reason, ok := result["error"].(string); ok { + fmt.Printf("Reason: %s\n", reason) + } else if reason, ok := result["message"].(string); ok { + fmt.Printf("Reason: %s\n", reason) + } + os.Exit(1) + } + + valid, _ := result["valid"].(bool) + expired, _ := result["expired"].(bool) + publicKey, _ := result["public_key"].(string) + tier, _ := result["tier"].(string) + status, _ := result["status"].(string) + + if expired { + // Expired key + fmt.Printf("%sExpired%s\n", Red, Reset) + fmt.Printf("Public Key: %s\n", publicKey) + fmt.Printf("Tier: %s\n", tier) + if expiresAt, ok := result["expires_at"].(string); ok { + fmt.Printf("Expired: %s\n", expiresAt) + } + fmt.Printf("%sTo renew: Visit https://unsandbox.com/keys/extend%s\n", Yellow, Reset) + + if extend { + extendURL := PortalBase + "/keys/extend?pk=" + publicKey + fmt.Printf("\n%sOpening browser to extend key...%s\n", Green, Reset) + if err := openBrowser(extendURL); err != nil { + fmt.Fprintf(os.Stderr, "%sError opening browser: %v%s\n", Red, err, Reset) + fmt.Printf("Please visit: %s\n", extendURL) + } + } + os.Exit(1) + } + + if valid { + // Valid key + fmt.Printf("%sValid%s\n", Green, Reset) + fmt.Printf("Public Key: %s\n", publicKey) + fmt.Printf("Tier: %s\n", tier) + fmt.Printf("Status: %s\n", status) + + if expiresAt, ok := result["expires_at"].(string); ok { + fmt.Printf("Expires: %s\n", expiresAt) + + // Calculate time remaining + expireTime, err := time.Parse(time.RFC3339, expiresAt) + if err == nil { + remaining := time.Until(expireTime) + if remaining > 0 { + fmt.Printf("Time Remaining: %s\n", formatDuration(remaining)) + } + } + } + + if rateLimit, ok := result["rate_limit"].(float64); ok { + fmt.Printf("Rate Limit: %.0f req/min\n", rateLimit) + } + if burst, ok := result["burst"].(float64); ok { + fmt.Printf("Burst: %.0f req\n", burst) + } + if concurrency, ok := result["concurrency"].(float64); ok { + fmt.Printf("Concurrency: %.0f\n", concurrency) + } + + if extend { + extendURL := PortalBase + "/keys/extend?pk=" + publicKey + fmt.Printf("\n%sOpening browser to extend key...%s\n", Green, Reset) + if err := openBrowser(extendURL); err != nil { + fmt.Fprintf(os.Stderr, "%sError opening browser: %v%s\n", Red, err, Reset) + fmt.Printf("Please visit: %s\n", extendURL) + } + } + } else { + // Invalid key + fmt.Printf("%sInvalid%s\n", Red, Reset) + if reason, ok := result["error"].(string); ok { + fmt.Printf("Reason: %s\n", reason) + } + os.Exit(1) + } +} + func main() { // Common flags apiKey := flag.String("k", "", "API key (or set UNSANDBOX_API_KEY)") @@ -497,6 +649,11 @@ func main() { serviceVcpu := serviceCmd.Int("v", 0, "vCPU count") serviceKey := serviceCmd.String("k", "", "API key") + // Key flags + keyCmd := flag.NewFlagSet("key", flag.ExitOnError) + keyExtend := keyCmd.Bool("extend", false, "Open browser to extend key") + keyKey := keyCmd.String("k", "", "API key") + // Parse flag.Parse() @@ -529,6 +686,12 @@ func main() { } cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, net, vc, key) return + + case "key": + keyCmd.Parse(os.Args[2:]) + key := getAPIKey(*keyKey) + validateKey(key, *keyExtend) + return } } @@ -537,6 +700,7 @@ func main() { fmt.Fprintf(os.Stderr, "Usage: %s [options] \n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s session [options]\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s service [options]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s key [options]\n", os.Args[0]) os.Exit(1) } diff --git a/un.hs b/un.hs index 5b9914a..609f610 100644 --- a/un.hs +++ b/un.hs @@ -68,6 +68,10 @@ import Control.Monad (when, unless, forM_) import qualified Data.ByteString as BS import qualified Data.ByteString.Base64 as B64 +-- API constants +portalBase :: String +portalBase = "https://unsandbox.com" + -- ANSI colors blue, red, green, yellow, reset :: String blue = "\x1b[34m" @@ -105,7 +109,7 @@ escapeJSON = concatMap escape escape c = [c] -- Parse command line arguments -data Command = Execute ExecuteOpts | Session SessionOpts | Service ServiceOpts | Help +data Command = Execute ExecuteOpts | Session SessionOpts | Service ServiceOpts | Key KeyOpts | Help data ExecuteOpts = ExecuteOpts { exFile :: String @@ -140,12 +144,25 @@ data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String | ServiceSleep String | ServiceWake String | ServiceDestroy String | ServiceCreate +data KeyOpts = KeyOpts + { keyExtend :: Bool + } + -- Parse arguments parseArgs :: [String] -> IO Command parseArgs ("session":rest) = Session <$> parseSession rest parseArgs ("service":rest) = Service <$> parseService rest +parseArgs ("key":rest) = Key <$> parseKey rest parseArgs args = parseExecute args +parseKey :: [String] -> IO KeyOpts +parseKey args = return $ parseKeyArgs args defaultKeyOpts + where + defaultKeyOpts = KeyOpts False + parseKeyArgs [] opts = opts + parseKeyArgs ("--extend":rest) opts = parseKeyArgs rest opts { keyExtend = True } + parseKeyArgs (_:rest) opts = parseKeyArgs rest opts + parseSession :: [String] -> IO SessionOpts parseSession args = return $ parseSessionArgs args defaultSessionOpts where @@ -208,6 +225,7 @@ main = do Execute opts -> executeCommand opts Session opts -> sessionCommand opts Service opts -> serviceCommand opts + Key opts -> keyCommand opts Help -> printHelp printHelp :: IO () @@ -216,6 +234,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 key [options] Validate/extend API key" putStrLn "" putStrLn "Execute options:" putStrLn " -e KEY=VALUE Environment variable" @@ -224,6 +243,9 @@ printHelp = do putStrLn " -o DIR Output directory" putStrLn " -n MODE Network mode (zerotrust|semitrusted)" putStrLn " -v N vCPU count (1-8)" + putStrLn "" + putStrLn "Key options:" + putStrLn " --extend Open browser to extend/renew key" exitFailure -- Execute command @@ -259,7 +281,7 @@ executeCommand opts = do ++ envJSON ++ filesJSON ++ artifactsJSON ++ networkJSON ++ vcpuJSON ++ "}" -- Call API - (exitCode, stdout, stderr) <- curlPost apiKey "/execute" json + (exitCode, stdout, stderr) <- curlPost apiKey "https://api.unsandbox.com/execute" json -- Print output unless (null stdout) $ putStr $ blue ++ stdout ++ reset @@ -275,17 +297,17 @@ sessionCommand opts = do apiKey <- getApiKey case sessAction opts of SessionList -> do - (_, stdout, _) <- curlGet apiKey "/sessions" + (_, stdout, _) <- curlGet apiKey "https://api.unsandbox.com/sessions" putStrLn stdout SessionKill sid -> do - (_, stdout, _) <- curlDelete apiKey ("/sessions/" ++ sid) + (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/sessions/" ++ sid) putStrLn $ green ++ "Session terminated: " ++ sid ++ reset SessionCreate -> do let shell = maybe "bash" id (sessShell opts) let networkJSON = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") (sessNetwork opts) let vcpuJSON = maybe "" (\v -> ",\"vcpu\":" ++ show v) (sessVcpu opts) let json = "{\"shell\":\"" ++ shell ++ "\"" ++ networkJSON ++ vcpuJSON ++ "}" - (_, stdout, _) <- curlPost apiKey "/sessions" json + (_, stdout, _) <- curlPost apiKey "https://api.unsandbox.com/sessions" json putStrLn $ yellow ++ "Session created (WebSocket required for interactivity)" ++ reset putStrLn stdout @@ -295,22 +317,22 @@ serviceCommand opts = do apiKey <- getApiKey case svcAction opts of ServiceList -> do - (_, stdout, _) <- curlGet apiKey "/services" + (_, stdout, _) <- curlGet apiKey "https://api.unsandbox.com/services" putStrLn stdout ServiceInfo sid -> do - (_, stdout, _) <- curlGet apiKey ("/services/" ++ sid) + (_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/services/" ++ sid) putStrLn stdout ServiceLogs sid -> do - (_, stdout, _) <- curlGet apiKey ("/services/" ++ sid ++ "/logs") + (_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/logs") putStrLn stdout ServiceSleep sid -> do - (_, stdout, _) <- curlPost apiKey ("/services/" ++ sid ++ "/sleep") "{}" + (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/sleep") "{}" putStrLn $ green ++ "Service sleeping: " ++ sid ++ reset ServiceWake sid -> do - (_, stdout, _) <- curlPost apiKey ("/services/" ++ sid ++ "/wake") "{}" + (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/wake") "{}" putStrLn $ green ++ "Service waking: " ++ sid ++ reset ServiceDestroy sid -> do - (_, stdout, _) <- curlDelete apiKey ("/services/" ++ sid) + (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/services/" ++ sid) putStrLn $ green ++ "Service destroyed: " ++ sid ++ reset ServiceCreate -> do case svcName opts of @@ -324,16 +346,16 @@ serviceCommand opts = do let networkJSON = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") (svcNetwork opts) let vcpuJSON = maybe "" (\v -> ",\"vcpu\":" ++ show v) (svcVcpu opts) let json = "{\"name\":\"" ++ name ++ "\"" ++ portsJSON ++ typeJSON ++ bootstrapJSON ++ networkJSON ++ vcpuJSON ++ "}" - (_, stdout, _) <- curlPost apiKey "/services" json + (_, stdout, _) <- curlPost apiKey "https://api.unsandbox.com/services" json putStrLn $ green ++ "Service created" ++ reset putStrLn stdout -- HTTP helpers using curl curlPost :: String -> String -> String -> IO (ExitCode, String, String) -curlPost apiKey endpoint body = do +curlPost apiKey url body = do (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" [ "-s", "-X", "POST" - , "https://api.unsandbox.com" ++ endpoint + , url , "-H", "Content-Type: application/json" , "-H", "Authorization: Bearer " ++ apiKey , "-d", body @@ -341,17 +363,17 @@ curlPost apiKey endpoint body = do return (exitCode, stdout, stderr) curlGet :: String -> String -> IO (ExitCode, String, String) -curlGet apiKey endpoint = +curlGet apiKey url = readProcessWithExitCode "curl" - [ "-s", "https://api.unsandbox.com" ++ endpoint + [ "-s", url , "-H", "Authorization: Bearer " ++ apiKey ] "" curlDelete :: String -> String -> IO (ExitCode, String, String) -curlDelete apiKey endpoint = +curlDelete apiKey url = readProcessWithExitCode "curl" [ "-s", "-X", "DELETE" - , "https://api.unsandbox.com" ++ endpoint + , url , "-H", "Authorization: Bearer " ++ apiKey ] "" @@ -377,3 +399,136 @@ parseExitCode resp = Just (_, ':':v) -> Just $ takeWhile isDigit v _ -> Nothing find f = foldr (\x acc -> if f x then Just x else acc) Nothing + +-- Extract JSON string field (simple parser for basic cases) +extractJsonString :: String -> String -> Maybe String +extractJsonString json field = + case break (== '"') rest of + (_, '"':value) -> + case break (== '"') value of + (v, _) -> Just v + _ -> Nothing + where + needle = "\"" ++ field ++ "\":" + rest = case dropWhile (/= '"') $ dropWhile (not . isPrefixOf needle) $ tails json of + (_:xs) -> case dropWhile (/= ':') xs of + (_:ys) -> dropWhile (`elem` " \t\n") ys + _ -> "" + _ -> "" + tails [] = [[]] + tails s@(_:xs) = s : tails xs + +-- Key command +keyCommand :: KeyOpts -> IO () +keyCommand opts = do + apiKey <- getApiKey + + if keyExtend opts + then extendKey apiKey + else validateKey apiKey + +-- Validate API key and show status +validateKey :: String -> IO () +validateKey apiKey = do + let url = portalBase ++ "/keys/validate" + (exitCode, stdout, stderr) <- curlPost apiKey url "{}" + + -- Check if valid:false appears in response + let isInvalid = "\"valid\":false" `isPrefixOf` dropWhile (/= 'v') stdout + + if exitCode /= ExitSuccess || isInvalid + then do + -- Parse error response + let reason = extractJsonString stdout "reason" + case reason of + Just "expired" -> do + putStrLn $ red ++ "Expired" ++ reset ++ "\n" + + -- Show key details + case extractJsonString stdout "public_key" of + Just pk -> putStrLn $ "Public Key: " ++ pk + Nothing -> return () + + case extractJsonString stdout "tier" of + Just tier -> putStrLn $ "Tier: " ++ tier + Nothing -> return () + + case extractJsonString stdout "expired_at_datetime" of + Just expiredAt -> do + putStr $ "Expired: " ++ expiredAt + case extractJsonString stdout "expired_ago" of + Just ago -> putStrLn $ " (" ++ ago ++ ")" + Nothing -> putStrLn "" + Nothing -> return () + + putStrLn "" + putStrLn $ yellow ++ "To renew:" ++ reset ++ " Visit https://unsandbox.com/keys/extend" + exitFailure + + Just "invalid_key" -> do + putStrLn $ red ++ "Invalid" ++ reset ++ ": key not found" + exitFailure + + Just "suspended" -> do + putStrLn $ red ++ "Suspended" ++ reset ++ ": key has been suspended" + exitFailure + + _ -> do + putStrLn $ red ++ "Invalid key" ++ reset + exitFailure + else do + -- Parse valid response + putStrLn $ green ++ "Valid" ++ reset ++ "\n" + + case extractJsonString stdout "public_key" of + Just pk -> putStrLn $ "Public Key: " ++ pk + Nothing -> return () + + case extractJsonString stdout "tier" of + Just tier -> putStrLn $ "Tier: " ++ tier + Nothing -> return () + + case extractJsonString stdout "status" of + Just status -> putStrLn $ "Status: " ++ status + Nothing -> return () + + case extractJsonString stdout "valid_through_datetime" of + Just validThrough -> putStrLn $ "Expires: " ++ validThrough + Nothing -> return () + + case extractJsonString stdout "valid_for_human" of + Just validFor -> putStrLn $ "Time Remaining: " ++ validFor + Nothing -> return () + + case extractJsonString stdout "rate_per_minute" of + Just rate -> putStrLn $ "Rate Limit: " ++ rate ++ "/min" + Nothing -> return () + + case extractJsonString stdout "burst" of + Just burst -> putStrLn $ "Burst: " ++ burst + Nothing -> return () + + case extractJsonString stdout "concurrency" of + Just conc -> putStrLn $ "Concurrency: " ++ conc + Nothing -> return () + +-- Extend key (open browser to extend page) +extendKey :: String -> IO () +extendKey apiKey = do + let url = portalBase ++ "/keys/validate" + (exitCode, stdout, _) <- curlPost apiKey url "{}" + + case extractJsonString stdout "public_key" of + Nothing -> do + hPutStrLn stderr "Error: Invalid key or could not retrieve public key" + exitFailure + Just publicKey -> do + let extendUrl = portalBase ++ "/keys/extend?pk=" ++ publicKey + putStrLn "Opening extension page in browser..." + putStrLn $ "If browser doesn't open, visit: " ++ extendUrl + + -- Try to open URL in browser (Linux-specific) + _ <- readProcessWithExitCode "sh" + ["-c", "xdg-open '" ++ extendUrl ++ "' 2>/dev/null || sensible-browser '" ++ extendUrl ++ "' 2>/dev/null || true"] + "" + return () diff --git a/un.jl b/un.jl old mode 100644 new mode 100755 index efa80d1..b4fc0ad --- a/un.jl +++ b/un.jl @@ -41,6 +41,7 @@ using HTTP using JSON using Base64 using ArgParse +using Printf # Extension to language mapping const EXT_MAP = Dict( @@ -67,6 +68,7 @@ const YELLOW = "\033[33m" const RESET = "\033[0m" const API_BASE = "https://api.unsandbox.com" +const PORTAL_BASE = "https://unsandbox.com" function detect_language(filename::String)::String ext = lowercase(match(r"\.[^.]+$", filename).match) @@ -334,6 +336,149 @@ function cmd_service(args) exit(1) end +function validate_key(api_key::String) + url = PORTAL_BASE * "/keys/validate" + headers = [ + "Authorization" => "Bearer $api_key", + "Content-Type" => "application/json" + ] + + try + response = HTTP.post(url, headers, "{}", readtimeout=300) + data = JSON.parse(String(response.body)) + + # Check if valid + if get(data, "valid", false) + # Print valid key info + println("$(GREEN)Valid$(RESET)\n") + println(@sprintf("%-20s %s", "Public Key:", get(data, "public_key", "N/A"))) + println(@sprintf("%-20s %s", "Tier:", get(data, "tier", "N/A"))) + println(@sprintf("%-20s %s", "Status:", get(data, "status", "N/A"))) + println(@sprintf("%-20s %s", "Expires:", get(data, "valid_through_datetime", "N/A"))) + println(@sprintf("%-20s %s", "Time Remaining:", get(data, "valid_for_human", "N/A"))) + println(@sprintf("%-20s %s/min", "Rate Limit:", get(data, "rate_per_minute", "N/A"))) + println(@sprintf("%-20s %s", "Burst:", get(data, "burst", "N/A"))) + println(@sprintf("%-20s %s", "Concurrency:", get(data, "concurrency", "N/A"))) + return 0 + else + # Handle invalid response + reason = get(data, "reason", "unknown") + if reason == "expired" + println("$(RED)Expired$(RESET)\n") + println(@sprintf("%-20s %s", "Public Key:", get(data, "public_key", "N/A"))) + println(@sprintf("%-20s %s", "Tier:", get(data, "tier", "N/A"))) + expired_at = get(data, "expired_at_datetime", "N/A") + expired_ago = get(data, "expired_ago", "") + if !isempty(expired_ago) + println(@sprintf("%-20s %s (%s)", "Expired:", expired_at, expired_ago)) + else + println(@sprintf("%-20s %s", "Expired:", expired_at)) + end + renew_url = get(data, "renew_url", "https://unsandbox.com/pricing") + println("\n$(YELLOW)To renew:$(RESET) Visit $renew_url") + elseif reason == "invalid_key" + println("$(RED)Invalid$(RESET): key not found") + elseif reason == "suspended" + println("$(RED)Suspended$(RESET): key has been suspended") + else + println("$(RED)Invalid$(RESET): $reason") + end + return 1 + end + catch e + if isa(e, HTTP.ExceptionRequest.StatusError) + # Parse error response from body + try + data = JSON.parse(String(e.response.body)) + reason = get(data, "reason", "unknown") + + if reason == "expired" + println("$(RED)Expired$(RESET)\n") + println(@sprintf("%-20s %s", "Public Key:", get(data, "public_key", "N/A"))) + println(@sprintf("%-20s %s", "Tier:", get(data, "tier", "N/A"))) + expired_at = get(data, "expired_at_datetime", "N/A") + expired_ago = get(data, "expired_ago", "") + if !isempty(expired_ago) + println(@sprintf("%-20s %s (%s)", "Expired:", expired_at, expired_ago)) + else + println(@sprintf("%-20s %s", "Expired:", expired_at)) + end + renew_url = get(data, "renew_url", "https://unsandbox.com/pricing") + println("\n$(YELLOW)To renew:$(RESET) Visit $renew_url") + elseif reason == "invalid_key" + println("$(RED)Invalid$(RESET): key not found") + elseif reason == "suspended" + println("$(RED)Suspended$(RESET): key has been suspended") + else + println("$(RED)Invalid$(RESET): $reason") + end + catch + println(stderr, "$(RED)Error: HTTP $(e.status)$(RESET)") + end + return 1 + else + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + return 1 + end + end +end + +function cmd_key(args) + api_key = get_api_key(args["api-key"]) + + # Handle --extend flag + if args["extend"] + # Validate key to get public key + url = PORTAL_BASE * "/keys/validate" + headers = [ + "Authorization" => "Bearer $api_key", + "Content-Type" => "application/json" + ] + + try + response = HTTP.post(url, headers, "{}", readtimeout=300) + data = JSON.parse(String(response.body)) + + public_key = get(data, "public_key", nothing) + if public_key === nothing + println(stderr, "$(RED)Error: Invalid key or could not retrieve public key$(RESET)") + exit(1) + end + + # Build extend URL + extend_url = "$(PORTAL_BASE)/keys/extend?pk=$(public_key)" + + println("Opening extension page in browser...") + println("If browser doesn't open, visit: $extend_url") + + # Try to open browser + if Sys.isapple() + run(`open $extend_url`) + elseif Sys.islinux() + try + run(`xdg-open $extend_url`) + catch + try + run(`sensible-browser $extend_url`) + catch + # Already printed the URL + end + end + elseif Sys.iswindows() + run(`cmd /c start $extend_url`) + end + + exit(0) + catch e + println(stderr, "$(RED)Error: Failed to validate key: $e$(RESET)") + exit(1) + end + end + + # Default: validate and display key info + exit(validate_key(api_key)) +end + function main() s = ArgParseSettings(description="Unsandbox CLI - Execute code in secure sandboxes") @@ -364,6 +509,9 @@ function main() "service" help = "Manage persistent services" action = :command + "key" + help = "Check API key validity and expiration" + action = :command end @add_arg_table! s["session"] begin @@ -412,16 +560,26 @@ function main() help = "API key" end + @add_arg_table! s["key"] begin + "--extend" + help = "Open browser to extend/renew key" + action = :store_true + "--api-key", "-k" + help = "API key" + end + args = parse_args(ARGS, s) if args["%COMMAND%"] == "session" cmd_session(args["session"]) elseif args["%COMMAND%"] == "service" cmd_service(args["service"]) + elseif args["%COMMAND%"] == "key" + cmd_key(args["key"]) elseif args["source_file"] !== nothing cmd_execute(args) else - println(stderr, "$(RED)Error: Provide source_file or use 'session'/'service' subcommand$(RESET)") + println(stderr, "$(RED)Error: Provide source_file or use 'session'/'service'/'key' subcommand$(RESET)") exit(1) end end diff --git a/un.js b/un.js index f5eaf56..585a0ca 100644 --- a/un.js +++ b/un.js @@ -54,8 +54,10 @@ const fs = require('fs'); const https = require('https'); const path = require('path'); +const { exec } = require('child_process'); const API_BASE = "https://api.unsandbox.com"; +const PORTAL_BASE = "https://unsandbox.com"; const BLUE = "\x1b[34m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; @@ -152,6 +154,124 @@ function apiRequest(endpoint, method = "GET", data = null, apiKey = null) { }); } +function portalRequest(endpoint, method = "GET", data = null, apiKey = null) { + return new Promise((resolve, reject) => { + const url = new URL(PORTAL_BASE + endpoint); + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + method: method, + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 30000 + }; + + const req = https.request(options, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(body)); + } catch (e) { + resolve(body); + } + } else { + try { + const errorBody = JSON.parse(body); + resolve({ error: errorBody.error || body, status: res.statusCode }); + } catch (e) { + resolve({ error: body, status: res.statusCode }); + } + } + }); + }); + + req.on('error', (e) => { + reject(e); + }); + + if (data) { + req.write(JSON.stringify(data)); + } + req.end(); + }); +} + +function openBrowser(url) { + const platform = process.platform; + let command; + + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "${url}"`; + } else { + command = `xdg-open "${url}"`; + } + + exec(command, (error) => { + if (error) { + console.error(`${RED}Error opening browser: ${error.message}${RESET}`); + console.log(`Please visit: ${url}`); + } + }); +} + +async function validateKey(apiKey, shouldExtend = false) { + try { + const result = await portalRequest("/keys/validate", "POST", {}, apiKey); + + if (result.error || result.status >= 400) { + console.log(`${RED}Invalid${RESET}`); + console.log(`Reason: ${result.error || 'Unknown error'}`); + process.exit(1); + } + + if (result.expired) { + console.log(`${RED}Expired${RESET}`); + console.log(`Public Key: ${result.public_key || 'N/A'}`); + console.log(`Tier: ${result.tier || 'N/A'}`); + console.log(`Expired: ${result.expires_at || 'N/A'}`); + console.log(`${YELLOW}To renew: Visit https://unsandbox.com/keys/extend${RESET}`); + + if (shouldExtend && result.public_key) { + const extendUrl = `${PORTAL_BASE}/keys/extend?pk=${encodeURIComponent(result.public_key)}`; + console.log(`\nOpening browser to extend key...`); + openBrowser(extendUrl); + } + + process.exit(1); + } + + console.log(`${GREEN}Valid${RESET}`); + console.log(`Public Key: ${result.public_key || 'N/A'}`); + console.log(`Tier: ${result.tier || 'N/A'}`); + console.log(`Status: ${result.status || 'N/A'}`); + console.log(`Expires: ${result.expires_at || 'N/A'}`); + console.log(`Time Remaining: ${result.time_remaining || 'N/A'}`); + console.log(`Rate Limit: ${result.rate_limit || 'N/A'}`); + console.log(`Burst: ${result.burst || 'N/A'}`); + console.log(`Concurrency: ${result.concurrency || 'N/A'}`); + + if (shouldExtend && result.public_key) { + const extendUrl = `${PORTAL_BASE}/keys/extend?pk=${encodeURIComponent(result.public_key)}`; + console.log(`\nOpening browser to extend key...`); + openBrowser(extendUrl); + } + } catch (error) { + console.error(`${RED}Error validating key: ${error.message}${RESET}`); + process.exit(1); + } +} + +async function cmdKey(args) { + const apiKey = getApiKey(args.apiKey); + await validateKey(apiKey, args.extend); +} + async function cmdExecute(args) { const apiKey = getApiKey(args.apiKey); @@ -377,13 +497,14 @@ function parseArgs(argv) { destroy: null, execute: null, command_arg: null, + extend: false, }; let i = 2; while (i < argv.length) { const arg = argv[i]; - if (arg === 'session' || arg === 'service') { + if (arg === 'session' || arg === 'service' || arg === 'key') { args.command = arg; i++; } else if (arg === '-e' && i + 1 < argv.length) { @@ -467,6 +588,9 @@ function parseArgs(argv) { } else if (arg === '--command' && i + 1 < argv.length) { args.command_arg = argv[++i]; i++; + } else if (arg === '--extend') { + args.extend = true; + i++; } else if (!arg.startsWith('-')) { args.sourceFile = arg; i++; @@ -486,6 +610,8 @@ async function main() { await cmdSession(args); } else if (args.command === 'service') { await cmdService(args); + } else if (args.command === 'key') { + await cmdKey(args); } else if (args.sourceFile) { await cmdExecute(args); } else { @@ -495,6 +621,7 @@ Usage: ${process.argv[1]} [options] ${process.argv[1]} session [options] ${process.argv[1]} service [options] + ${process.argv[1]} key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -529,6 +656,10 @@ Service options: --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + +Key options: + --extend Open browser to extend key expiration + -k KEY API key to validate `); process.exit(1); } diff --git a/un.kt b/un.kt index 3d00b15..37d836a 100644 --- a/un.kt +++ b/un.kt @@ -47,6 +47,7 @@ import java.util.Base64 import kotlin.system.exitProcess val API_BASE = "https://api.unsandbox.com" +val PORTAL_BASE = "https://unsandbox.com" val BLUE = "\u001B[34m" val RED = "\u001B[31m" val GREEN = "\u001B[32m" @@ -92,7 +93,8 @@ data class Args( var serviceTail: String? = null, var serviceSleep: String? = null, var serviceWake: String? = null, - var serviceDestroy: String? = null + var serviceDestroy: String? = null, + var keyExtend: Boolean = false ) fun main(args: Array) { @@ -102,6 +104,7 @@ fun main(args: Array) { when (parsedArgs.command) { "session" -> cmdSession(parsedArgs) "service" -> cmdService(parsedArgs) + "key" -> cmdKey(parsedArgs) else -> if (parsedArgs.sourceFile != null) { cmdExecute(parsedArgs) } else { @@ -329,6 +332,77 @@ fun cmdService(args: Args) { exitProcess(1) } +fun cmdKey(args: Args) { + val apiKey = getApiKey(args.apiKey) + + val result = validateKey(apiKey) + val valid = result["valid"] as? Boolean ?: false + val expired = result["expired"] as? Boolean ?: false + val publicKey = result["public_key"] as? String ?: "" + val tier = result["tier"] as? String ?: "" + val expiresAt = result["expires_at"] as? String ?: "" + + if (args.keyExtend) { + if (publicKey.isEmpty()) { + System.err.println("${RED}Error: Could not retrieve public key${RESET}") + exitProcess(1) + } + val extendUrl = "$PORTAL_BASE/keys/extend?pk=$publicKey" + println("${YELLOW}Opening browser to extend key...${RESET}") + println(extendUrl) + + // Try to open browser using common commands + val osName = System.getProperty("os.name").lowercase() + val openCmd = when { + osName.contains("mac") || osName.contains("darwin") -> "open" + osName.contains("win") -> "start" + else -> "xdg-open" + } + + try { + Runtime.getRuntime().exec(arrayOf(openCmd, extendUrl)) + } catch (e: Exception) { + println("${YELLOW}Could not open browser automatically. Please visit the URL above.${RESET}") + } + return + } + + if (expired) { + println("${RED}Status: Expired${RESET}") + println("Public Key: $publicKey") + println("Tier: $tier") + println("Expired: $expiresAt") + println("${YELLOW}To renew: Visit $PORTAL_BASE/keys/extend${RESET}") + } else if (valid) { + println("${GREEN}Status: Valid${RESET}") + println("Public Key: $publicKey") + println("Tier: $tier") + println("Expires: $expiresAt") + } else { + println("${RED}Status: Invalid${RESET}") + exitProcess(1) + } +} + +fun validateKey(apiKey: String): Map { + val url = URL("$PORTAL_BASE/keys/validate") + val connection = url.openConnection() as HttpURLConnection + + connection.requestMethod = "POST" + connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.setRequestProperty("Content-Type", "application/json") + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + + if (connection.responseCode !in 200..299) { + val error = connection.errorStream?.bufferedReader()?.readText() ?: "" + throw RuntimeException("HTTP ${connection.responseCode} - $error") + } + + val response = connection.inputStream.bufferedReader().readText() + return parseJson(response) +} + fun getApiKey(argsKey: String?): String { val key = argsKey ?: System.getenv("UNSANDBOX_API_KEY") if (key.isNullOrEmpty()) { @@ -500,6 +574,7 @@ fun parseArgs(args: Array): Args { when (args[i]) { "session" -> result.command = "session" "service" -> result.command = "service" + "key" -> result.command = "key" "-k", "--api-key" -> result.apiKey = args[++i] "-n", "--network" -> result.network = args[++i] "-v", "--vcpu" -> result.vcpu = args[++i].toInt() @@ -525,6 +600,7 @@ fun parseArgs(args: Array): Args { "--sleep" -> result.serviceSleep = args[++i] "--wake" -> result.serviceWake = args[++i] "--destroy" -> result.serviceDestroy = args[++i] + "--extend" -> result.keyExtend = true else -> if (!args[i].startsWith("-")) result.sourceFile = args[i] } i++ @@ -537,6 +613,7 @@ fun printHelp() { Usage: kotlin UnKt [options] kotlin UnKt session [options] kotlin UnKt service [options] + kotlin UnKt key [options] Execute options: -e KEY=VALUE Set environment variable @@ -564,5 +641,8 @@ Service options: --sleep ID Freeze service --wake ID Unfreeze service --destroy ID Destroy service + +Key options: + --extend Open browser to extend key """.trimIndent()) } diff --git a/un.lisp b/un.lisp index b053957..2c7e1bf 100644 --- a/un.lisp +++ b/un.lisp @@ -53,6 +53,8 @@ (defparameter *yellow* (format nil "~C[33m" #\Escape)) (defparameter *reset* (format nil "~C[0m" #\Escape)) +(defparameter *portal-base* "https://unsandbox.com") + (defparameter *ext-map* '((".hs" . "haskell") (".ml" . "ocaml") (".clj" . "clojure") (".scm" . "scheme") (".lisp" . "commonlisp") (".erl" . "erlang") @@ -119,6 +121,16 @@ (format nil "https://api.unsandbox.com~a" endpoint) "-H" (format nil "Authorization: Bearer ~a" api-key)))) +(defun curl-post-portal (api-key endpoint json-data) + (let ((tmp-file (write-temp-file json-data))) + (unwind-protect + (run-curl (list "curl" "-s" "-X" "POST" + (format nil "~a~a" *portal-base* endpoint) + "-H" "Content-Type: application/json" + "-H" (format nil "Authorization: Bearer ~a" api-key) + "-d" (format nil "@~a" tmp-file))) + (delete-file tmp-file)))) + (defun get-api-key () (or (uiop:getenv "UNSANDBOX_API_KEY") (progn @@ -183,6 +195,84 @@ (format t "Error: --name required to create service~%") (uiop:quit 1))))) +(defun parse-json-field (json field) + "Simple JSON field parser - extracts value for given field" + (let* ((field-pattern (format nil "\"~a\":" field)) + (start (search field-pattern json))) + (when start + (let* ((value-start (+ start (length field-pattern))) + (first-char (char json value-start))) + (cond + ((char= first-char #\") + ;; String value + (let ((str-start (1+ value-start))) + (loop for i from str-start below (length json) + when (and (char= (char json i) #\") + (not (char= (char json (1- i)) #\\))) + return (subseq json str-start i)))) + ((char= first-char #\{) + ;; Object value - skip for now + nil) + ((char= first-char #\[) + ;; Array value - skip for now + nil) + (t + ;; Number, boolean, or null + (let ((end (or (position #\, json :start value-start) + (position #\} json :start value-start) + (length json)))) + (string-trim '(#\Space #\Tab #\Newline #\Return) + (subseq json value-start end))))))))) + +(defun open-browser (url) + "Open URL in default browser" + (uiop:run-program (list "xdg-open" url) :ignore-error-status t)) + +(defun validate-key (extend-flag) + (let* ((api-key (get-api-key)) + (response (curl-post-portal api-key "/keys/validate" "{}")) + (status (parse-json-field response "status")) + (public-key (parse-json-field response "public_key")) + (tier (parse-json-field response "tier")) + (expires-at (parse-json-field response "expires_at"))) + + (cond + ((string= status "valid") + (format t "~aValid~a~%" *green* *reset*) + (when public-key (format t "Public Key: ~a~%" public-key)) + (when tier (format t "Tier: ~a~%" tier)) + (when expires-at (format t "Expires: ~a~%" expires-at)) + (when extend-flag + (if public-key + (let ((extend-url (format nil "~a/keys/extend?pk=~a" *portal-base* public-key))) + (format t "~aOpening browser to extend key...~a~%" *blue* *reset*) + (open-browser extend-url)) + (format t "~aError: No public_key in response~a~%" *red* *reset*)))) + + ((string= status "expired") + (format t "~aExpired~a~%" *red* *reset*) + (when public-key (format t "Public Key: ~a~%" public-key)) + (when tier (format t "Tier: ~a~%" tier)) + (when expires-at (format t "Expired: ~a~%" expires-at)) + (format t "~aTo renew: Visit ~a/keys/extend~a~%" *yellow* *portal-base* *reset*) + (when extend-flag + (if public-key + (let ((extend-url (format nil "~a/keys/extend?pk=~a" *portal-base* public-key))) + (format t "~aOpening browser to extend key...~a~%" *blue* *reset*) + (open-browser extend-url)) + (format t "~aError: No public_key in response~a~%" *red* *reset*)))) + + ((string= status "invalid") + (format t "~aInvalid~a~%" *red* *reset*) + (format t "Response: ~a~%" response)) + + (t + (format t "~aUnknown status~a~%" *red* *reset*) + (format t "Response: ~a~%" response))))) + +(defun key-cmd (extend-flag) + (validate-key extend-flag)) + (defun main () (let ((args (uiop:command-line-arguments))) (if (null args) @@ -190,6 +280,7 @@ (format t "Usage: un.lisp [options] ~%") (format t " un.lisp session [options]~%") (format t " un.lisp service [options]~%") + (format t " un.lisp key [--extend]~%") (uiop:quit 1)) (cond ((string= (first args) "session") @@ -231,6 +322,9 @@ (t (format t "Error: Invalid service command~%") (uiop:quit 1)))) + ((string= (first args) "key") + (let ((extend-flag (and (> (length args) 1) (string= (second args) "--extend")))) + (key-cmd extend-flag))) (t (execute-cmd (first args))))))) diff --git a/un.lua b/un.lua index ece9924..23d4f02 100644 --- a/un.lua +++ b/un.lua @@ -53,6 +53,7 @@ local json = require("cjson") local API_BASE = "https://api.unsandbox.com" +local PORTAL_BASE = "https://unsandbox.com" local BLUE = "\27[34m" local RED = "\27[31m" local GREEN = "\27[32m" @@ -303,6 +304,91 @@ local function cmd_session(options) print(YELLOW .. "(Interactive sessions require WebSocket - use un2 for full support)" .. RESET) end +local function cmd_key(options) + local api_key = get_api_key(options.api_key) + + if options.extend then + -- Get public_key from validation response + local url = PORTAL_BASE .. "/keys/validate" + local tmpfile = os.tmpname() + + local cmd = "curl -s -X POST " .. shell_escape(url) .. + " -H 'Authorization: Bearer " .. api_key .. "'" .. + " -H 'Content-Type: application/json'" .. + " -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) + + if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then + io.stderr:write(RED .. "Error: HTTP " .. (http_code or "000") .. " - " .. response .. RESET .. "\n") + os.exit(1) + end + + local result = json.decode(response) + local public_key = result.public_key + + if not public_key then + io.stderr:write(RED .. "Error: Could not retrieve public key" .. RESET .. "\n") + os.exit(1) + end + + -- Open browser with extend URL + local extend_url = PORTAL_BASE .. "/keys/extend?pk=" .. public_key + print(GREEN .. "Opening browser to extend key..." .. RESET) + print(extend_url) + os.execute("xdg-open " .. shell_escape(extend_url) .. " 2>/dev/null || open " .. shell_escape(extend_url) .. " 2>/dev/null") + return + end + + -- Validate key (default action) + local url = PORTAL_BASE .. "/keys/validate" + local tmpfile = os.tmpname() + + local cmd = "curl -s -X POST " .. shell_escape(url) .. + " -H 'Authorization: Bearer " .. api_key .. "'" .. + " -H 'Content-Type: application/json'" .. + " -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) + + if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then + io.stderr:write(RED .. "Error: Invalid API key" .. RESET .. "\n") + os.exit(1) + end + + local result = json.decode(response) + + if result.status == "valid" then + print(GREEN .. "Valid" .. RESET) + if result.public_key then print("Public Key: " .. result.public_key) end + if result.tier then print("Tier: " .. result.tier) end + if result.expires_at then print("Expires: " .. result.expires_at) end + elseif result.status == "expired" then + print(RED .. "Expired" .. RESET) + if result.public_key then print("Public Key: " .. result.public_key) end + if result.tier then print("Tier: " .. result.tier) end + if result.expired_at then print("Expired: " .. result.expired_at) end + print(YELLOW .. "To renew: Visit https://unsandbox.com/keys/extend" .. RESET) + else + print(RED .. "Invalid" .. RESET) + if result.message then print("Message: " .. result.message) end + end +end + local function cmd_service(options) local api_key = get_api_key(options.api_key) @@ -440,14 +526,15 @@ local function main() wake = nil, destroy = nil, execute = nil, - command = nil + command = nil, + extend = false } local i = 1 while i <= #arg do local a = arg[i] - if a == "session" or a == "service" then + if a == "session" or a == "service" or a == "key" then options.command = a elseif a == "-e" then i = i + 1 @@ -525,6 +612,8 @@ local function main() elseif a == "--command" then i = i + 1 options.command = arg[i] + elseif a == "--extend" then + options.extend = true elseif not a:match("^%-") then options.source_file = a end @@ -536,6 +625,8 @@ local function main() cmd_session(options) elseif options.command == "service" then cmd_service(options) + elseif options.command == "key" then + cmd_key(options) elseif options.source_file then cmd_execute(options) else @@ -546,6 +637,7 @@ Usage: ]] .. arg[0] .. [[ [options] ]] .. arg[0] .. [[ session [options] ]] .. arg[0] .. [[ service [options] + ]] .. arg[0] .. [[ key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -580,6 +672,9 @@ Service options: --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + +Key options: + --extend Open browser to extend/renew key ]]) os.exit(1) end diff --git a/un.ml b/un.ml old mode 100644 new mode 100755 index 6f3a778..15af305 --- a/un.ml +++ b/un.ml @@ -62,6 +62,9 @@ let green = "\x1b[32m" let yellow = "\x1b[33m" let reset = "\x1b[0m" +(* Portal base URL *) +let portal_base = "https://unsandbox.com" + (* Extension to language mapping *) let ext_to_lang ext = match ext with @@ -118,6 +121,23 @@ let curl_post api_key endpoint json = let _ = Unix.close_process_in ic in output +let portal_curl_post api_key endpoint json = + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" + portal_base endpoint api_key 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 output = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + output + let curl_get api_key endpoint = let cmd = Printf.sprintf "curl -s https://api.unsandbox.com%s -H 'Authorization: Bearer %s'" endpoint api_key in @@ -147,6 +167,31 @@ let curl_delete api_key endpoint = let _ = Unix.close_process_in ic in output +(* Extract JSON value - simple regex-based parser *) +let extract_json_value json_str key = + let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in + let regex = Str.regexp pattern in + try + let _ = Str.search_forward regex json_str 0 in + Some (Str.matched_group 1 json_str) + with Not_found -> None + +(* Open browser *) +let open_browser url = + Printf.printf "%sOpening browser: %s%s\n" blue url reset; + let _ = match Sys.os_type with + | "Unix" | "Cygwin" -> + (* Try xdg-open for Linux *) + (try Sys.command (Printf.sprintf "xdg-open '%s' 2>/dev/null" url) + with _ -> + (* Fallback to open for macOS *) + try Sys.command (Printf.sprintf "open '%s' 2>/dev/null" url) + with _ -> 1) + | "Win32" -> + Sys.command (Printf.sprintf "start '%s'" url) + | _ -> 1 + in () + (* Parse JSON response for stdout/stderr/exit_code *) let extract_field field json = try @@ -226,6 +271,53 @@ let execute_command file env_vars artifacts out_dir network vcpu = in exit exit_code +(* Display key info *) +let display_key_info response extend = + let status = extract_json_value response "status" in + let public_key = extract_json_value response "public_key" in + let tier = extract_json_value response "tier" in + let expires_at = extract_json_value response "expires_at" in + + match status with + | Some "valid" -> + Printf.printf "%sValid%s\n" green reset; + (match public_key with Some pk -> Printf.printf "Public Key: %s\n" pk | None -> ()); + (match tier with Some t -> Printf.printf "Tier: %s\n" t | None -> ()); + (match expires_at with Some exp -> Printf.printf "Expires: %s\n" exp | None -> ()); + if extend then + (match public_key with + | Some pk -> open_browser (Printf.sprintf "%s/keys/extend?pk=%s" portal_base pk) + | None -> ()) + | Some "expired" -> + Printf.printf "%sExpired%s\n" red reset; + (match public_key with Some pk -> Printf.printf "Public Key: %s\n" pk | None -> ()); + (match tier with Some t -> Printf.printf "Tier: %s\n" t | None -> ()); + (match expires_at with Some exp -> Printf.printf "Expired: %s\n" exp | None -> ()); + Printf.printf "%sTo renew: Visit %s/keys/extend%s\n" yellow portal_base reset; + if extend then + (match public_key with + | Some pk -> open_browser (Printf.sprintf "%s/keys/extend?pk=%s" portal_base pk) + | None -> ()) + | Some "invalid" -> + Printf.printf "%sInvalid%s\n" red reset + | Some s -> + Printf.printf "%sUnknown status: %s%s\n" red s reset; + Printf.printf "%s\n" response + | None -> + Printf.printf "%sError: Could not parse response%s\n" red reset; + Printf.printf "%s\n" response + +(* Validate key command *) +let validate_key api_key extend = + let json = "{}" in + let response = portal_curl_post api_key "/keys/validate" json in + display_key_info response extend + +(* Key command *) +let key_command extend = + let api_key = get_api_key () in + validate_key api_key extend + (* Session command *) let session_command action shell network vcpu = let api_key = get_api_key () in @@ -364,7 +456,11 @@ 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"; exit 1 + | "key" :: rest -> + let extend = List.mem "--extend" rest in + key_command extend | "session" :: rest -> let rec parse_session action shell network vcpu = function | [] -> session_command action shell network vcpu diff --git a/un.nim b/un.nim index 5f0ad2e..426f05e 100644 --- a/un.nim +++ b/un.nim @@ -47,6 +47,7 @@ import os, strutils, osproc, strformat const API_BASE = "https://api.unsandbox.com" + PORTAL_BASE = "https://unsandbox.com" BLUE = "\x1b[34m" RED = "\x1b[31m" GREEN = "\x1b[32m" @@ -188,6 +189,81 @@ proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, l stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET) quit(1) +proc cmdKey(extend: bool, apiKey: string) = + let cmd = fmt"""curl -s -X POST '{PORTAL_BASE}/keys/validate' -H 'Authorization: Bearer {apiKey}'""" + let response = execCurl(cmd) + + # Parse JSON response manually (simple approach) + if response.contains("\"status\":\"valid\""): + echo GREEN & "Valid" & RESET + # Extract and display key info + if response.contains("\"public_key\":"): + let pkStart = response.find("\"public_key\":\"") + 14 + let pkEnd = response.find("\"", pkStart) + if pkEnd > pkStart: + let publicKey = response[pkStart.. tierStart: + echo "Tier: " & response[tierStart.. expiresStart: + echo "Expires: " & response[expiresStart.. pkStart: + publicKey = response[pkStart.. tierStart: + echo "Tier: " & response[tierStart.. expiresStart: + echo "Expired: " & response[expiresStart.. errStart: + echo "Error: " & response[errStart..") stderr.writeLine(" un.nim session [options]") stderr.writeLine(" un.nim service [options]") + stderr.writeLine(" un.nim key [options]") quit(1) + if args[0] == "key": + var extend = false + var i = 1 + while i < args.len: + case args[i] + of "--extend": extend = true + of "-k": apiKey = args[i+1]; inc i + inc i + cmdKey(extend, apiKey) + return + if args[0] == "session": var list = false var kill, shell, network = "" diff --git a/un.php b/un.php old mode 100644 new mode 100755 index f5a5acb..25a8f22 --- a/un.php +++ b/un.php @@ -53,6 +53,7 @@ */ const API_BASE = 'https://api.unsandbox.com'; +const PORTAL_BASE = 'https://unsandbox.com'; const BLUE = "\033[34m"; const RED = "\033[31m"; const GREEN = "\033[32m"; @@ -263,6 +264,113 @@ function cmd_session($options) { echo YELLOW . "(Interactive sessions require WebSocket - use un2 for full support)" . RESET . "\n"; } +function validate_key($api_key) { + $url = PORTAL_BASE . '/keys/validate'; + $ch = curl_init($url); + + $headers = [ + 'Authorization: Bearer ' . $api_key, + 'Content-Type: application/json' + ]; + + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30 + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($response === false) { + fwrite(STDERR, RED . "Error: " . curl_error($ch) . RESET . "\n"); + curl_close($ch); + exit(1); + } + + curl_close($ch); + + $data = json_decode($response, true); + + if ($http_code === 200 && isset($data['valid']) && $data['valid']) { + echo GREEN . "Valid" . RESET . "\n\n"; + echo "Public Key: " . ($data['public_key'] ?? 'N/A') . "\n"; + echo "Tier: " . ($data['tier'] ?? 'N/A') . "\n"; + echo "Status: " . ($data['status'] ?? 'N/A') . "\n"; + echo "Expires: " . ($data['expires_at'] ?? 'N/A') . "\n"; + echo "Time Remaining: " . ($data['time_remaining'] ?? 'N/A') . "\n"; + echo "Rate Limit: " . ($data['rate_limit'] ?? 'N/A') . " req/min\n"; + echo "Burst: " . ($data['burst'] ?? 'N/A') . "\n"; + echo "Concurrency: " . ($data['concurrency'] ?? 'N/A') . "\n"; + } elseif ($http_code === 200 && isset($data['valid']) && !$data['valid'] && isset($data['status']) && $data['status'] === 'expired') { + echo RED . "Expired" . RESET . "\n\n"; + echo "Public Key: " . ($data['public_key'] ?? 'N/A') . "\n"; + echo "Tier: " . ($data['tier'] ?? 'N/A') . "\n"; + echo "Expired: " . ($data['expires_at'] ?? 'N/A') . "\n\n"; + echo YELLOW . "To renew: Visit https://unsandbox.com/keys/extend" . RESET . "\n"; + } else { + echo RED . "Invalid" . RESET . "\n\n"; + if (isset($data['error'])) { + echo "Error: " . $data['error'] . "\n"; + } elseif (isset($data['reason'])) { + echo "Reason: " . $data['reason'] . "\n"; + } else { + echo "HTTP $http_code - $response\n"; + } + } +} + +function cmd_key($options) { + $api_key = get_api_key($options['api_key']); + + if ($options['extend']) { + // First validate to get public_key + $url = PORTAL_BASE . '/keys/validate'; + $ch = curl_init($url); + + $headers = [ + 'Authorization: Bearer ' . $api_key, + 'Content-Type: application/json' + ]; + + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30 + ]); + + $response = curl_exec($ch); + curl_close($ch); + + $data = json_decode($response, true); + $public_key = $data['public_key'] ?? null; + + if (!$public_key) { + fwrite(STDERR, RED . "Error: Could not retrieve public key" . RESET . "\n"); + exit(1); + } + + $extend_url = PORTAL_BASE . '/keys/extend?pk=' . urlencode($public_key); + echo "Opening browser to: $extend_url\n"; + + // Detect platform and open browser + if (PHP_OS_FAMILY === 'Linux') { + exec('xdg-open ' . escapeshellarg($extend_url) . ' > /dev/null 2>&1 &'); + } elseif (PHP_OS_FAMILY === 'Darwin') { + exec('open ' . escapeshellarg($extend_url) . ' > /dev/null 2>&1 &'); + } elseif (PHP_OS_FAMILY === 'Windows') { + exec('start ' . escapeshellarg($extend_url) . ' > NUL 2>&1'); + } else { + echo YELLOW . "Cannot auto-open browser on this platform. Please visit:" . RESET . "\n"; + echo "$extend_url\n"; + } + } else { + validate_key($api_key); + } +} + function cmd_service($options) { $api_key = get_api_key($options['api_key']); @@ -392,7 +500,7 @@ function main() { 'wake' => null, 'destroy' => null, 'execute' => null, - 'command' => null + 'extend' => false ]; for ($i = 1; $i < count($argv); $i++) { @@ -401,6 +509,7 @@ function main() { switch ($arg) { case 'session': case 'service': + case 'key': $options['command'] = $arg; break; case '-e': @@ -486,6 +595,9 @@ function main() { case '--command': $options['command'] = $argv[++$i]; break; + case '--extend': + $options['extend'] = true; + break; default: if (!str_starts_with($arg, '-')) { $options['source_file'] = $arg; @@ -498,6 +610,8 @@ function main() { cmd_session($options); } elseif ($options['command'] === 'service') { cmd_service($options); + } elseif ($options['command'] === 'key') { + cmd_key($options); } elseif ($options['source_file']) { cmd_execute($options); } else { @@ -507,6 +621,7 @@ Usage: {$argv[0]} [options] {$argv[0]} session [options] {$argv[0]} service [options] + {$argv[0]} key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -541,6 +656,10 @@ Service options: --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + +Key options: + -k KEY API key (or use UNSANDBOX_API_KEY env var) + --extend Open browser to extend/renew key "; exit(1); } diff --git a/un.pl b/un.pl index ccb4c04..8492861 100644 --- a/un.pl +++ b/un.pl @@ -59,6 +59,7 @@ use MIME::Base64; use File::Path qw(make_path); my $API_BASE = 'https://api.unsandbox.com'; +my $PORTAL_BASE = 'https://unsandbox.com'; my $BLUE = "\033[34m"; my $RED = "\033[31m"; my $GREEN = "\033[32m"; @@ -361,6 +362,61 @@ sub cmd_service { exit 1; } +sub cmd_key { + my ($options) = @_; + my $api_key = get_api_key($options->{api_key}); + + # Call /keys/validate endpoint + my $url = "$PORTAL_BASE/keys/validate"; + my $ua = LWP::UserAgent->new(timeout => 30); + my $request = HTTP::Request->new('POST' => $url); + $request->header('Authorization' => "Bearer $api_key"); + $request->header('Content-Type' => 'application/json'); + + my $response = $ua->request($request); + + unless ($response->is_success) { + print STDERR "${RED}Error: HTTP ", $response->code, " - ", $response->content, "${RESET}\n"; + exit 1; + } + + my $result = decode_json($response->content); + + # Handle different states + my $status = $result->{status} || 'unknown'; + + if ($status eq 'valid') { + print "${GREEN}Valid${RESET}\n"; + print "Public Key: ", ($result->{public_key} // 'N/A'), "\n"; + print "Tier: ", ($result->{tier} // 'N/A'), "\n"; + print "Expires: ", ($result->{expires_at} // 'N/A'), "\n"; + } elsif ($status eq 'expired') { + print "${RED}Expired${RESET}\n"; + print "Public Key: ", ($result->{public_key} // 'N/A'), "\n"; + print "Tier: ", ($result->{tier} // 'N/A'), "\n"; + print "Expired: ", ($result->{expired_at} // 'N/A'), "\n"; + print "${YELLOW}To renew: Visit https://unsandbox.com/keys/extend${RESET}\n"; + + # Handle --extend flag for expired keys + if ($options->{extend} && $result->{public_key}) { + my $extend_url = "$PORTAL_BASE/keys/extend?pk=$result->{public_key}"; + print "\n${BLUE}Opening browser to: $extend_url${RESET}\n"; + system("xdg-open", $extend_url) if -x "/usr/bin/xdg-open"; + } + } elsif ($status eq 'invalid') { + print "${RED}Invalid${RESET}\n"; + } else { + print "${YELLOW}Unknown status: $status${RESET}\n"; + } + + # Handle --extend flag for valid keys + if ($options->{extend} && $status eq 'valid' && $result->{public_key}) { + my $extend_url = "$PORTAL_BASE/keys/extend?pk=$result->{public_key}"; + print "\n${BLUE}Opening browser to: $extend_url${RESET}\n"; + system("xdg-open", $extend_url) if -x "/usr/bin/xdg-open"; + } +} + sub main { my %options = ( command => undef, @@ -391,13 +447,14 @@ sub main { wake => undef, destroy => undef, execute => undef, - command => undef + command => undef, + extend => 0 ); for (my $i = 0; $i < @ARGV; $i++) { my $arg = $ARGV[$i]; - if ($arg eq 'session' || $arg eq 'service') { + if ($arg eq 'session' || $arg eq 'service' || $arg eq 'key') { $options{command} = $arg; } elsif ($arg eq '-e') { push @{$options{env}}, $ARGV[++$i]; @@ -453,6 +510,8 @@ sub main { $options{execute} = $ARGV[++$i]; } elsif ($arg eq '--command') { $options{command} = $ARGV[++$i]; + } elsif ($arg eq '--extend') { + $options{extend} = 1; } elsif ($arg !~ /^-/) { $options{source_file} = $arg; } @@ -462,6 +521,8 @@ sub main { cmd_session(\%options); } elsif ($options{command} && $options{command} eq 'service') { cmd_service(\%options); + } elsif ($options{command} && $options{command} eq 'key') { + cmd_key(\%options); } elsif ($options{source_file}) { cmd_execute(\%options); } else { @@ -472,6 +533,7 @@ Usage: $0 [options] $0 session [options] $0 service [options] + $0 key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -506,6 +568,9 @@ Service options: --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + +Key options: + --extend Open browser to extend/renew key HELP exit 1; } diff --git a/un.py b/un.py index 546e12c..15d3043 100644 --- a/un.py +++ b/un.py @@ -58,8 +58,10 @@ import base64 import argparse import urllib.request import urllib.error +import webbrowser API_BASE = "https://api.unsandbox.com" +PORTAL_BASE = "https://unsandbox.com" BLUE = "\033[34m" RED = "\033[31m" GREEN = "\033[32m" @@ -267,6 +269,73 @@ def cmd_session(args): print(f"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}") +def validate_key(api_key, extend=False): + """Validate API key and display information""" + url = f"{PORTAL_BASE}/keys/validate" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + req = urllib.request.Request(url, method="POST", headers=headers) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + result = json.loads(resp.read().decode('utf-8')) + + # Handle --extend flag + if extend: + public_key = result.get("public_key") + if public_key: + extend_url = f"{PORTAL_BASE}/keys/extend?pk={public_key}" + print(f"{BLUE}Opening browser to extend key...{RESET}") + webbrowser.open(extend_url) + return + else: + print(f"{RED}Error: Could not retrieve public key{RESET}", file=sys.stderr) + sys.exit(1) + + # Check if key is expired + if result.get("expired", False): + print(f"{RED}Expired{RESET}") + print(f"Public Key: {result.get('public_key', 'N/A')}") + print(f"Tier: {result.get('tier', 'N/A')}") + print(f"Expired: {result.get('expires_at', 'N/A')}") + print(f"{YELLOW}To renew: Visit https://unsandbox.com/keys/extend{RESET}") + sys.exit(1) + + # Valid key + print(f"{GREEN}Valid{RESET}") + print(f"Public Key: {result.get('public_key', 'N/A')}") + print(f"Tier: {result.get('tier', 'N/A')}") + print(f"Status: {result.get('status', 'N/A')}") + print(f"Expires: {result.get('expires_at', 'N/A')}") + print(f"Time Remaining: {result.get('time_remaining', 'N/A')}") + print(f"Rate Limit: {result.get('rate_limit', 'N/A')}") + print(f"Burst: {result.get('burst', 'N/A')}") + print(f"Concurrency: {result.get('concurrency', 'N/A')}") + + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') if e.fp else str(e) + try: + error_json = json.loads(error_body) + reason = error_json.get("error", error_body) + except: + reason = error_body + print(f"{RED}Invalid{RESET}") + print(f"Reason: {reason}") + sys.exit(1) + except urllib.error.URLError as e: + print(f"{RED}Error: {e.reason}{RESET}", file=sys.stderr) + sys.exit(1) + + +def cmd_key(args): + """Validate API key""" + api_key = get_api_key(args.key) + validate_key(api_key, extend=args.extend) + + def cmd_service(args): """Manage persistent services""" api_key = get_api_key(args.api_key) @@ -380,6 +449,11 @@ Examples: subparsers = parser.add_subparsers(dest="command") + # Key subcommand + key_parser = subparsers.add_parser("key", help="Validate API key") + key_parser.add_argument("-k", "--key", help="API key to validate (or set UNSANDBOX_API_KEY)") + key_parser.add_argument("--extend", action="store_true", help="Open browser to extend key") + # Session subcommand session_parser = subparsers.add_parser("session", help="Interactive shell/REPL sessions") session_parser.add_argument("-s", "--shell", help="Shell/REPL to use (default: bash)") @@ -423,7 +497,9 @@ Examples: args = parser.parse_args() - if args.command == "session": + if args.command == "key": + cmd_key(args) + elif args.command == "session": cmd_session(args) elif args.command == "service": cmd_service(args) diff --git a/un.r b/un.r index e13dfb7..ff19d5e 100644 --- a/un.r +++ b/un.r @@ -64,6 +64,7 @@ YELLOW <- "\033[33m" RESET <- "\033[0m" API_BASE <- "https://api.unsandbox.com" +PORTAL_BASE <- "https://unsandbox.com" detect_language <- function(filename) { ext <- tolower(sub(".*(\\..*)$", "\\1", filename)) @@ -233,6 +234,73 @@ cmd_session <- function(args) { quit(status = 1) } +cmd_key <- function(args) { + api_key <- get_api_key(args$api_key) + + if (!is.null(args$extend) && args$extend) { + # First validate to get public_key + url <- paste0(PORTAL_BASE, "/keys/validate") + headers <- add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", api_key) + ) + + tryCatch({ + response <- POST(url, headers, encode = "json", timeout(10)) + result <- fromJSON(content(response, "text", encoding = "UTF-8")) + + if (!is.null(result$public_key)) { + extend_url <- paste0(PORTAL_BASE, "/keys/extend?pk=", result$public_key) + cat(sprintf("Opening: %s\n", extend_url)) + system(sprintf("xdg-open '%s' 2>/dev/null || open '%s' 2>/dev/null || start '%s'", extend_url, extend_url, extend_url)) + } else { + cat(sprintf("%sError: Could not retrieve public key%s\n", RED, RESET), file = stderr()) + quit(status = 1) + } + }, error = function(e) { + cat(sprintf("%sError: Request failed: %s%s\n", RED, e$message, RESET), file = stderr()) + quit(status = 1) + }) + return() + } + + # Validate key + url <- paste0(PORTAL_BASE, "/keys/validate") + headers <- add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", api_key) + ) + + tryCatch({ + response <- POST(url, headers, encode = "json", timeout(10)) + result <- fromJSON(content(response, "text", encoding = "UTF-8")) + + status <- if (!is.null(result$status)) result$status else "Unknown" + + if (status == "valid") { + cat(sprintf("%sValid%s\n", GREEN, RESET)) + cat(sprintf("Public Key: %s\n", if (!is.null(result$public_key)) result$public_key else "N/A")) + cat(sprintf("Tier: %s\n", if (!is.null(result$tier)) result$tier else "N/A")) + if (!is.null(result$expires_at)) { + cat(sprintf("Expires: %s\n", result$expires_at)) + } + } else if (status == "expired") { + cat(sprintf("%sExpired%s\n", RED, RESET)) + cat(sprintf("Public Key: %s\n", if (!is.null(result$public_key)) result$public_key else "N/A")) + cat(sprintf("Tier: %s\n", if (!is.null(result$tier)) result$tier else "N/A")) + if (!is.null(result$expires_at)) { + cat(sprintf("Expired: %s\n", result$expires_at)) + } + cat(sprintf("%sTo renew: Visit https://unsandbox.com/keys/extend%s\n", YELLOW, RESET)) + } else { + cat(sprintf("%sInvalid%s\n", RED, RESET)) + } + }, error = function(e) { + cat(sprintf("%sError: Request failed: %s%s\n", RED, e$message, RESET), file = stderr()) + quit(status = 1) + }) +} + cmd_service <- function(args) { api_key <- get_api_key(args$api_key) @@ -356,7 +424,8 @@ parse_args <- function() { domains = NULL, type = NULL, bootstrap = NULL, - vcpu = NULL + vcpu = NULL, + extend = FALSE ) i <- 1 @@ -369,6 +438,9 @@ parse_args <- function() { } else if (arg == "service") { result$command <- "service" i <- i + 1 + } else if (arg == "key") { + result$command <- "key" + i <- i + 1 } else if (arg %in% c("-k", "--api-key")) { i <- i + 1 result$api_key <- args[i] @@ -443,6 +515,9 @@ parse_args <- function() { i <- i + 1 result$vcpu <- as.integer(args[i]) i <- i + 1 + } else if (arg == "--extend") { + result$extend <- TRUE + i <- i + 1 } else if (!startsWith(arg, "-")) { result$source_file <- arg i <- i + 1 @@ -462,12 +537,15 @@ main <- function() { cmd_session(args) } else if (!is.null(args$command) && args$command == "service") { cmd_service(args) + } else if (!is.null(args$command) && args$command == "key") { + cmd_key(args) } else if (!is.null(args$source_file)) { cmd_execute(args) } else { 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 key [options]\n", file = stderr()) quit(status = 1) } } diff --git a/un.rb b/un.rb index c68ead3..3960b30 100644 --- a/un.rb +++ b/un.rb @@ -57,6 +57,7 @@ require 'fileutils' require 'optparse' API_BASE = 'https://api.unsandbox.com' +PORTAL_BASE = 'https://unsandbox.com' BLUE = "\e[34m" RED = "\e[31m" GREEN = "\e[32m" @@ -245,6 +246,86 @@ def cmd_session(options) puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}" end +def validate_key(api_key) + uri = URI("#{PORTAL_BASE}/keys/validate") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = 30 + + request = Net::HTTP::Post.new(uri) + request['Authorization'] = "Bearer #{api_key}" + request['Content-Type'] = 'application/json' + + response = http.request(request) + + begin + result = JSON.parse(response.body) + rescue JSON::ParserError => e + warn "#{RED}Error: Failed to parse response: #{e.message}#{RESET}" + exit 1 + end + + if response.is_a?(Net::HTTPSuccess) && result['valid'] + puts "#{GREEN}Valid#{RESET}" + puts "Public Key: #{result['public_key']}" + puts "Tier: #{result['tier']}" + puts "Status: #{result['status']}" + puts "Expires: #{result['expires_at']}" + puts "Time Remaining: #{result['time_remaining']}" + puts "Rate Limit: #{result['rate_limit']}" + puts "Burst: #{result['burst']}" + puts "Concurrency: #{result['concurrency']}" + result + elsif result['expired'] + puts "#{RED}Expired#{RESET}" + puts "Public Key: #{result['public_key']}" + puts "Tier: #{result['tier']}" + puts "Expired: #{result['expires_at']}" + puts "#{YELLOW}To renew: Visit https://unsandbox.com/keys/extend#{RESET}" + result + else + puts "#{RED}Invalid#{RESET}" + puts "Error: #{result['error'] || result['reason'] || 'Unknown error'}" + exit 1 + end +rescue => e + warn "#{RED}Error: #{e.message}#{RESET}" + exit 1 +end + +def open_browser(url) + case RbConfig::CONFIG['host_os'] + when /mswin|mingw|cygwin/ + system("start #{url}") + when /darwin/ + system("open #{url}") + when /linux|bsd/ + system("xdg-open #{url}") + else + puts "#{YELLOW}Please open this URL in your browser:#{RESET}" + puts url + end +end + +def cmd_key(options) + api_key = get_api_key(options[:api_key]) + + if options[:extend] + result = validate_key(api_key) + public_key = result['public_key'] + if public_key + url = "#{PORTAL_BASE}/keys/extend?pk=#{public_key}" + puts "#{GREEN}Opening browser to extend key...#{RESET}" + open_browser(url) + else + warn "#{RED}Error: Could not retrieve public key#{RESET}" + exit 1 + end + else + validate_key(api_key) + end +end + def cmd_service(options) api_key = get_api_key(options[:api_key]) @@ -366,7 +447,7 @@ def main wake: nil, destroy: nil, execute: nil, - command: nil + extend: false } # Manual argument parsing @@ -375,7 +456,7 @@ def main arg = ARGV[i] case arg - when 'session', 'service' + when 'session', 'service', 'key' options[:command] = arg when '-e' i += 1 @@ -453,6 +534,8 @@ def main when '--command' i += 1 options[:command] = ARGV[i] + when '--extend' + options[:extend] = true else options[:source_file] = arg unless arg.start_with?('-') end @@ -465,6 +548,8 @@ def main cmd_session(options) when 'service' cmd_service(options) + when 'key' + cmd_key(options) else if options[:source_file] cmd_execute(options) @@ -476,6 +561,7 @@ def main #{$PROGRAM_NAME} [options] #{$PROGRAM_NAME} session [options] #{$PROGRAM_NAME} service [options] + #{$PROGRAM_NAME} key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -510,6 +596,10 @@ def main --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + + Key options: + -k KEY API key (or use UNSANDBOX_API_KEY env var) + --extend Validate key and open browser to extend HELP exit 1 end diff --git a/un.rs b/un.rs index 3364898..e11f7e7 100644 --- a/un.rs +++ b/un.rs @@ -51,6 +51,7 @@ use std::process::{self, Command}; use std::collections::HashMap; const API_BASE: &str = "https://api.unsandbox.com"; +const PORTAL_BASE: &str = "https://unsandbox.com"; const BLUE: &str = "\x1b[34m"; const RED: &str = "\x1b[31m"; const GREEN: &str = "\x1b[32m"; @@ -453,6 +454,55 @@ fn cmd_service( process::exit(1); } +fn cmd_key(extend: bool, api_key: &str) { + let result = api_request("/keys/validate", "POST", Some("{}"), api_key); + + let status = extract_json_string(&result, "status"); + let public_key = extract_json_string(&result, "public_key"); + let tier = extract_json_string(&result, "tier"); + let expired_at = extract_json_string(&result, "expired_at"); + + if extend && !public_key.is_empty() { + let url = format!("{}/keys/extend?pk={}", PORTAL_BASE, public_key); + println!("{}Opening browser: {}{}", YELLOW, url, RESET); + + // Try xdg-open (Linux), open (macOS), or start (Windows) + let _ = Command::new("xdg-open") + .arg(&url) + .spawn() + .or_else(|_| Command::new("open").arg(&url).spawn()) + .or_else(|_| Command::new("cmd").args(&["/c", "start", &url]).spawn()); + + return; + } + + match status.as_str() { + "valid" => { + println!("{}Valid{}", GREEN, RESET); + println!("Public Key: {}", public_key); + println!("Tier: {}", tier); + if !expired_at.is_empty() { + println!("Expires: {}", expired_at); + } + } + "expired" => { + println!("{}Expired{}", RED, RESET); + println!("Public Key: {}", public_key); + println!("Tier: {}", tier); + if !expired_at.is_empty() { + println!("Expired: {}", expired_at); + } + println!("{}To renew: Visit {}/keys/extend{}", YELLOW, PORTAL_BASE, RESET); + } + "invalid" => { + println!("{}Invalid{}", RED, RESET); + } + _ => { + println!("{}Unknown status: {}{}", YELLOW, status, RESET); + } + } +} + // Minimal base64 encoding mod base64 { const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -491,6 +541,7 @@ fn main() { eprintln!("Usage: {} [options] ", args[0]); eprintln!(" {} session [options]", args[0]); eprintln!(" {} service [options]", args[0]); + eprintln!(" {} key [--extend]", args[0]); process::exit(1); } @@ -579,6 +630,14 @@ fn main() { ); return; } + "key" => { + let key = get_api_key(api_key.as_deref()); + cmd_key( + args.contains(&"--extend".to_string()), + &key, + ); + return; + } _ => { if !args[i].starts_with('-') { source_file = Some(args[i].clone()); diff --git a/un.scm b/un.scm index f2285f0..eb513f7 100644 --- a/un.scm +++ b/un.scm @@ -53,6 +53,8 @@ (define yellow "\x1b[33m") (define reset "\x1b[0m") +(define portal-base "https://unsandbox.com") + (define ext-map '((".hs" . "haskell") (".ml" . "ocaml") (".clj" . "clojure") (".scm" . "scheme") (".lisp" . "commonlisp") (".erl" . "erlang") @@ -135,12 +137,86 @@ (close-pipe port) output)) +(define (curl-post-portal api-key endpoint json-data) + (let* ((tmp-file (write-temp-file json-data)) + (cmd (format #f "curl -s -X POST ~a~a -H 'Content-Type: application/json' -H 'Authorization: Bearer ~a' -d @~a" + portal-base endpoint api-key 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 (get-api-key) (or (getenv "UNSANDBOX_API_KEY") (begin (display "Error: UNSANDBOX_API_KEY not set\n" (current-error-port)) (exit 1)))) +(define (json-extract-string json key) + "Extract string value for key from JSON (simple parser)" + (let* ((pattern (format #f "\"~a\":\\s*\"([^\"]*)" key)) + (cmd (format #f "echo '~a' | grep -oP '~a' | sed 's/\"~a\":\\s*\"//'" json pattern key)) + (port (open-input-pipe cmd)) + (result (read-line port))) + (close-pipe port) + (if (eof-object? result) #f result))) + +(define (json-has-field json field) + "Check if JSON contains a field" + (string-contains json (format #f "\"~a\"" field))) + +(define (open-browser url) + "Open URL in browser using xdg-open" + (let ((cmd (format #f "xdg-open '~a' 2>/dev/null &" url))) + (system cmd))) + +(define (validate-key-cmd extend) + (let* ((api-key (get-api-key)) + (response (curl-post-portal api-key "/keys/validate" "{}")) + (status (json-extract-string response "status")) + (public-key (json-extract-string response "public_key")) + (tier (json-extract-string response "tier")) + (expires-at (json-extract-string response "expires_at"))) + + (cond + ;; Valid key + ((and status (string=? status "valid")) + (format #t "~aValid~a\n" green reset) + (when public-key (format #t "Public Key: ~a\n" public-key)) + (when tier (format #t "Tier: ~a\n" tier)) + (when expires-at (format #t "Expires: ~a\n" expires-at)) + (when extend + (if public-key + (let ((url (format #f "~a/keys/extend?pk=~a" portal-base public-key))) + (format #t "~aOpening browser to extend key...~a\n" yellow reset) + (open-browser url)) + (format #t "~aError: No public_key in response~a\n" red reset)))) + + ;; Expired key + ((and status (string=? status "expired")) + (format #t "~aExpired~a\n" red reset) + (when public-key (format #t "Public Key: ~a\n" public-key)) + (when tier (format #t "Tier: ~a\n" tier)) + (when expires-at (format #t "Expired: ~a\n" expires-at)) + (format #t "~aTo renew: Visit ~a/keys/extend~a\n" yellow portal-base reset) + (when extend + (if public-key + (let ((url (format #f "~a/keys/extend?pk=~a" portal-base public-key))) + (format #t "~aOpening browser...~a\n" yellow reset) + (open-browser url)) + (format #t "~aError: No public_key in response~a\n" red reset)))) + + ;; Invalid or error + (else + (format #t "~aInvalid~a\n" red reset) + (display response) + (newline))))) + (define (execute-cmd file) (let* ((api-key (get-api-key)) (ext (get-extension file)) @@ -212,8 +288,12 @@ (display "Usage: un.scm [options] \n") (display " un.scm session [options]\n") (display " un.scm service [options]\n") + (display " un.scm key [--extend]\n") (exit 1)) (cond + ((equal? (car args) "key") + (let ((extend (and (> (length args) 1) (equal? (cadr args) "--extend")))) + (validate-key-cmd extend))) ((equal? (car args) "session") (if (and (> (length args) 1) (equal? (cadr args) "--list")) (session-cmd "list" #f #f) diff --git a/un.sh b/un.sh index 0590612..5af3869 100644 --- a/un.sh +++ b/un.sh @@ -52,6 +52,7 @@ set -euo pipefail # Requires: UNSANDBOX_API_KEY environment variable, jq, curl API_BASE="https://api.unsandbox.com" +PORTAL_BASE="https://unsandbox.com" BLUE="\033[34m" RED="\033[31m" GREEN="\033[32m" @@ -591,6 +592,149 @@ cmd_service() { exit 1 } +validate_key() { + local api_key="$1" + local extend_mode="$2" + + if [[ -z "$api_key" ]]; then + echo -e "${RED}Error: API key not provided. Use -k flag or set UNSANDBOX_API_KEY${RESET}" >&2 + exit 1 + fi + + # Call portal validation endpoint + local response + local http_code + response=$(curl -s -w "\n%{http_code}" -X POST "${PORTAL_BASE}/keys/validate" \ + -H "Authorization: Bearer $api_key" \ + -H "Content-Type: application/json" 2>&1) + + http_code=$(echo "$response" | tail -n1) + local body=$(echo "$response" | head -n-1) + + if [[ "$http_code" -eq 200 ]]; then + # Valid key - parse response + if command -v jq &> /dev/null; then + # Use jq for parsing + local valid=$(echo "$body" | jq -r '.valid // false') + local public_key=$(echo "$body" | jq -r '.public_key // "N/A"') + local tier=$(echo "$body" | jq -r '.tier // "N/A"') + local expires_at=$(echo "$body" | jq -r '.expires_at // "N/A"') + local expired=$(echo "$body" | jq -r '.expired // false') + + if [[ "$expired" == "true" ]]; then + echo -e "${RED}Expired${RESET}" + echo "Public Key: $public_key" + echo "Tier: $tier" + echo "Expired: $expires_at" + echo -e "${YELLOW}To renew: Visit ${PORTAL_BASE}/keys/extend${RESET}" + exit 1 + else + echo -e "${GREEN}Valid${RESET}" + echo "Public Key: $public_key" + echo "Tier: $tier" + echo "Expires: $expires_at" + + # If extend mode, open browser + if [[ "$extend_mode" == "true" ]]; then + local extend_url="${PORTAL_BASE}/keys/extend?pk=${public_key}" + echo -e "\n${BLUE}Opening browser to extend key...${RESET}" + + # Detect platform and open browser + if command -v xdg-open &> /dev/null; then + xdg-open "$extend_url" &> /dev/null + elif command -v open &> /dev/null; then + open "$extend_url" &> /dev/null + elif command -v start &> /dev/null; then + start "$extend_url" &> /dev/null + else + echo -e "${YELLOW}Cannot detect browser opener. Visit: $extend_url${RESET}" + fi + fi + fi + else + # Fallback: use grep/sed for parsing (no jq available) + local valid=$(echo "$body" | grep -o '"valid"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*:[[:space:]]*//' | tr -d ' "') + local public_key=$(echo "$body" | grep -o '"public_key"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') + local tier=$(echo "$body" | grep -o '"tier"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') + local expires_at=$(echo "$body" | grep -o '"expires_at"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') + local expired=$(echo "$body" | grep -o '"expired"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*:[[:space:]]*//' | tr -d ' "') + + [[ -z "$public_key" ]] && public_key="N/A" + [[ -z "$tier" ]] && tier="N/A" + [[ -z "$expires_at" ]] && expires_at="N/A" + + if [[ "$expired" == "true" ]]; then + echo -e "${RED}Expired${RESET}" + echo "Public Key: $public_key" + echo "Tier: $tier" + echo "Expired: $expires_at" + echo -e "${YELLOW}To renew: Visit ${PORTAL_BASE}/keys/extend${RESET}" + exit 1 + else + echo -e "${GREEN}Valid${RESET}" + echo "Public Key: $public_key" + echo "Tier: $tier" + echo "Expires: $expires_at" + + # If extend mode, open browser + if [[ "$extend_mode" == "true" ]]; then + local extend_url="${PORTAL_BASE}/keys/extend?pk=${public_key}" + echo -e "\n${BLUE}Opening browser to extend key...${RESET}" + + # Detect platform and open browser + if command -v xdg-open &> /dev/null; then + xdg-open "$extend_url" &> /dev/null + elif command -v open &> /dev/null; then + open "$extend_url" &> /dev/null + elif command -v start &> /dev/null; then + start "$extend_url" &> /dev/null + else + echo -e "${YELLOW}Cannot detect browser opener. Visit: $extend_url${RESET}" + fi + fi + fi + fi + else + # Invalid key or error + if command -v jq &> /dev/null; then + local error=$(echo "$body" | jq -r '.error // "Unknown error"') + echo -e "${RED}Invalid${RESET}" + echo "Error: $error" + else + # Fallback + local error=$(echo "$body" | grep -o '"error"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') + [[ -z "$error" ]] && error="Unknown error (HTTP $http_code)" + echo -e "${RED}Invalid${RESET}" + echo "Error: $error" + fi + exit 1 + fi +} + +cmd_key() { + local api_key="${UNSANDBOX_API_KEY}" + local extend=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + -k) + api_key="$2" + shift 2 + ;; + --extend) + extend=true + shift + ;; + *) + shift + ;; + esac + done + + validate_key "$api_key" "$extend" +} + # Main show_help() { cat < $0 session [options] $0 service [options] + $0 key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -634,6 +779,10 @@ Service options: --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + +Key options: + -k KEY API key to validate + --extend Validate and open browser to extend key EOF } @@ -650,6 +799,9 @@ if [[ "$1" == "session" ]]; then elif [[ "$1" == "service" ]]; then shift cmd_service "$@" +elif [[ "$1" == "key" ]]; then + shift + cmd_key "$@" else cmd_execute "$@" fi diff --git a/un.tcl b/un.tcl index 0109700..164b0f0 100644 --- a/un.tcl +++ b/un.tcl @@ -48,6 +48,7 @@ package require base64 ::http::register https 443 ::tls::socket set API_BASE "https://api.unsandbox.com" +set PORTAL_BASE "https://unsandbox.com" set BLUE "\033\[34m" set RED "\033\[31m" set GREEN "\033\[32m" @@ -351,6 +352,91 @@ proc cmd_session {args} { puts "${::YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${::RESET}" } +proc cmd_key {args} { + set api_key [get_api_key] + set extend_mode 0 + + # Parse arguments + for {set i 0} {$i < [llength $args]} {incr i} { + set arg [lindex $args $i] + switch -exact -- $arg { + --extend { + set extend_mode 1 + } + } + } + + # POST to /keys/validate with Bearer auth + set url "${::PORTAL_BASE}/keys/validate" + set headers [list Authorization "Bearer $api_key" Content-Type "application/json"] + + set token [::http::geturl $url -method POST -headers $headers -timeout 30000] + set status [::http::status $token] + set ncode [::http::ncode $token] + set body [::http::data $token] + ::http::cleanup $token + + if {$status ne "ok"} { + puts stderr "${::RED}Error: Failed to connect to validation endpoint${::RESET}" + exit 1 + } + + if {$ncode == 401 || $ncode == 403} { + puts "${::RED}Invalid${::RESET}" + puts "Status: Invalid API key" + exit 1 + } + + if {$ncode != 200} { + puts stderr "${::RED}Error: HTTP $ncode${::RESET}" + puts stderr $body + exit 1 + } + + set result [::json::json2dict $body] + set key_status [dict get $result status] + set public_key [dict get $result public_key] + set tier [dict get $result tier] + + if {$key_status eq "valid"} { + puts "${::GREEN}Valid${::RESET}" + puts "Public Key: $public_key" + puts "Tier: $tier" + + if {[dict exists $result expires_at]} { + set expires_at [dict get $result expires_at] + puts "Expires: $expires_at" + } + + if {$extend_mode} { + set extend_url "${::PORTAL_BASE}/keys/extend?pk=${public_key}" + puts "${::YELLOW}Opening browser to extend key...${::RESET}" + exec xdg-open $extend_url & + } + } elseif {$key_status eq "expired"} { + puts "${::RED}Expired${::RESET}" + puts "Public Key: $public_key" + puts "Tier: $tier" + + if {[dict exists $result expired_at]} { + set expired_at [dict get $result expired_at] + puts "Expired: $expired_at" + } + + puts "${::YELLOW}To renew: Visit ${::PORTAL_BASE}/keys/extend${::RESET}" + + if {$extend_mode} { + set extend_url "${::PORTAL_BASE}/keys/extend?pk=${public_key}" + puts "${::YELLOW}Opening browser to extend key...${::RESET}" + exec xdg-open $extend_url & + } + } else { + puts "${::RED}Invalid${::RESET}" + puts "Status: Unknown key status" + exit 1 + } +} + proc cmd_service {args} { set api_key [get_api_key] set list_mode 0 @@ -525,6 +611,7 @@ 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 key \[--extend\]" exit 1 } @@ -534,6 +621,8 @@ proc main {argv} { cmd_session [lrange $argv 1 end] } elseif {$first_arg eq "service"} { cmd_service [lrange $argv 1 end] + } elseif {$first_arg eq "key"} { + cmd_key [lrange $argv 1 end] } else { cmd_execute $argv } diff --git a/un.ts b/un.ts index 8bc2a43..49bb8f1 100644 --- a/un.ts +++ b/un.ts @@ -56,6 +56,7 @@ import * as https from 'https'; import * as path from 'path'; const API_BASE = "https://api.unsandbox.com"; +const PORTAL_BASE = "https://unsandbox.com"; const BLUE = "\x1b[34m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; @@ -108,6 +109,7 @@ interface Args { destroy: string | null; execute: string | null; command_arg: string | null; + extend: boolean; } function getApiKey(argsKey: string | null): string { @@ -379,6 +381,42 @@ async function cmdService(args: Args): Promise { process.exit(1); } +async function cmdKey(args: Args): Promise { + const apiKey = getApiKey(args.apiKey); + + // Validate the key + const result = await apiRequest("/keys/validate", "POST", {}, apiKey); + + if (result.status === "valid") { + console.log(`${GREEN}Valid${RESET}`); + console.log(`Public Key: ${result.public_key || 'N/A'}`); + console.log(`Tier: ${result.tier || 'N/A'}`); + console.log(`Expires: ${result.expires_at || 'N/A'}`); + + // Handle --extend flag + if (args.extend && result.public_key) { + const extendUrl = `${PORTAL_BASE}/keys/extend?pk=${result.public_key}`; + console.log(`\n${BLUE}Opening: ${extendUrl}${RESET}`); + + // Try to open browser using common commands + const { exec } = require('child_process'); + exec(`xdg-open "${extendUrl}" || open "${extendUrl}" || start "${extendUrl}"`, (error: any) => { + if (error) { + console.error(`${YELLOW}Could not open browser automatically. Visit: ${extendUrl}${RESET}`); + } + }); + } + } else if (result.status === "expired") { + console.log(`${RED}Expired${RESET}`); + console.log(`Public Key: ${result.public_key || 'N/A'}`); + console.log(`Tier: ${result.tier || 'N/A'}`); + console.log(`Expired: ${result.expires_at || 'N/A'}`); + console.log(`${YELLOW}To renew: Visit ${PORTAL_BASE}/keys/extend${RESET}`); + } else { + console.log(`${RED}Invalid${RESET}`); + } +} + function parseArgs(argv: string[]): Args { const args: Args = { command: null, @@ -410,13 +448,14 @@ function parseArgs(argv: string[]): Args { destroy: null, execute: null, command_arg: null, + extend: false, }; let i = 2; while (i < argv.length) { const arg = argv[i]; - if (arg === 'session' || arg === 'service') { + if (arg === 'session' || arg === 'service' || arg === 'key') { args.command = arg; i++; } else if (arg === '-e' && i + 1 < argv.length) { @@ -500,6 +539,9 @@ function parseArgs(argv: string[]): Args { } else if (arg === '--command' && i + 1 < argv.length) { args.command_arg = argv[++i]; i++; + } else if (arg === '--extend') { + args.extend = true; + i++; } else if (!arg.startsWith('-')) { args.sourceFile = arg; i++; @@ -519,6 +561,8 @@ async function main(): Promise { await cmdSession(args); } else if (args.command === 'service') { await cmdService(args); + } else if (args.command === 'key') { + await cmdKey(args); } else if (args.sourceFile) { await cmdExecute(args); } else { @@ -528,6 +572,7 @@ Usage: ${process.argv[1]} [options] ${process.argv[1]} session [options] ${process.argv[1]} service [options] + ${process.argv[1]} key [options] Execute options: -e KEY=VALUE Environment variable (multiple allowed) @@ -562,6 +607,9 @@ Service options: --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) + +Key options: + --extend Open browser to extend key expiration `); process.exit(1); } diff --git a/un.v b/un.v index 149d66a..5994266 100644 --- a/un.v +++ b/un.v @@ -46,12 +46,13 @@ import os const ( - api_base = 'https://api.unsandbox.com' - blue = '\x1b[34m' - red = '\x1b[31m' - green = '\x1b[32m' - yellow = '\x1b[33m' - reset = '\x1b[0m' + api_base = 'https://api.unsandbox.com' + portal_base = 'https://unsandbox.com' + blue = '\x1b[34m' + red = '\x1b[31m' + green = '\x1b[32m' + yellow = '\x1b[33m' + reset = '\x1b[0m' ) fn detect_language(filename string) !string { @@ -99,6 +100,82 @@ fn exec_curl(cmd string) string { return result.output } +fn extract_json_string(json string, key string) string { + search := '"${key}":"' + start_idx := json.index(search) or { return '' } + start := start_idx + search.len + + mut end := start + for end < json.len { + if json[end] == `"` && (end == 0 || json[end - 1] != `\\`) { + break + } + end++ + } + + if end > start { + raw := json[start..end] + // Unescape JSON string + return raw.replace('\\n', '\n') + .replace('\\r', '\r') + .replace('\\t', '\t') + .replace('\\"', '"') + .replace('\\\\', '\\') + } + return '' +} + +fn cmd_key(extend bool, api_key string) { + cmd := "curl -s -X POST '${api_base}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '{}'" + result := exec_curl(cmd) + + status := extract_json_string(result, 'status') + public_key := extract_json_string(result, 'public_key') + tier := extract_json_string(result, 'tier') + expired_at := extract_json_string(result, 'expired_at') + + if extend && public_key != '' { + url := '${portal_base}/keys/extend?pk=${public_key}' + println('${yellow}Opening browser: ${url}${reset}') + + // Try xdg-open (Linux), open (macOS), or start (Windows) + os.execute('xdg-open "${url}"') or { + os.execute('open "${url}"') or { + os.execute('cmd /c start "${url}"') or { + eprintln('${red}Error: Could not open browser${reset}') + } + } + } + return + } + + match status { + 'valid' { + println('${green}Valid${reset}') + println('Public Key: ${public_key}') + println('Tier: ${tier}') + if expired_at != '' { + println('Expires: ${expired_at}') + } + } + 'expired' { + println('${red}Expired${reset}') + println('Public Key: ${public_key}') + println('Tier: ${tier}') + if expired_at != '' { + println('Expired: ${expired_at}') + } + println('${yellow}To renew: Visit ${portal_base}/keys/extend${reset}') + } + 'invalid' { + println('${red}Invalid${reset}') + } + else { + println('${yellow}Unknown status: ${status}${reset}') + } + } +} + fn cmd_execute(source_file string, envs []string, artifacts bool, network string, vcpu int, api_key string) { lang := detect_language(source_file) or { eprintln('${red}Error: ${err}${reset}') @@ -263,6 +340,7 @@ fn main() { eprintln('Usage: ${os.args[0]} [options] ') eprintln(' ${os.args[0]} session [options]') eprintln(' ${os.args[0]} service [options]') + eprintln(' ${os.args[0]} key [--extend]') exit(1) } @@ -391,6 +469,26 @@ fn main() { return } + if os.args[1] == 'key' { + mut extend := false + + mut i := 2 + for i < os.args.len { + match os.args[i] { + '--extend' { extend = true } + '-k' { + i++ + api_key = os.args[i] + } + else {} + } + i++ + } + + cmd_key(extend, api_key) + return + } + // Execute mode mut envs := []string{} mut artifacts := false diff --git a/un.zig b/un.zig index c0f2245..902f579 100644 --- a/un.zig +++ b/un.zig @@ -52,6 +52,7 @@ const process = std.process; const mem = std.mem; const API_BASE = "https://api.unsandbox.com"; +const PORTAL_BASE = "https://unsandbox.com"; pub fn main() !u8 { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -65,6 +66,7 @@ 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} key [--extend]\n", .{args[0]}); return 1; } @@ -171,6 +173,136 @@ pub fn main() !u8 { return 0; } + // Handle key command + if (mem.eql(u8, args[1], "key")) { + var extend = false; + var i: usize = 2; + while (i < args.len) : (i += 1) { + if (mem.eql(u8, args[i], "--extend")) { + extend = true; + } + } + + if (extend) { + // First validate to get the public_key + const json_file = "/tmp/unsandbox_key_validate.json"; + const cmd_validate = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -o {s}", .{ PORTAL_BASE, api_key, json_file }); + defer allocator.free(cmd_validate); + _ = std.c.system(cmd_validate.ptr); + + // Read the JSON response to extract public_key + const json_content = fs.cwd().readFileAlloc(allocator, json_file, 1024 * 1024) catch |err| { + std.debug.print("\x1b[31mError reading validation response: {}\x1b[0m\n", .{err}); + std.fs.cwd().deleteFile(json_file) catch {}; + return 1; + }; + defer allocator.free(json_content); + std.fs.cwd().deleteFile(json_file) catch {}; + + // Simple JSON parsing to find public_key (looking for "public_key":"value") + const pk_prefix = "\"public_key\":\""; + var public_key: ?[]const u8 = null; + if (mem.indexOf(u8, json_content, pk_prefix)) |start_idx| { + const value_start = start_idx + pk_prefix.len; + if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { + public_key = json_content[value_start..end_idx]; + } + } + + if (public_key) |pk| { + const url = try std.fmt.allocPrint(allocator, "{s}/keys/extend?pk={s}", .{ PORTAL_BASE, pk }); + defer allocator.free(url); + std.debug.print("\x1b[33mOpening browser to extend key...\x1b[0m\n", .{}); + const open_cmd = try std.fmt.allocPrint(allocator, "xdg-open '{s}' 2>/dev/null || open '{s}' 2>/dev/null || start '{s}' 2>/dev/null", .{ url, url, url }); + defer allocator.free(open_cmd); + _ = std.c.system(open_cmd.ptr); + } else { + std.debug.print("\x1b[31mError: Could not extract public_key from response\x1b[0m\n", .{}); + return 1; + } + } else { + // Regular validation + const json_file = "/tmp/unsandbox_key_validate.json"; + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -o {s}", .{ PORTAL_BASE, api_key, json_file }); + defer allocator.free(cmd); + _ = std.c.system(cmd.ptr); + + // Read and parse the response + const json_content = fs.cwd().readFileAlloc(allocator, json_file, 1024 * 1024) catch |err| { + std.debug.print("\x1b[31mError reading validation response: {}\x1b[0m\n", .{err}); + std.fs.cwd().deleteFile(json_file) catch {}; + return 1; + }; + defer allocator.free(json_content); + std.fs.cwd().deleteFile(json_file) catch {}; + + // Simple JSON parsing (looking for specific fields) + const status_prefix = "\"status\":\""; + var status: ?[]const u8 = null; + if (mem.indexOf(u8, json_content, status_prefix)) |start_idx| { + const value_start = start_idx + status_prefix.len; + if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { + status = json_content[value_start..end_idx]; + } + } + + if (status == null) { + std.debug.print("\x1b[31mError: Invalid response from server\x1b[0m\n", .{}); + return 1; + } + + // Extract other fields + var public_key: ?[]const u8 = null; + var tier: ?[]const u8 = null; + var expires_at: ?[]const u8 = null; + + const pk_prefix = "\"public_key\":\""; + if (mem.indexOf(u8, json_content, pk_prefix)) |start_idx| { + const value_start = start_idx + pk_prefix.len; + if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { + public_key = json_content[value_start..end_idx]; + } + } + + const tier_prefix = "\"tier\":\""; + if (mem.indexOf(u8, json_content, tier_prefix)) |start_idx| { + const value_start = start_idx + tier_prefix.len; + if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { + tier = json_content[value_start..end_idx]; + } + } + + const expires_prefix = "\"expires_at\":\""; + if (mem.indexOf(u8, json_content, expires_prefix)) |start_idx| { + const value_start = start_idx + expires_prefix.len; + if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { + expires_at = json_content[value_start..end_idx]; + } + } + + // Display results based on status + if (status) |s| { + if (mem.eql(u8, s, "valid")) { + std.debug.print("\x1b[32mValid\x1b[0m\n", .{}); + if (public_key) |pk| std.debug.print("Public Key: {s}\n", .{pk}); + if (tier) |t| std.debug.print("Tier: {s}\n", .{t}); + if (expires_at) |exp| std.debug.print("Expires: {s}\n", .{exp}); + } else if (mem.eql(u8, s, "expired")) { + std.debug.print("\x1b[31mExpired\x1b[0m\n", .{}); + if (public_key) |pk| std.debug.print("Public Key: {s}\n", .{pk}); + if (tier) |t| std.debug.print("Tier: {s}\n", .{t}); + if (expires_at) |exp| std.debug.print("Expired: {s}\n", .{exp}); + std.debug.print("\x1b[33mTo renew: Visit {s}/keys/extend\x1b[0m\n", .{PORTAL_BASE}); + } else if (mem.eql(u8, s, "invalid")) { + std.debug.print("\x1b[31mInvalid\x1b[0m\n", .{}); + } else { + std.debug.print("Status: {s}\n", .{s}); + } + } + } + return 0; + } + // Execute mode - find source file var source_file: ?[]const u8 = null; for (args[1..]) |arg| {