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
This commit is contained in:
parent
a6aad7ec7b
commit
04060aa4da
3 changed files with 698 additions and 0 deletions
38
clients/dotnet/Makefile
Normal file
38
clients/dotnet/Makefile
Normal file
|
|
@ -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"
|
||||
648
clients/dotnet/sync/src/Un.cs
Normal file
648
clients/dotnet/sync/src/Un.cs
Normal file
|
|
@ -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] <source_file>
|
||||
// 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<string, string>(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<string, object> { ["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<Dictionary<string, string>>();
|
||||
foreach (var filepath in args.Files)
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(filepath);
|
||||
inputFiles.Add(new Dictionary<string, string>
|
||||
{
|
||||
["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<string, object> { ["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<string, object> { ["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<string, object> { ["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<string, object> { ["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<Dictionary<string, object>> ApiRequestAsync(string endpoint, HttpMethod method, Dictionary<string, object>? 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<string, object>();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(responseBody);
|
||||
return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object)p.Value.Clone());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, object> { ["raw"] = responseBody };
|
||||
}
|
||||
}
|
||||
|
||||
async Task<bool> 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<string> envs, string? envFile)
|
||||
{
|
||||
var lines = new List<string>(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] <source_file>
|
||||
dotnet run -- session [options]
|
||||
dotnet run -- service [options]
|
||||
dotnet run -- service env <action> <service_id> [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<string> 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;
|
||||
}
|
||||
12
clients/dotnet/sync/src/Un.csproj
Normal file
12
clients/dotnet/sync/src/Un.csproj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Unsandbox</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>un</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Loading…
Add table
Add a link
Reference in a new issue