From 04060aa4da9c86bedf57b2e81f4bf1c47e061316 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 22 Jan 2026 16:36:38 -0500 Subject: [PATCH] feat: add .NET 10 CLI implementation - Single-file Un.cs using modern C# features - Top-level statements, HttpClient, System.Text.Json - Full feature parity with Mono version (648 lines vs 1260) - Supports execute, session, service, service env, key commands --- clients/dotnet/Makefile | 38 ++ clients/dotnet/sync/src/Un.cs | 648 ++++++++++++++++++++++++++++++ clients/dotnet/sync/src/Un.csproj | 12 + 3 files changed, 698 insertions(+) create mode 100644 clients/dotnet/Makefile create mode 100644 clients/dotnet/sync/src/Un.cs create mode 100644 clients/dotnet/sync/src/Un.csproj diff --git a/clients/dotnet/Makefile b/clients/dotnet/Makefile new file mode 100644 index 0000000..f6ebf55 --- /dev/null +++ b/clients/dotnet/Makefile @@ -0,0 +1,38 @@ +# UN CLI - .NET 10 Implementation + +.PHONY: build run clean test help + +BUILD_DIR := sync/src +BINARY := sync/src/bin/Release/net10.0/un + +build: + cd $(BUILD_DIR) && dotnet build -c Release + +run: + cd $(BUILD_DIR) && dotnet run -- + +clean: + cd $(BUILD_DIR) && dotnet clean + rm -rf $(BUILD_DIR)/bin $(BUILD_DIR)/obj + +test: build + @echo "Testing --help..." + cd $(BUILD_DIR) && dotnet run -- --help + @echo "" + @echo "Testing --version..." + cd $(BUILD_DIR) && dotnet run -- --version + +test-cli: build + @echo "CLI tests require UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY" + cd $(BUILD_DIR) && dotnet run -- key + +help: + @echo "UN CLI (.NET 10) Makefile" + @echo "" + @echo "Targets:" + @echo " build Build the CLI" + @echo " run Run the CLI" + @echo " clean Clean build artifacts" + @echo " test Run basic tests" + @echo " test-cli Run CLI tests (requires API keys)" + @echo " help Show this help" diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs new file mode 100644 index 0000000..2102f24 --- /dev/null +++ b/clients/dotnet/sync/src/Un.cs @@ -0,0 +1,648 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// +// Un.cs - Unsandbox CLI Client (.NET 10 Implementation) +// Build: dotnet build +// Run: dotnet run -- [options] +// Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +const string API_BASE = "https://api.unsandbox.com"; +const string PORTAL_BASE = "https://unsandbox.com"; +const string VERSION = "4.2.6"; + +// ANSI colors +const string BLUE = "\x1B[34m"; +const string RED = "\x1B[31m"; +const string GREEN = "\x1B[32m"; +const string YELLOW = "\x1B[33m"; +const string RESET = "\x1B[0m"; + +var extMap = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + [".py"] = "python", [".js"] = "javascript", [".ts"] = "typescript", + [".rb"] = "ruby", [".php"] = "php", [".pl"] = "perl", [".lua"] = "lua", + [".sh"] = "bash", [".go"] = "go", [".rs"] = "rust", [".c"] = "c", + [".cpp"] = "cpp", [".cc"] = "cpp", [".cxx"] = "cpp", + [".java"] = "java", [".kt"] = "kotlin", [".cs"] = "dotnet", [".fs"] = "fsharp", + [".hs"] = "haskell", [".ml"] = "ocaml", [".clj"] = "clojure", [".scm"] = "scheme", + [".lisp"] = "commonlisp", [".erl"] = "erlang", [".ex"] = "elixir", [".exs"] = "elixir", + [".jl"] = "julia", [".r"] = "r", [".R"] = "r", [".cr"] = "crystal", + [".d"] = "d", [".nim"] = "nim", [".zig"] = "zig", [".v"] = "v", + [".dart"] = "dart", [".groovy"] = "groovy", [".scala"] = "scala", + [".f90"] = "fortran", [".f95"] = "fortran", [".cob"] = "cobol", + [".pro"] = "prolog", [".forth"] = "forth", [".4th"] = "forth", + [".tcl"] = "tcl", [".raku"] = "raku", [".m"] = "objc" +}; + +var jsonOptions = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +}; + +using var httpClient = new HttpClient { BaseAddress = new Uri(API_BASE), Timeout = TimeSpan.FromMinutes(5) }; + +try +{ + var parsedArgs = ParseArgs(args); + + if (parsedArgs.ShowHelp) + { + PrintHelp(); + return 0; + } + + if (parsedArgs.ShowVersion) + { + Console.WriteLine($"un {VERSION} (.NET 10)"); + return 0; + } + + await (parsedArgs.Command switch + { + "session" => CmdSessionAsync(parsedArgs), + "service" => CmdServiceAsync(parsedArgs), + "key" => CmdKeyAsync(parsedArgs), + _ when parsedArgs.SourceFile != null => CmdExecuteAsync(parsedArgs), + _ => Task.Run(() => { PrintHelp(); Environment.Exit(1); }) + }); + + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"{RED}Error: {ex.Message}{RESET}"); + return 1; +} + +async Task CmdExecuteAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + var code = await File.ReadAllTextAsync(args.SourceFile!); + var language = DetectLanguage(args.SourceFile!); + + var payload = new Dictionary { ["language"] = language, ["code"] = code }; + + if (args.Env.Count > 0) + { + var envVars = args.Env + .Select(e => e.Split('=', 2)) + .Where(p => p.Length == 2) + .ToDictionary(p => p[0], p => p[1]); + if (envVars.Count > 0) payload["env"] = envVars; + } + + if (args.Files.Count > 0) + { + var inputFiles = new List>(); + foreach (var filepath in args.Files) + { + var content = await File.ReadAllBytesAsync(filepath); + inputFiles.Add(new Dictionary + { + ["filename"] = Path.GetFileName(filepath), + ["content_base64"] = Convert.ToBase64String(content) + }); + } + payload["input_files"] = inputFiles; + } + + if (args.Artifacts) payload["return_artifacts"] = true; + if (args.Network != null) payload["network"] = args.Network; + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + + var result = await ApiRequestAsync("/execute", HttpMethod.Post, payload, publicKey, secretKey); + + if (result.TryGetValue("stdout", out var stdout) && stdout is JsonElement stdoutEl) + Console.Write($"{BLUE}{stdoutEl.GetString()}{RESET}"); + if (result.TryGetValue("stderr", out var stderr) && stderr is JsonElement stderrEl) + Console.Error.Write($"{RED}{stderrEl.GetString()}{RESET}"); + + if (args.Artifacts && result.TryGetValue("artifacts", out var artifacts) && artifacts is JsonElement artifactsEl) + { + var outDir = args.OutputDir ?? "."; + Directory.CreateDirectory(outDir); + foreach (var artifact in artifactsEl.EnumerateArray()) + { + var filename = artifact.GetProperty("filename").GetString() ?? "artifact"; + var contentB64 = artifact.GetProperty("content_base64").GetString() ?? ""; + var path = Path.Combine(outDir, filename); + await File.WriteAllBytesAsync(path, Convert.FromBase64String(contentB64)); + Console.Error.WriteLine($"{GREEN}Saved: {path}{RESET}"); + } + } + + var exitCode = result.TryGetValue("exit_code", out var ec) && ec is JsonElement ecEl ? ecEl.GetInt32() : 0; + Environment.Exit(exitCode); +} + +async Task CmdSessionAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (args.SessionList) + { + var result = await ApiRequestAsync("/sessions", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("sessions", out var sessions) && sessions is JsonElement sessionsEl) + { + var sessionList = sessionsEl.EnumerateArray().ToList(); + if (sessionList.Count == 0) { Console.WriteLine("No active sessions"); return; } + Console.WriteLine($"{"ID",-40} {"Shell",-10} {"Status",-10} {"Created"}"); + foreach (var s in sessionList) + { + Console.WriteLine($"{GetStr(s, "id"),-40} {GetStr(s, "shell"),-10} {GetStr(s, "status"),-10} {GetStr(s, "created_at")}"); + } + } + return; + } + + if (args.SessionKill != null) + { + await ApiRequestAsync($"/sessions/{args.SessionKill}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session terminated: {args.SessionKill}{RESET}"); + return; + } + + var payload = new Dictionary { ["shell"] = args.SessionShell ?? "bash" }; + if (args.Network != null) payload["network"] = args.Network; + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + + Console.WriteLine($"{YELLOW}Creating session...{RESET}"); + var createResult = await ApiRequestAsync("/sessions", HttpMethod.Post, payload, publicKey, secretKey); + var sessionId = createResult.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Session created: {sessionId}{RESET}"); + Console.WriteLine($"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}"); +} + +async Task CmdKeyAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + var result = await ApiRequestAsync("/keys/validate", HttpMethod.Post, null, publicKey, secretKey); + + if (!result.TryGetValue("valid", out var validObj) || validObj is not JsonElement validEl) + { + Console.Error.WriteLine($"{RED}Error: Invalid response from server{RESET}"); + Environment.Exit(1); + } + + var isValid = validEl.GetBoolean(); + var isExpired = result.TryGetValue("expired", out var expObj) && expObj is JsonElement expEl && expEl.GetBoolean(); + + if (isValid && !isExpired) + { + Console.WriteLine($"{GREEN}Valid{RESET}"); + if (result.TryGetValue("public_key", out var pk) && pk is JsonElement pkEl) Console.WriteLine($"Public Key: {pkEl.GetString()}"); + if (result.TryGetValue("tier", out var tier) && tier is JsonElement tierEl) Console.WriteLine($"Tier: {tierEl.GetString()}"); + if (result.TryGetValue("expires_at", out var exp) && exp is JsonElement expAtEl) Console.WriteLine($"Expires: {expAtEl.GetString()}"); + } + else if (isExpired) + { + Console.WriteLine($"{RED}Expired{RESET}"); + string? pkStr = null; + if (result.TryGetValue("public_key", out var pk) && pk is JsonElement pkEl) { pkStr = pkEl.GetString(); Console.WriteLine($"Public Key: {pkStr}"); } + if (result.TryGetValue("tier", out var tier) && tier is JsonElement tierEl) Console.WriteLine($"Tier: {tierEl.GetString()}"); + if (result.TryGetValue("expired_at", out var expAt) && expAt is JsonElement expAtEl) Console.WriteLine($"Expired: {expAtEl.GetString()}"); + Console.WriteLine($"{YELLOW}To renew: Visit {PORTAL_BASE}/keys/extend{RESET}"); + + if (args.KeyExtend && !string.IsNullOrEmpty(pkStr)) + { + var url = $"{PORTAL_BASE}/keys/extend?pk={pkStr}"; + Console.WriteLine($"{YELLOW}Opening: {url}{RESET}"); + OpenBrowser(url); + } + } + else + { + Console.WriteLine($"{RED}Invalid{RESET}"); + } +} + +void OpenBrowser(string url) +{ + try + { + if (OperatingSystem.IsWindows()) + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true }); + else if (OperatingSystem.IsLinux()) + System.Diagnostics.Process.Start("xdg-open", url); + else if (OperatingSystem.IsMacOS()) + System.Diagnostics.Process.Start("open", url); + } + catch (Exception ex) { Console.Error.WriteLine($"{RED}Failed to open browser: {ex.Message}{RESET}"); } +} + +async Task CmdServiceAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (!string.IsNullOrEmpty(args.EnvAction)) + { + await CmdServiceEnvAsync(args, publicKey, secretKey); + return; + } + + if (args.ServiceList) + { + var result = await ApiRequestAsync("/services", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("services", out var services) && services is JsonElement servicesEl) + { + var serviceList = servicesEl.EnumerateArray().ToList(); + if (serviceList.Count == 0) { Console.WriteLine("No services"); return; } + Console.WriteLine($"{"ID",-20} {"Name",-15} {"Status",-10} {"Ports",-15} {"Domains"}"); + foreach (var s in serviceList) + { + var ports = s.TryGetProperty("ports", out var p) ? string.Join(",", p.EnumerateArray().Select(x => x.GetInt32())) : ""; + var domains = s.TryGetProperty("domains", out var d) ? string.Join(",", d.EnumerateArray().Select(x => x.GetString())) : ""; + Console.WriteLine($"{GetStr(s, "id"),-20} {GetStr(s, "name"),-15} {GetStr(s, "status"),-10} {ports,-15} {domains}"); + } + } + return; + } + + if (args.ServiceInfo != null) + { + var result = await ApiRequestAsync($"/services/{args.ServiceInfo}", HttpMethod.Get, null, publicKey, secretKey); + Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (args.ServiceLogs != null) + { + var result = await ApiRequestAsync($"/services/{args.ServiceLogs}/logs", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("logs", out var logs) && logs is JsonElement logsEl) Console.WriteLine(logsEl.GetString()); + return; + } + + if (args.ServiceTail != null) + { + var result = await ApiRequestAsync($"/services/{args.ServiceTail}/logs?lines=9000", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("logs", out var logs) && logs is JsonElement logsEl) Console.WriteLine(logsEl.GetString()); + return; + } + + if (args.ServiceSleep != null) + { + await ApiRequestAsync($"/services/{args.ServiceSleep}/freeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service frozen: {args.ServiceSleep}{RESET}"); + return; + } + + if (args.ServiceWake != null) + { + await ApiRequestAsync($"/services/{args.ServiceWake}/unfreeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service unfreezing: {args.ServiceWake}{RESET}"); + return; + } + + if (args.ServiceDestroy != null) + { + await ApiRequestAsync($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); + return; + } + + if (args.ServiceExecute != null) + { + var payload = new Dictionary { ["command"] = args.ServiceCommand ?? "" }; + var result = await ApiRequestAsync($"/services/{args.ServiceExecute}/execute", HttpMethod.Post, payload, publicKey, secretKey); + if (result.TryGetValue("stdout", out var stdout) && stdout is JsonElement stdoutEl) Console.Write($"{BLUE}{stdoutEl.GetString()}{RESET}"); + if (result.TryGetValue("stderr", out var stderr) && stderr is JsonElement stderrEl) Console.Error.Write($"{RED}{stderrEl.GetString()}{RESET}"); + return; + } + + if (args.ServiceDumpBootstrap != null) + { + Console.Error.WriteLine($"Fetching bootstrap script from {args.ServiceDumpBootstrap}..."); + var payload = new Dictionary { ["command"] = "cat /tmp/bootstrap.sh" }; + var result = await ApiRequestAsync($"/services/{args.ServiceDumpBootstrap}/execute", HttpMethod.Post, payload, publicKey, secretKey); + var bootstrap = result.TryGetValue("stdout", out var bs) && bs is JsonElement bsEl ? bsEl.GetString() : null; + if (!string.IsNullOrEmpty(bootstrap)) + { + if (args.ServiceDumpFile != null) + { + await File.WriteAllTextAsync(args.ServiceDumpFile, bootstrap); + Console.WriteLine($"Bootstrap saved to {args.ServiceDumpFile}"); + } + else Console.Write(bootstrap); + } + else + { + Console.Error.WriteLine($"{RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file){RESET}"); + Environment.Exit(1); + } + return; + } + + if (args.ServiceName != null) + { + var payload = new Dictionary { ["name"] = args.ServiceName }; + if (args.ServicePorts != null) + payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (args.ServiceType != null) payload["service_type"] = args.ServiceType; + if (args.ServiceBootstrap != null) payload["bootstrap"] = args.ServiceBootstrap; + if (args.Network != null) payload["network"] = args.Network; + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + + var result = await ApiRequestAsync("/services", HttpMethod.Post, payload, publicKey, secretKey); + var serviceId = result.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : null; + Console.WriteLine($"{GREEN}Service created: {serviceId}{RESET}"); + if (result.TryGetValue("name", out var name) && name is JsonElement nameEl) Console.WriteLine($"Name: {nameEl.GetString()}"); + if (result.TryGetValue("url", out var url) && url is JsonElement urlEl) Console.WriteLine($"URL: {urlEl.GetString()}"); + + if (!string.IsNullOrEmpty(serviceId) && (args.Env.Count > 0 || !string.IsNullOrEmpty(args.EnvFile))) + { + var envContent = BuildEnvContent(args.Env, args.EnvFile); + if (!string.IsNullOrEmpty(envContent)) + { + if (await ServiceEnvSetAsync(serviceId, envContent, publicKey, secretKey)) + Console.WriteLine($"{GREEN}Vault configured with environment variables{RESET}"); + else + Console.Error.WriteLine($"{YELLOW}Warning: Failed to set vault{RESET}"); + } + } + return; + } + + Console.Error.WriteLine($"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}"); + Environment.Exit(1); +} + +async Task CmdServiceEnvAsync(Args args, string publicKey, string secretKey) +{ + var action = args.EnvAction; + var target = args.EnvTarget; + + if (action == "status") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env status requires service ID{RESET}"); Environment.Exit(1); } + var result = await ApiRequestAsync($"/services/{target}/env", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("has_vault", out var hv) && hv is JsonElement hvEl && hvEl.GetBoolean()) + { + Console.WriteLine($"{GREEN}Vault: configured{RESET}"); + if (result.TryGetValue("env_count", out var ec) && ec is JsonElement ecEl) Console.WriteLine($"Variables: {ecEl.GetInt32()}"); + if (result.TryGetValue("updated_at", out var ua) && ua is JsonElement uaEl) Console.WriteLine($"Updated: {uaEl.GetString()}"); + } + else Console.WriteLine($"{YELLOW}Vault: not configured{RESET}"); + } + else if (action == "set") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env set requires service ID{RESET}"); Environment.Exit(1); } + if (args.Env.Count == 0 && string.IsNullOrEmpty(args.EnvFile)) { Console.Error.WriteLine($"{RED}Error: service env set requires -e or --env-file{RESET}"); Environment.Exit(1); } + var envContent = BuildEnvContent(args.Env, args.EnvFile); + if (await ServiceEnvSetAsync(target, envContent, publicKey, secretKey)) + Console.WriteLine($"{GREEN}Vault updated for service {target}{RESET}"); + else { Console.Error.WriteLine($"{RED}Error: Failed to update vault{RESET}"); Environment.Exit(1); } + } + else if (action == "export") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env export requires service ID{RESET}"); Environment.Exit(1); } + var result = await ApiRequestAsync($"/services/{target}/env/export", HttpMethod.Post, null, publicKey, secretKey); + if (result.TryGetValue("content", out var content) && content is JsonElement contentEl) Console.Write(contentEl.GetString()); + } + else if (action == "delete") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env delete requires service ID{RESET}"); Environment.Exit(1); } + await ApiRequestAsync($"/services/{target}/env", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Vault deleted for service {target}{RESET}"); + } +} + +async Task> ApiRequestAsync(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) +{ + var body = data != null ? JsonSerializer.Serialize(data, jsonOptions) : ""; + + using var request = new HttpRequestMessage(method, endpoint); + if (data != null) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + // HMAC Authentication + if (!string.IsNullOrEmpty(secretKey)) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var message = $"{timestamp}:{method.Method}:{endpoint}:{body}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + } + else + { + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + } + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + if (responseBody.Contains("timestamp") && ((int)response.StatusCode == 401 || responseBody.ToLower().Contains("expired"))) + { + Console.Error.WriteLine($"{RED}Error: Request timestamp expired (must be within 5 minutes of server time){RESET}"); + Console.Error.WriteLine($"{YELLOW}Your computer's clock may have drifted.{RESET}"); + Environment.Exit(1); + } + throw new Exception($"HTTP {(int)response.StatusCode}: {responseBody}"); + } + + if (string.IsNullOrWhiteSpace(responseBody)) return new Dictionary(); + + try + { + var doc = JsonDocument.Parse(responseBody); + return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object)p.Value.Clone()); + } + catch + { + return new Dictionary { ["raw"] = responseBody }; + } +} + +async Task ServiceEnvSetAsync(string serviceId, string envContent, string publicKey, string secretKey) +{ + if (envContent.Length > 65536) { Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}"); return false; } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Put, $"/services/{serviceId}/env"); + request.Content = new StringContent(envContent, Encoding.UTF8, "text/plain"); + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var message = $"{timestamp}:PUT:/services/{serviceId}/env:{envContent}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + + var response = await httpClient.SendAsync(request); + return response.IsSuccessStatusCode; + } + catch { return false; } +} + +(string, string) GetApiKeys(string? argsKey) +{ + var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); + var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); + + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) + { + var legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); + if (string.IsNullOrEmpty(legacyKey)) + { + Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); + Environment.Exit(1); + } + return (legacyKey, ""); + } + return (publicKey, secretKey); +} + +string DetectLanguage(string filename) +{ + var ext = Path.GetExtension(filename).ToLower(); + if (string.IsNullOrEmpty(ext) || !extMap.TryGetValue(ext, out var language)) + throw new Exception($"Unsupported file extension: {ext}"); + return language; +} + +string BuildEnvContent(List envs, string? envFile) +{ + var lines = new List(envs); + if (!string.IsNullOrEmpty(envFile)) + { + var content = File.ReadAllText(envFile); + lines.AddRange(content.Split('\n').Select(l => l.Trim()).Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("#"))); + } + return string.Join("\n", lines); +} + +string GetStr(JsonElement el, string prop) => el.TryGetProperty(prop, out var p) ? p.GetString() ?? "N/A" : "N/A"; + +Args ParseArgs(string[] args) +{ + var result = new Args(); + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "-h" || arg == "--help") result.ShowHelp = true; + else if (arg == "--version") result.ShowVersion = true; + else if (arg == "session") result.Command = "session"; + else if (arg == "service") result.Command = "service"; + else if (arg == "key") result.Command = "key"; + else if (arg == "env" && result.Command == "service") + { + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) + { + result.EnvAction = args[++i]; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) result.EnvTarget = args[++i]; + } + } + else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i]; + else if (arg == "-n" || arg == "--network") result.Network = args[++i]; + else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]); + else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]); + else if (arg == "--env-file") result.EnvFile = args[++i]; + else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]); + else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true; + else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i]; + else if (arg == "-l" || arg == "--list") { if (result.Command == "session") result.SessionList = true; else if (result.Command == "service") result.ServiceList = true; } + else if (arg == "-s" || arg == "--shell") result.SessionShell = args[++i]; + else if (arg == "--kill") result.SessionKill = args[++i]; + else if (arg == "--name") result.ServiceName = args[++i]; + else if (arg == "--ports") result.ServicePorts = args[++i]; + else if (arg == "--type") result.ServiceType = args[++i]; + else if (arg == "--bootstrap") result.ServiceBootstrap = args[++i]; + else if (arg == "--info") result.ServiceInfo = args[++i]; + else if (arg == "--logs") result.ServiceLogs = args[++i]; + else if (arg == "--tail") result.ServiceTail = args[++i]; + else if (arg == "--freeze") result.ServiceSleep = args[++i]; + else if (arg == "--unfreeze") result.ServiceWake = args[++i]; + else if (arg == "--destroy") result.ServiceDestroy = args[++i]; + else if (arg == "--execute") result.ServiceExecute = args[++i]; + else if (arg == "--command") result.ServiceCommand = args[++i]; + else if (arg == "--dump-bootstrap") result.ServiceDumpBootstrap = args[++i]; + else if (arg == "--dump-file") result.ServiceDumpFile = args[++i]; + else if (arg == "--extend") result.KeyExtend = true; + else if (!arg.StartsWith("-")) result.SourceFile = arg; + } + return result; +} + +void PrintHelp() +{ + Console.WriteLine($@"un {VERSION} (.NET 10) - Unsandbox CLI + +Usage: dotnet run -- [options] + dotnet run -- session [options] + dotnet run -- service [options] + dotnet run -- service env [options] + dotnet run -- key [options] + +Execute options: + -e KEY=VALUE Set environment variable + -f FILE Add input file + -a Return artifacts + -o DIR Output directory for artifacts + -n MODE Network mode (zerotrust/semitrusted) + -v N vCPU count (1-8) + -k KEY API key + +Session options: + --list List active sessions + --shell NAME Shell/REPL to use + --kill ID Terminate session + +Service options: + --list List services + --name NAME Create service with name + --ports PORTS Comma-separated ports + --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) + --bootstrap CMD Bootstrap command + --info ID Get service details + --logs ID Get all logs + --tail ID Get last 9000 lines + --freeze ID Freeze service + --unfreeze ID Unfreeze service + --destroy ID Destroy service + --execute ID Execute command in service + --command CMD Command to execute (with --execute) + --dump-bootstrap ID Dump bootstrap script + --dump-file FILE File to save bootstrap (with --dump-bootstrap) + -e KEY=VALUE Set vault env var (with --name or env set) + --env-file FILE Load vault vars from file + +Service env commands: + env status ID Check vault status + env set ID Set vault (use -e or --env-file) + env export ID Export vault contents + env delete ID Delete vault + +Key options: + --extend Open browser to extend expired key + +Environment: + UNSANDBOX_PUBLIC_KEY Your public API key + UNSANDBOX_SECRET_KEY Your secret API key"); +} + +class Args +{ + public bool ShowHelp, ShowVersion; + public string? Command, SourceFile, ApiKey, Network, OutputDir; + public int Vcpu; + public List Env = new(), Files = new(); + public bool Artifacts, SessionList, ServiceList; + public string? SessionShell, SessionKill; + public string? ServiceName, ServicePorts, ServiceBootstrap, ServiceType; + public string? ServiceInfo, ServiceLogs, ServiceTail, ServiceSleep, ServiceWake, ServiceDestroy; + public string? ServiceExecute, ServiceCommand; + public string? ServiceDumpBootstrap, ServiceDumpFile; + public string? EnvFile, EnvAction, EnvTarget; + public bool KeyExtend; +} diff --git a/clients/dotnet/sync/src/Un.csproj b/clients/dotnet/sync/src/Un.csproj new file mode 100644 index 0000000..51c1526 --- /dev/null +++ b/clients/dotnet/sync/src/Un.csproj @@ -0,0 +1,12 @@ + + + + Exe + net10.0 + Unsandbox + enable + enable + un + + +