From c6813e5a18643d6ada06a503c9d1099ce64354af Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 2 Jan 2026 14:06:36 -0500 Subject: [PATCH] Add -f FILE flag support for session and service commands All 38+ implementations now support: - session -f FILE: Upload files to /tmp/ in session container - service -f FILE: Upload files to /tmp/ in service container - service --bootstrap-file FILE: Read bootstrap script from file --- CLAUDE.md | 26 +++++++++ Un.java | 25 +++++++++ un.awk | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- un.clj | 153 ++++++++++++++++++++++++++++++++--------------------- un.cob | 114 +++++++++++++++++++++++++++++++++++++-- un.cpp | 84 +++++++++++++++++++++++++---- un.cr | 68 +++++++++++++++++++++--- un.d | 54 +++++++++++++++---- un.dart | 49 +++++++++++++++++ un.erl | 48 ++++++++++++++++- un.ex | 50 +++++++++++++++++- un.f90 | 102 ++++++++++++++++++++++++++++++++--- un.forth | 103 ++++++++++++++++++++++++++++++++---- un.fs | 23 ++++++++ un.go | 62 ++++++++++++++++++---- un.groovy | 44 ++++++++++++++++ un.hs | 41 +++++++++++++-- un.jl | 78 ++++++++++++++++++++++++--- un.js | 48 +++++++++++++++-- un.kt | 42 +++++++++++++++ un.lisp | 95 ++++++++++++++++++++++++++------- un.lua | 47 ++++++++++++++--- un.m | 63 ++++++++++++++++++++-- un.ml | 92 +++++++++++++++++++++++--------- un.nim | 58 ++++++++++++++++---- un.php | 49 +++++++++++++++-- un.pl | 62 +++++++++++++++++++--- un.pro | 126 ++++++++++++++++++++++++++++++++------------ un.ps1 | 65 +++++++++++++++++++++-- un.py | 51 +++++++++++++++--- un.r | 67 +++++++++++++++++++++-- un.raku | 61 +++++++++++++++++++-- un.rb | 48 ++++++++++++++--- un.rs | 83 +++++++++++++++++++++++++++-- un.scm | 121 +++++++++++++++++++++++++++++++++--------- un.sh | 64 +++++++++++++++++++--- un.tcl | 67 +++++++++++++++++++++-- un.ts | 50 ++++++++++++++++-- un.v | 73 +++++++++++++++++++++---- un.zig | 124 ++++++++++++++++++++++++++++++++++++++++++- 40 files changed, 2506 insertions(+), 329 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb65a56..bb4d1f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,32 @@ The pattern `((VAR++))` returns exit code 1 when VAR is 0. Use `VAR=$((VAR + 1)) ### Shebang lines Shebang MUST be on line 1, not buried in license headers. +## Keeping Implementations in Sync + +**CRITICAL: ALL 38 implementations must have feature parity.** + +When adding a new feature to the CLI (e.g., new flag, new command): +1. Update the canonical C implementation at `~/git/unsandbox.com/cli/un.c` +2. Update ALL 38 implementations in this repo - not just "main" ones, ALL of them +3. Use the Task agent to batch update if needed + +Current implementations (ALL must be updated): +``` +un.awk un.clj un.cob un.cpp un.cr un.d un.dart un.erl un.ex un.f90 +un.forth un.fs un.go un.groovy un.hs un.jl un.js un.kt un.lisp un.lua +un.m un.ml un.nim un.php un.pl un.pro un.ps1 un.py un.r un.raku +un.rb un.rs un.scm un.sh un.tcl un.ts un.v un.zig +``` + +### Feature Checklist + +Each implementation must support: +- **Execute**: `un file.py` - run code with `-e ENV=val`, `-f FILE`, `-n MODE`, `-a` artifacts +- **Session**: `un session` - interactive shell with `-f FILE`, `--tmux`, `--screen`, `--list`, `--attach`, `--kill` +- **Service**: `un service` - persistent services with `-f FILE`, `--name`, `--ports`, `--bootstrap`, `--bootstrap-file`, `--list`, `--info`, `--logs`, `--destroy` + +The `-f FILE` flag must work for ALL three commands (execute, session, service) - files go to `/tmp/` in the container. + ## Related Repos - `~/git/unsandbox.com/` - Portal (contains un.c CLI at cli/un.c) diff --git a/Un.java b/Un.java index 64a1dd2..f72cee5 100644 --- a/Un.java +++ b/Un.java @@ -210,6 +210,19 @@ public class Un { payload.put("vcpu", args.vcpu); } + // Add input files + if (args.files != null && !args.files.isEmpty()) { + List> inputFiles = new ArrayList<>(); + for (String filepath : args.files) { + byte[] content = Files.readAllBytes(Paths.get(filepath)); + Map fileObj = new HashMap<>(); + fileObj.put("filename", Paths.get(filepath).getFileName().toString()); + fileObj.put("content_base64", Base64.getEncoder().encodeToString(content)); + inputFiles.add(fileObj); + } + payload.put("input_files", inputFiles); + } + System.out.println(YELLOW + "Creating session..." + RESET); Map result = apiRequest("/sessions", "POST", payload, publicKey, secretKey); System.out.println(GREEN + "Session created: " + result.getOrDefault("id", "N/A") + RESET); @@ -341,6 +354,18 @@ public class Un { if (args.serviceBootstrap != null) { payload.put("bootstrap", args.serviceBootstrap); } + // Add input files + if (args.files != null && !args.files.isEmpty()) { + List> inputFiles = new ArrayList<>(); + for (String filepath : args.files) { + byte[] content = Files.readAllBytes(Paths.get(filepath)); + Map fileObj = new HashMap<>(); + fileObj.put("filename", Paths.get(filepath).getFileName().toString()); + fileObj.put("content_base64", Base64.getEncoder().encodeToString(content)); + inputFiles.add(fileObj); + } + payload.put("input_files", inputFiles); + } if (args.network != null) { payload.put("network", args.network); } diff --git a/un.awk b/un.awk index 074ea67..feb2dd0 100644 --- a/un.awk +++ b/un.awk @@ -326,7 +326,90 @@ function service_dump_bootstrap(id, dump_file , endpoint, json_body, timestam } } -function service_create(name, ports, domains, service_type, bootstrap , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd) { +function read_and_base64(filepath , cmd, b64) { + cmd = "base64 -w0 '" filepath "' 2>/dev/null || base64 '" filepath "'" + cmd | getline b64 + close(cmd) + return b64 +} + +function build_input_files_json(files_str , n, files, i, fname, b64, json) { + if (files_str == "") return "" + n = split(files_str, files, ",") + json = ",\"input_files\":[" + for (i = 1; i <= n; i++) { + fname = files[i] + b64 = read_and_base64(fname) + if (i > 1) json = json "," + # Get just the basename for filename + cmd = "basename '" fname "'" + cmd | getline basename + close(cmd) + json = json "{\"filename\":\"" escape_json(basename) "\",\"content\":\"" b64 "\"}" + } + json = json "]" + return json +} + +function session_create(shell, network, vcpu, input_files , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response, input_files_json) { + get_api_keys() + + # Build JSON payload + json = "{\"shell\":\"" (shell != "" ? shell : "bash") "\"" + + if (network != "") { + json = json ",\"network\":\"" escape_json(network) "\"" + } + + if (vcpu != "") { + json = json ",\"vcpu\":" vcpu + } + + # Add input_files if provided + input_files_json = build_input_files_json(input_files) + if (input_files_json != "") { + json = json input_files_json + } + + json = json "}" + + # Write to temp file + tmp = "/tmp/un_awk_sess_" PROCINFO["pid"] ".json" + print json > tmp + close(tmp) + + # Build HMAC signature if secret key exists + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:/sessions:" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + + # Call curl + cmd = "curl -s -X POST '" API_BASE "/sessions' " \ + "-H 'Content-Type: application/json' " \ + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers \ + "-d '@" tmp "'" + + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + + # Clean up + system("rm -f " tmp) + + print YELLOW "Session created (WebSocket required)" RESET + print response +} + +function service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, boot_content, line, input_files_json) { get_api_keys() # Build JSON payload @@ -355,6 +438,29 @@ function service_create(name, ports, domains, service_type, bootstrap , json, json = json ",\"bootstrap\":\"" escape_json(bootstrap) "\"" } + if (bootstrap_file != "") { + # Read file content + boot_content = "" + while ((getline line < bootstrap_file) > 0) { + if (boot_content != "") boot_content = boot_content "\n" + boot_content = boot_content line + } + close(bootstrap_file) + + if (boot_content == "") { + print RED "Error: Bootstrap file not found or empty: " bootstrap_file RESET > "/dev/stderr" + exit 1 + } + + json = json ",\"bootstrap_content\":\"" escape_json(boot_content) "\"" + } + + # Add input_files if provided + input_files_json = build_input_files_json(input_files) + if (input_files_json != "") { + json = json input_files_json + } + json = json "}" # Write to temp file @@ -495,12 +601,17 @@ function show_help() { print "Usage: awk -f un.awk " print " awk -f un.awk session --list" print " awk -f un.awk session --kill ID" + print " awk -f un.awk session [-s SHELL] [-f FILE]..." print " awk -f un.awk key [--extend]" print " awk -f un.awk service --list" - print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD]" + print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD] [-f FILE]..." print " awk -f un.awk service --destroy ID" print " awk -f un.awk service --dump-bootstrap ID [--dump-file FILE]" print "" + print "Session options:" + print " -s, --shell SHELL Shell to use (default: bash)" + print " -f FILE Input file to upload (can be repeated)" + print "" print "Service options:" print " --name NAME Service name (required for --create)" print " --ports PORTS Comma-separated port numbers" @@ -509,6 +620,7 @@ function show_help() { print " --bootstrap CMD Bootstrap command or script" print " --dump-bootstrap ID Dump bootstrap script from service" print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)" + print " -f FILE Input file to upload (can be repeated)" print "" print "Requires: UNSANDBOX_API_KEY environment variable" } @@ -536,7 +648,33 @@ END { } else if (ARGC >= 4 && ARGV[2] == "--kill") { session_kill(ARGV[3]) } else { - print "Usage: awk -f un.awk session --list|--kill ID" + # Parse session creation arguments + shell = "" + network = "" + vcpu = "" + input_files = "" + + i = 2 + while (i < ARGC) { + if ((ARGV[i] == "--shell" || ARGV[i] == "-s") && i + 1 < ARGC) { + shell = ARGV[i + 1] + i += 2 + } else if (ARGV[i] == "-n" && i + 1 < ARGC) { + network = ARGV[i + 1] + i += 2 + } else if (ARGV[i] == "-v" && i + 1 < ARGC) { + vcpu = ARGV[i + 1] + i += 2 + } else if (ARGV[i] == "-f" && i + 1 < ARGC) { + if (input_files != "") input_files = input_files "," + input_files = input_files ARGV[i + 1] + i += 2 + } else { + i++ + } + } + + session_create(shell, network, vcpu, input_files) } exit 0 } @@ -568,6 +706,8 @@ END { domains = "" service_type = "" bootstrap = "" + bootstrap_file = "" + input_files = "" i = 3 while (i < ARGC) { @@ -586,6 +726,13 @@ END { } else if (ARGV[i] == "--bootstrap" && i + 1 < ARGC) { bootstrap = ARGV[i + 1] i += 2 + } else if (ARGV[i] == "--bootstrap-file" && i + 1 < ARGC) { + bootstrap_file = ARGV[i + 1] + i += 2 + } else if (ARGV[i] == "-f" && i + 1 < ARGC) { + if (input_files != "") input_files = input_files "," + input_files = input_files ARGV[i + 1] + i += 2 } else { i++ } @@ -596,7 +743,7 @@ END { exit 1 } - service_create(name, ports, domains, service_type, bootstrap) + service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files) } else { print "Usage: awk -f un.awk service --list|--create|--destroy ID" } diff --git a/un.clj b/un.clj index 406b680..388e0e2 100644 --- a/un.clj +++ b/un.clj @@ -95,6 +95,21 @@ (str/replace "\\\"" "\"") (str/replace "\\\\" "\\"))) +(defn read-and-base64 [filepath] + (let [content (slurp filepath) + bytes (.getBytes content "UTF-8")] + (.encodeToString (java.util.Base64/getEncoder) bytes))) + +(defn build-input-files-json [files] + (if (empty? files) + "" + (let [file-jsons (map (fn [f] + (let [basename (-> (io/file f) .getName) + b64 (read-and-base64 f)] + (str "{\"filename\":\"" (escape-json basename) "\",\"content\":\"" b64 "\"}"))) + files)] + (str ",\"input_files\":[" (str/join "," file-jsons) "]")))) + (defn extract-field [field json-str] (let [pattern-str (re-pattern (str "\"" field "\":\"([^\"]*)\"")) pattern-num (re-pattern (str "\"" field "\":(\\d+)"))] @@ -235,7 +250,7 @@ (flush))) (System/exit exit-code))))) -(defn session-command [action sid shell network vcpu] +(defn session-command [action sid shell network vcpu input-files] (let [api-key (get-api-key)] (case action :list (println (curl-get api-key "/sessions")) @@ -245,11 +260,12 @@ :create (let [sh (or shell "bash") network-json (if network (str ",\"network\":\"" network "\"") "") vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "") - json (str "{\"shell\":\"" sh "\"" network-json vcpu-json "}")] + input-files-json (build-input-files-json input-files) + json (str "{\"shell\":\"" sh "\"" network-json vcpu-json input-files-json "}")] (println (str yellow "Session created (WebSocket required)" reset)) (println (curl-post api-key "/sessions" json)))))) -(defn service-command [action sid name ports bootstrap service-type network vcpu] +(defn service-command [action sid name ports bootstrap bootstrap-file service-type network vcpu input-files] (let [api-key (get-api-key)] (case action :list (println (curl-get api-key "/services")) @@ -292,10 +308,14 @@ :create (when name (let [ports-json (if ports (str ",\"ports\":[" ports "]") "") bootstrap-json (if bootstrap (str ",\"bootstrap\":\"" (escape-json bootstrap) "\"") "") + bootstrap-content-json (if bootstrap-file + (str ",\"bootstrap_content\":\"" (escape-json (slurp bootstrap-file)) "\"") + "") service-type-json (if service-type (str ",\"service_type\":\"" service-type "\"") "") network-json (if network (str ",\"network\":\"" network "\"") "") vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "") - json (str "{\"name\":\"" name "\"" ports-json bootstrap-json service-type-json network-json vcpu-json "}")] + input-files-json (build-input-files-json input-files) + json (str "{\"name\":\"" name "\"" ports-json bootstrap-json bootstrap-content-json service-type-json network-json vcpu-json input-files-json "}")] (println (str green "Service created" reset)) (println (curl-post api-key "/services" json))))))) @@ -359,19 +379,22 @@ session-action nil session-id nil session-shell nil + session-input-files [] service-action nil service-id nil service-name nil service-ports nil service-bootstrap nil + service-bootstrap-file nil service-type nil + service-input-files [] 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) + :session (session-command (or session-action :create) session-id session-shell network vcpu session-input-files) + :service (service-command (or service-action :create) service-id service-name service-ports service-bootstrap service-bootstrap-file service-type network vcpu service-input-files) :key (key-command key-extend) :execute (if file (execute-command file env-vars artifacts out-dir network vcpu) @@ -382,117 +405,129 @@ (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 key-extend :session) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :session) (= (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 key-extend :service) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :service) (= (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) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :key) ;; 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) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files 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 key-extend mode) + (recur (rest args) file env-vars artifacts out-dir network vcpu :list session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu :kill (second args) session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id (second args) session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) + + (and (= mode :session) (= (first args) "-f")) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell (conj session-input-files (second args)) + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) ;; Service 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 key-extend mode) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :list service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :info (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :logs (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (and (= mode :service) (= (first args) "--freeze")) - (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :sleep (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (and (= mode :service) (= (first args) "--unfreeze")) - (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :wake (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :destroy (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (and (= mode :service) (= (first args) "--execute")) - (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - :execute (second args) service-name service-ports (nth args 2) service-type key-extend mode) + (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :execute (second args) service-name service-ports (nth args 2) service-bootstrap-file service-type service-input-files key-extend mode) (and (= mode :service) (= (first args) "--dump-bootstrap") (>= (count args) 3) (not (.startsWith (nth args 2) "-"))) - (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - :dump-bootstrap (second args) service-name service-ports service-bootstrap (nth args 2) key-extend mode) + (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :dump-bootstrap (second args) service-name service-ports service-bootstrap (nth args 2) service-type service-input-files key-extend mode) (and (= mode :service) (= (first args) "--dump-bootstrap")) - (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell - :dump-bootstrap (second args) service-name service-ports service-bootstrap service-type key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :dump-bootstrap (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :create service-id (second args) service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name (second args) service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports (second args) service-bootstrap-file service-type service-input-files key-extend mode) + + (and (= mode :service) (= (first args) "--bootstrap-file")) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap (second args) service-type service-input-files key-extend mode) (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) key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file (second args) service-input-files key-extend mode) + + (and (= mode :service) (= (first args) "-f")) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type (conj service-input-files (second args)) key-extend mode) ;; 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 key-extend mode)) + session-action session-id session-shell session-input-files service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files 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 key-extend mode) + (recur (rest args) file env-vars true out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (= (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts (second args) network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (= (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir (second args) vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) (= (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 key-extend mode) + (recur (rest (rest args)) file env-vars artifacts out-dir network (Integer/parseInt (second args)) session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) ;; 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 key-extend mode) + (recur (rest args) (first args) env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode) :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 key-extend mode)))) + (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)))) (parse-args *command-line-args*) diff --git a/un.cob b/un.cob index 0f32a47..0095487 100644 --- a/un.cob +++ b/un.cob @@ -75,6 +75,8 @@ 01 WS-DOMAINS PIC X(256). 01 WS-SERVICE-TYPE PIC X(64). 01 WS-BOOTSTRAP PIC X(2048). + 01 WS-BOOTSTRAP-FILE PIC X(256). + 01 WS-INPUT-FILES PIC X(1024). 01 WS-PORTAL-BASE PIC X(256) VALUE "https://unsandbox.com". 01 WS-EXTEND-FLAG PIC X(8). @@ -155,6 +157,9 @@ STOP RUN END-IF. + * Initialize session parameters + MOVE SPACES TO WS-INPUT-FILES. + * Parse session arguments (simplified) * For full implementation, would need to parse multiple args ACCEPT WS-ARG2 FROM ARGUMENT-VALUE. @@ -166,9 +171,8 @@ ACCEPT WS-ID FROM ARGUMENT-VALUE PERFORM SESSION-KILL ELSE - DISPLAY "Error: Use --list or --kill ID" - UPON SYSERR - MOVE 1 TO RETURN-CODE + PERFORM PARSE-SESSION-CREATE-ARGS + PERFORM SESSION-CREATE END-IF END-IF. @@ -187,6 +191,8 @@ MOVE SPACES TO WS-DOMAINS. MOVE SPACES TO WS-SERVICE-TYPE. MOVE SPACES TO WS-BOOTSTRAP. + MOVE SPACES TO WS-BOOTSTRAP-FILE. + MOVE SPACES TO WS-INPUT-FILES. * Parse service arguments ACCEPT WS-ARG2 FROM ARGUMENT-VALUE. @@ -356,6 +362,59 @@ CALL "SYSTEM" USING WS-CURL-CMD. + PARSE-SESSION-CREATE-ARGS. + * Parse arguments for session creation + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE. + PERFORM UNTIL WS-ARG3 = SPACES + IF WS-ARG3 = "-f" + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE + IF WS-INPUT-FILES NOT = SPACES + STRING FUNCTION TRIM(WS-INPUT-FILES) "," + FUNCTION TRIM(WS-ARG3) + DELIMITED BY SIZE INTO WS-INPUT-FILES + END-STRING + ELSE + MOVE WS-ARG3 TO WS-INPUT-FILES + END-IF + END-IF + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE + END-PERFORM. + + SESSION-CREATE. + * Build curl command for session creation with input_files support + STRING "INPUT_FILES=''; " + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + IF WS-INPUT-FILES NOT = SPACES + STRING FUNCTION TRIM(WS-CURL-CMD) + "IFS=',' read -ra FILES <<< '" + FUNCTION TRIM(WS-INPUT-FILES) + "'; " + "for f in \"${FILES[@]}\"; do " + "b64=$(base64 -w0 \"$f\" 2>/dev/null || base64 \"$f\"); " + "name=$(basename \"$f\"); " + "if [ -n \"$INPUT_FILES\" ]; then INPUT_FILES=\"$INPUT_FILES,\"; fi; " + "INPUT_FILES=\"$INPUT_FILES{\\\"filename\\\":\\\"$name\\\",\\\"content\\\":\\\"$b64\\\"}\"; " + "done; " + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING + END-IF. + + STRING FUNCTION TRIM(WS-CURL-CMD) + "if [ -n \"$INPUT_FILES\" ]; then " + "JSON='{\"shell\":\"bash\",\"input_files\":['\"$INPUT_FILES\"']}'; " + "else JSON='{\"shell\":\"bash\"}'; fi; " + "curl -s -X POST https://api.unsandbox.com/sessions " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) + "' -d \"$JSON\" && " + "echo -e '\x1b[33mSession created (WebSocket required)\x1b[0m'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + SERVICE-LIST. STRING "curl -s -X GET https://api.unsandbox.com/services " "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) @@ -487,6 +546,18 @@ ACCEPT WS-SERVICE-TYPE FROM ARGUMENT-VALUE ELSE IF WS-ARG3 = "--bootstrap" ACCEPT WS-BOOTSTRAP FROM ARGUMENT-VALUE + ELSE IF WS-ARG3 = "--bootstrap-file" + ACCEPT WS-BOOTSTRAP-FILE FROM ARGUMENT-VALUE + ELSE IF WS-ARG3 = "-f" + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE + IF WS-INPUT-FILES NOT = SPACES + STRING FUNCTION TRIM(WS-INPUT-FILES) "," + FUNCTION TRIM(WS-ARG3) + DELIMITED BY SIZE INTO WS-INPUT-FILES + END-STRING + ELSE + MOVE WS-ARG3 TO WS-INPUT-FILES + END-IF END-IF ACCEPT WS-ARG3 FROM ARGUMENT-VALUE END-PERFORM. @@ -542,6 +613,43 @@ END-STRING END-IF. + * Add bootstrap_content from file if provided + IF WS-BOOTSTRAP-FILE NOT = SPACES + STRING FUNCTION TRIM(WS-CURL-CMD) + "' | jq --rawfile b '" + FUNCTION TRIM(WS-BOOTSTRAP-FILE) + "' '. + {bootstrap_content: $b}' | tr -d '\\n' | curl " + "-s -X POST https://api.unsandbox.com/services " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) + "' -d @- | jq -r '.id + "" created""'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING + CALL "SYSTEM" USING WS-CURL-CMD + EXIT PARAGRAPH + END-IF. + + * Add input_files if provided (use shell script for base64) + IF WS-INPUT-FILES NOT = SPACES + STRING "INPUT_FILES=''; " + "IFS=',' read -ra FILES <<< '" + FUNCTION TRIM(WS-INPUT-FILES) + "'; " + "for f in \"${FILES[@]}\"; do " + "b64=$(base64 -w0 \"$f\" 2>/dev/null || base64 \"$f\"); " + "name=$(basename \"$f\"); " + "if [ -n \"$INPUT_FILES\" ]; then INPUT_FILES=\"$INPUT_FILES,\"; fi; " + "INPUT_FILES=\"$INPUT_FILES{\\\"filename\\\":\\\"$name\\\",\\\"content\\\":\\\"$b64\\\"}\"; " + "done; " + FUNCTION TRIM(WS-CURL-CMD) + ",\"input_files\":['\"$INPUT_FILES\"']}' | " + "jq -r '.id + "" created""'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING + CALL "SYSTEM" USING WS-CURL-CMD + EXIT PARAGRAPH + END-IF. + * Close JSON and add output formatting STRING FUNCTION TRIM(WS-CURL-CMD) "}' | jq -r '.id + "" created""'" diff --git a/un.cpp b/un.cpp index 3394ccc..0b8a5cf 100644 --- a/un.cpp +++ b/un.cpp @@ -100,6 +100,25 @@ string escape_json(const string& s) { return o.str(); } +// Base64 encoding +static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +string base64_encode(const string& input) { + string output; + int val = 0, valb = -6; + for (unsigned char c : input) { + val = (val << 8) + c; + valb += 8; + while (valb >= 0) { + output.push_back(b64_table[(val >> valb) & 0x3F]); + valb -= 6; + } + } + if (valb > -6) output.push_back(b64_table[((val << 8) >> (valb + 8)) & 0x3F]); + while (output.size() % 4) output.push_back('='); + return output; +} + string exec_curl(const string& cmd) { FILE* pipe = popen(cmd.c_str(), "r"); if (!pipe) return ""; @@ -233,7 +252,7 @@ void cmd_execute(const string& source_file, const vector& envs, const ve exit(exit_code); } -void cmd_session(bool list, const string& kill, const string& shell, const string& network, int vcpu, bool tmux, bool screen, const string& public_key, const string& secret_key) { +void cmd_session(bool list, const string& kill, const string& shell, const string& network, int vcpu, bool tmux, bool screen, const vector& files, const string& public_key, const string& secret_key) { if (list) { string auth_headers = build_auth_headers("GET", "/sessions", "", public_key, secret_key); string cmd = "curl -s -X GET '" + API_BASE + "/sessions' " + auth_headers; @@ -255,6 +274,26 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin if (vcpu > 0) json << ",\"vcpu\":" << vcpu; if (tmux) json << ",\"persistence\":\"tmux\""; if (screen) json << ",\"persistence\":\"screen\""; + + // Input files + if (!files.empty()) { + json << ",\"input_files\":["; + for (size_t i = 0; i < files.size(); i++) { + if (i > 0) json << ","; + ifstream file(files[i], ios::binary); + if (!file) { + cerr << RED << "Error: Input file not found: " << files[i] << RESET << endl; + exit(1); + } + ostringstream content; + content << file.rdbuf(); + string b64 = base64_encode(content.str()); + string filename = files[i].substr(files[i].find_last_of("/\\") + 1); + json << "{\"filename\":\"" << filename << "\",\"content_base64\":\"" << b64 << "\"}"; + } + json << "]"; + } + json << "}"; cout << YELLOW << "Creating session..." << RESET << endl; @@ -266,7 +305,7 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin cout << exec_curl(cmd) << endl; } -void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const string& public_key, const string& secret_key) { +void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const string& public_key, const string& secret_key) { if (list) { string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key); string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers; @@ -415,14 +454,36 @@ void cmd_service(const string& name, const string& ports, const string& type, co if (!ports.empty()) json << ",\"ports\":[" << ports << "]"; if (!type.empty()) json << ",\"service_type\":\"" << type << "\""; if (!bootstrap.empty()) { + json << ",\"bootstrap\":\"" << escape_json(bootstrap) << "\""; + } + if (!bootstrap_file.empty()) { struct stat st; - if (stat(bootstrap.c_str(), &st) == 0) { - string boot_code = read_file(bootstrap); - json << ",\"bootstrap\":\"" << escape_json(boot_code) << "\""; + if (stat(bootstrap_file.c_str(), &st) == 0) { + string boot_code = read_file(bootstrap_file); + json << ",\"bootstrap_content\":\"" << escape_json(boot_code) << "\""; } else { - json << ",\"bootstrap\":\"" << escape_json(bootstrap) << "\""; + cerr << RED << "Error: Bootstrap file not found: " << bootstrap_file << RESET << endl; + exit(1); } } + // Input files + if (!files.empty()) { + json << ",\"input_files\":["; + for (size_t i = 0; i < files.size(); i++) { + if (i > 0) json << ","; + ifstream file(files[i], ios::binary); + if (!file) { + cerr << RED << "Error: Input file not found: " << files[i] << RESET << endl; + exit(1); + } + ostringstream content; + content << file.rdbuf(); + string b64 = base64_encode(content.str()); + string filename = files[i].substr(files[i].find_last_of("/\\") + 1); + json << "{\"filename\":\"" << filename << "\",\"content_base64\":\"" << b64 << "\"}"; + } + json << "]"; + } if (!network.empty()) json << ",\"network\":\"" << network << "\""; if (vcpu > 0) json << ",\"vcpu\":" << vcpu; json << "}"; @@ -547,12 +608,14 @@ int main(int argc, char* argv[]) { string kill, shell, network; int vcpu = 0; bool tmux = false, screen = false; + vector files; for (int i = 2; i < argc; i++) { string arg = argv[i]; if (arg == "--list") list = true; else if (arg == "--kill" && i+1 < argc) kill = argv[++i]; else if (arg == "--shell" && i+1 < argc) shell = argv[++i]; + else if (arg == "-f" && i+1 < argc) files.push_back(argv[++i]); else if (arg == "-n" && i+1 < argc) network = argv[++i]; else if (arg == "-v" && i+1 < argc) vcpu = stoi(argv[++i]); else if (arg == "--tmux") tmux = true; @@ -560,15 +623,16 @@ int main(int argc, char* argv[]) { else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; } - cmd_session(list, kill, shell, network, vcpu, tmux, screen, public_key, secret_key); + cmd_session(list, kill, shell, network, vcpu, tmux, screen, files, public_key, secret_key); return 0; } if (cmd_type == "service") { - string name, ports, type, bootstrap; + string name, ports, type, bootstrap, bootstrap_file; bool list = false; string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network; int vcpu = 0; + vector files; for (int i = 2; i < argc; i++) { string arg = argv[i]; @@ -576,6 +640,8 @@ int main(int argc, char* argv[]) { else if (arg == "--ports" && i+1 < argc) ports = argv[++i]; else if (arg == "--type" && i+1 < argc) type = argv[++i]; else if (arg == "--bootstrap" && i+1 < argc) bootstrap = argv[++i]; + else if (arg == "--bootstrap-file" && i+1 < argc) bootstrap_file = argv[++i]; + else if (arg == "-f" && i+1 < argc) files.push_back(argv[++i]); else if (arg == "--list") list = true; else if (arg == "--info" && i+1 < argc) info = argv[++i]; else if (arg == "--logs" && i+1 < argc) logs = argv[++i]; @@ -592,7 +658,7 @@ int main(int argc, char* argv[]) { else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; } - cmd_service(name, ports, type, bootstrap, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, public_key, secret_key); + cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, public_key, secret_key); return 0; } diff --git a/un.cr b/un.cr index 6cb19b0..a49a75b 100644 --- a/un.cr +++ b/un.cr @@ -262,8 +262,36 @@ def cmd_session(args) return end - STDERR.puts "#{RED}Error: Use --list or --kill#{RESET}" - exit 1 + # Create new session + payload = JSON.parse({shell: "bash"}.to_json) + + if network = args[:network]?.as?(String) + payload.as_h["network"] = JSON::Any.new(network) + end + + # Add input files + if files = args[:files]?.as?(Array(String)) + input_files = [] of JSON::Any + files.each do |filepath| + unless File.exists?(filepath) + STDERR.puts "#{RED}Error: Input file not found: #{filepath}#{RESET}" + exit 1 + end + content = Base64.strict_encode(File.read(filepath)) + input_files << JSON.parse({ + filename: File.basename(filepath), + content_base64: content + }.to_json) + end + unless input_files.empty? + payload.as_h["input_files"] = JSON.parse(input_files.to_json) + end + end + + puts "#{YELLOW}Creating session...#{RESET}" + result = api_request("/sessions", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Session created: #{result["id"]?.try(&.as_s?) || "N/A"}#{RESET}" + puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}" end def cmd_key(args) @@ -469,11 +497,16 @@ def cmd_service(args) # Add bootstrap if bootstrap = args[:bootstrap]?.as?(String) - # Check if bootstrap is a file - if File.exists?(bootstrap) - payload.as_h["bootstrap"] = JSON::Any.new(File.read(bootstrap)) + payload.as_h["bootstrap"] = JSON::Any.new(bootstrap) + end + + # Add bootstrap_file + if bootstrap_file = args[:bootstrap_file]?.as?(String) + if File.exists?(bootstrap_file) + payload.as_h["bootstrap_content"] = JSON::Any.new(File.read(bootstrap_file)) else - payload.as_h["bootstrap"] = JSON::Any.new(bootstrap) + STDERR.puts "#{RED}Error: Bootstrap file not found: #{bootstrap_file}#{RESET}" + exit 1 end end @@ -482,6 +515,25 @@ def cmd_service(args) payload.as_h["network"] = JSON::Any.new(network) end + # Add input files + if files = args[:files]?.as?(Array(String)) + input_files = [] of JSON::Any + files.each do |filepath| + unless File.exists?(filepath) + STDERR.puts "#{RED}Error: Input file not found: #{filepath}#{RESET}" + exit 1 + end + content = Base64.strict_encode(File.read(filepath)) + input_files << JSON.parse({ + filename: File.basename(filepath), + content_base64: content + }.to_json) + end + unless input_files.empty? + payload.as_h["input_files"] = JSON.parse(input_files.to_json) + end + end + # Create service result = api_request("/services", public_key, secret_key, method: "POST", data: payload) puts "#{GREEN}Service created: #{result["id"]?.try(&.as_s?) || "N/A"}#{RESET}" @@ -521,6 +573,7 @@ def main domains: nil, service_type: nil, bootstrap: nil, + bootstrap_file: nil, extend: false } of Symbol => (String | Array(String) | Bool | Nil) @@ -548,7 +601,8 @@ def main opts.on("--ports=PORTS", "Comma-separated ports") { |p| args[:ports] = p } 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("--bootstrap=CMD", "Bootstrap command or URI") { |b| args[:bootstrap] = b } + opts.on("--bootstrap-file=FILE", "Upload local file as bootstrap script") { |f| args[:bootstrap_file] = f } opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true } opts.unknown_args do |before, after| diff --git a/un.d b/un.d index 9b67077..da20c99 100644 --- a/un.d +++ b/un.d @@ -88,6 +88,31 @@ string escapeJson(string s) { return result; } +string readAndBase64(string filepath) { + import std.base64 : Base64; + try { + auto content = readText(filepath); + return Base64.encode(cast(ubyte[])content); + } catch (Exception e) { + stderr.writefln("%sError: Cannot read file: %s%s", RED, filepath, RESET); + return ""; + } +} + +string buildInputFilesJson(string[] files) { + if (files.length == 0) return ""; + string[] fileJsons; + foreach (f; files) { + string b64 = readAndBase64(f); + if (b64.empty) continue; + string basename = baseName(f); + fileJsons ~= format(`{"filename":"%s","content":"%s"}`, escapeJson(basename), b64); + } + if (fileJsons.length == 0) return ""; + import std.array : join; + return format(`,"input_files":[%s]`, fileJsons.join(",")); +} + string computeHmac(string secretKey, string message) { import std.process : pipeShell, Redirect, wait; import std.stdio : File; @@ -173,7 +198,7 @@ void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network writeln(result); } -void cmdSession(bool list, string kill, string shell, string network, int vcpu, bool tmux, bool screen, string publicKey, string secretKey) { +void cmdSession(bool list, string kill, string shell, string network, int vcpu, bool tmux, bool screen, string[] inputFiles, string publicKey, string secretKey) { if (list) { string authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey); string cmd = format(`curl -s -X GET '%s/sessions' %s`, API_BASE, authHeaders); @@ -195,6 +220,7 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu, if (vcpu > 0) json ~= format(`,"vcpu":%d`, vcpu); if (tmux) json ~= `,"persistence":"tmux"`; if (screen) json ~= `,"persistence":"screen"`; + json ~= buildInputFilesJson(inputFiles); json ~= "}"; writefln("%sCreating session...%s", YELLOW, RESET); @@ -203,7 +229,7 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu, writeln(execCurl(cmd)); } -void cmdService(string name, string ports, string bootstrap, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string publicKey, string secretKey) { +void cmdService(string name, string ports, string bootstrap, string bootstrapFile, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string[] inputFiles, string publicKey, string secretKey) { if (list) { string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey); string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders); @@ -340,15 +366,20 @@ void cmdService(string name, string ports, string bootstrap, string type, bool l if (!ports.empty) json ~= format(`,"ports":[%s]`, ports); if (!type.empty) json ~= format(`,"service_type":"%s"`, type); if (!bootstrap.empty) { - if (exists(bootstrap)) { - string bootCode = readText(bootstrap); - json ~= format(`,"bootstrap":"%s"`, escapeJson(bootCode)); + json ~= format(`,"bootstrap":"%s"`, escapeJson(bootstrap)); + } + if (!bootstrapFile.empty) { + if (exists(bootstrapFile)) { + string bootCode = readText(bootstrapFile); + json ~= format(`,"bootstrap_content":"%s"`, escapeJson(bootCode)); } else { - json ~= format(`,"bootstrap":"%s"`, escapeJson(bootstrap)); + stderr.writefln("%sError: Bootstrap file not found: %s%s", RED, bootstrapFile, RESET); + exit(1); } } if (!network.empty) json ~= format(`,"network":"%s"`, network); if (vcpu > 0) json ~= format(`,"vcpu":%d`, vcpu); + json ~= buildInputFilesJson(inputFiles); json ~= "}"; writefln("%sCreating service...%s", YELLOW, RESET); @@ -505,6 +536,7 @@ int main(string[] args) { string kill, shell, network; int vcpu = 0; bool tmux = false, screen = false; + string[] inputFiles; for (size_t i = 2; i < args.length; i++) { if (args[i] == "--list") list = true; @@ -514,23 +546,26 @@ int main(string[] args) { else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]); else if (args[i] == "--tmux") tmux = true; else if (args[i] == "--screen") screen = true; + else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i]; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - cmdSession(list, kill, shell, network, vcpu, tmux, screen, publicKey, secretKey); + cmdSession(list, kill, shell, network, vcpu, tmux, screen, inputFiles, publicKey, secretKey); return 0; } if (args[1] == "service") { - string name, ports, bootstrap, type; + string name, ports, bootstrap, bootstrapFile, type; bool list = false; string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network; int vcpu = 0; + string[] inputFiles; for (size_t i = 2; i < args.length; i++) { if (args[i] == "--name" && i+1 < args.length) name = args[++i]; else if (args[i] == "--ports" && i+1 < args.length) ports = args[++i]; else if (args[i] == "--bootstrap" && i+1 < args.length) bootstrap = args[++i]; + else if (args[i] == "--bootstrap-file" && i+1 < args.length) bootstrapFile = args[++i]; else if (args[i] == "--type" && i+1 < args.length) type = args[++i]; else if (args[i] == "--list") list = true; else if (args[i] == "--info" && i+1 < args.length) info = args[++i]; @@ -545,10 +580,11 @@ int main(string[] args) { else if (args[i] == "--dump-file" && i+1 < args.length) dumpFile = args[++i]; else if (args[i] == "-n" && i+1 < args.length) network = args[++i]; else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]); + else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i]; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - cmdService(name, ports, bootstrap, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, publicKey, secretKey); + cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, publicKey, secretKey); return 0; } diff --git a/un.dart b/un.dart index 2b03262..e619cce 100644 --- a/un.dart +++ b/un.dart @@ -86,6 +86,7 @@ class Args { String? servicePorts; String? serviceType; String? serviceBootstrap; + String? serviceBootstrapFile; String? serviceInfo; String? serviceLogs; String? serviceTail; @@ -305,6 +306,24 @@ Future cmdSession(Args args) async { payload['vcpu'] = args.vcpu; } + // Add input files + if (args.files.isNotEmpty) { + final inputFiles = >[]; + for (final filepath in args.files) { + final file = File(filepath); + if (!await file.exists()) { + stderr.writeln('${red}Error: Input file not found: $filepath$reset'); + exit(1); + } + final content = await file.readAsBytes(); + inputFiles.add({ + 'filename': filepath.split('/').last, + 'content_base64': base64Encode(content), + }); + } + payload['input_files'] = inputFiles; + } + print('${yellow}Creating session...$reset'); final result = await apiRequestCurl('/sessions', 'POST', jsonEncode(payload), publicKey, secretKey); print('${green}Session created: ${result['id'] ?? 'N/A'}$reset'); @@ -426,6 +445,15 @@ Future cmdService(Args args) async { if (args.serviceBootstrap != null) { payload['bootstrap'] = args.serviceBootstrap; } + if (args.serviceBootstrapFile != null) { + final file = File(args.serviceBootstrapFile!); + if (await file.exists()) { + payload['bootstrap_content'] = await file.readAsString(); + } else { + stderr.writeln('${red}Error: Bootstrap file not found: ${args.serviceBootstrapFile}$reset'); + exit(1); + } + } if (args.network != null) { payload['network'] = args.network; } @@ -433,6 +461,24 @@ Future cmdService(Args args) async { payload['vcpu'] = args.vcpu; } + // Add input files + if (args.files.isNotEmpty) { + final inputFiles = >[]; + for (final filepath in args.files) { + final file = File(filepath); + if (!await file.exists()) { + stderr.writeln('${red}Error: Input file not found: $filepath$reset'); + exit(1); + } + final content = await file.readAsBytes(); + inputFiles.add({ + 'filename': filepath.split('/').last, + 'content_base64': base64Encode(content), + }); + } + payload['input_files'] = inputFiles; + } + final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), publicKey, secretKey); print('${green}Service created: ${result['id'] ?? 'N/A'}$reset'); print('Name: ${result['name'] ?? 'N/A'}'); @@ -573,6 +619,9 @@ Args parseArgs(List argv) { case '--bootstrap': args.serviceBootstrap = argv[++i]; break; + case '--bootstrap-file': + args.serviceBootstrapFile = argv[++i]; + break; case '--info': args.serviceInfo = argv[++i]; break; diff --git a/un.erl b/un.erl index f8b7c5d..eadd44b 100755 --- a/un.erl +++ b/un.erl @@ -105,7 +105,9 @@ session_command(["--kill", SessionId | _]) -> session_command(Args) -> ApiKey = get_api_key(), Shell = get_shell_opt(Args, "bash"), - Json = "{\"shell\":\"" ++ Shell ++ "\"}", + InputFiles = get_input_files(Args), + InputFilesJson = build_input_files_json(InputFiles), + Json = "{\"shell\":\"" ++ Shell ++ "\"" ++ InputFilesJson ++ "}", TmpFile = write_temp_file(Json), Response = curl_post(ApiKey, "/sessions", TmpFile), file:delete(TmpFile), @@ -199,7 +201,9 @@ service_command(Args) -> ApiKey = get_api_key(), Ports = get_service_ports(Args), Bootstrap = get_service_bootstrap(Args), + BootstrapFile = get_service_bootstrap_file(Args), Type = get_service_type(Args), + InputFiles = get_input_files(Args), PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" @@ -208,11 +212,24 @@ service_command(Args) -> undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, + BootstrapContentJson = case BootstrapFile of + undefined -> ""; + BF -> + case file:read_file(BF) of + {ok, ContentBin} -> + Content = binary_to_list(ContentBin), + ",\"bootstrap_content\":\"" ++ escape_json(Content) ++ "\""; + {error, _} -> + io:format(standard_error, "\033[31mError: Bootstrap file not found: ~s\033[0m~n", [BF]), + halt(1) + end + end, TypeJson = case Type of undefined -> ""; T -> ",\"service_type\":\"" ++ T ++ "\"" end, - Json = "{\"name\":\"" ++ Name ++ "\"" ++ PortsJson ++ BootstrapJson ++ TypeJson ++ "}", + InputFilesJson = build_input_files_json(InputFiles), + Json = "{\"name\":\"" ++ Name ++ "\"" ++ PortsJson ++ BootstrapJson ++ BootstrapContentJson ++ TypeJson ++ InputFilesJson ++ "}", TmpFile = write_temp_file(Json), Response = curl_post(ApiKey, "/services", TmpFile), file:delete(TmpFile), @@ -409,6 +426,23 @@ escape_json([C | Rest], Acc) -> build_json(Language, Code) -> "{\"language\":\"" ++ Language ++ "\",\"code\":\"" ++ escape_json(Code) ++ "\"}". +read_and_base64(Filepath) -> + case file:read_file(Filepath) of + {ok, Content} -> + base64:encode_to_string(Content); + {error, _} -> + "" + end. + +build_input_files_json([]) -> ""; +build_input_files_json(Files) -> + FileJsons = lists:map(fun(F) -> + B64 = read_and_base64(F), + Basename = filename:basename(F), + "{\"filename\":\"" ++ escape_json(Basename) ++ "\",\"content\":\"" ++ B64 ++ "\"}" + end, Files), + ",\"input_files\":[" ++ string:join(FileJsons, ",") ++ "]". + write_temp_file(Data) -> TmpFile = "/tmp/un_erl_" ++ integer_to_list(rand:uniform(999999)) ++ ".json", file:write_file(TmpFile, Data), @@ -484,10 +518,20 @@ get_service_bootstrap([]) -> undefined; get_service_bootstrap(["--bootstrap", Bootstrap | _]) -> Bootstrap; get_service_bootstrap([_ | Rest]) -> get_service_bootstrap(Rest). +get_service_bootstrap_file([]) -> undefined; +get_service_bootstrap_file(["--bootstrap-file", BootstrapFile | _]) -> BootstrapFile; +get_service_bootstrap_file([_ | Rest]) -> get_service_bootstrap_file(Rest). + get_service_type([]) -> undefined; get_service_type(["--type", Type | _]) -> Type; get_service_type([_ | Rest]) -> get_service_type(Rest). +get_input_files(Args) -> get_input_files(Args, []). + +get_input_files([], Acc) -> lists:reverse(Acc); +get_input_files(["-f", File | Rest], Acc) -> get_input_files(Rest, [File | Acc]); +get_input_files([_ | Rest], Acc) -> get_input_files(Rest, Acc). + has_extend_flag([]) -> false; has_extend_flag(["--extend" | _]) -> true; has_extend_flag([_ | Rest]) -> has_extend_flag(Rest). diff --git a/un.ex b/un.ex index 747c2ca..b932fc6 100755 --- a/un.ex +++ b/un.ex @@ -137,11 +137,13 @@ defmodule Un do shell = get_opt(args, "--shell", "-s", "bash") network = get_opt(args, "-n", nil, nil) vcpu = get_opt(args, "-v", nil, nil) + input_files = get_all_opts(args, "-f") network_json = if network, do: ",\"network\":\"#{network}\"", else: "" vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" + input_files_json = build_input_files_json(input_files) - json = "{\"shell\":\"#{shell}\"#{network_json}#{vcpu_json}}" + json = "{\"shell\":\"#{shell}\"#{network_json}#{vcpu_json}#{input_files_json}}" response = curl_post(api_key, "/sessions", json) IO.puts("#{@yellow}Session created (WebSocket required)#{@reset}") IO.puts(response) @@ -238,17 +240,30 @@ defmodule Un do api_key = get_api_key() ports = get_opt(args, "--ports", nil, nil) bootstrap = get_opt(args, "--bootstrap", nil, nil) + bootstrap_file = get_opt(args, "--bootstrap-file", nil, nil) network = get_opt(args, "-n", nil, nil) vcpu = get_opt(args, "-v", nil, nil) service_type = get_opt(args, "--type", nil, nil) + input_files = get_all_opts(args, "-f") ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" + bootstrap_content_json = if bootstrap_file do + case File.read(bootstrap_file) do + {:ok, content} -> ",\"bootstrap_content\":\"#{escape_json(content)}\"" + {:error, _} -> + IO.puts(:stderr, "#{@red}Error: Bootstrap file not found: #{bootstrap_file}#{@reset}") + System.halt(1) + end + else + "" + end network_json = if network, do: ",\"network\":\"#{network}\"", else: "" vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" type_json = if service_type, do: ",\"service_type\":\"#{service_type}\"", else: "" + input_files_json = build_input_files_json(input_files) - json = "{\"name\":\"#{name}\"#{ports_json}#{bootstrap_json}#{network_json}#{vcpu_json}#{type_json}}" + json = "{\"name\":\"#{name}\"#{ports_json}#{bootstrap_json}#{bootstrap_content_json}#{network_json}#{vcpu_json}#{type_json}#{input_files_json}}" response = curl_post(api_key, "/services", json) IO.puts("#{@green}Service created#{@reset}") IO.puts(response) @@ -443,6 +458,25 @@ defmodule Un do |> String.replace("\t", "\\t") end + defp read_and_base64(filepath) do + case File.read(filepath) do + {:ok, content} -> Base.encode64(content) + {:error, _} -> "" + end + end + + defp build_input_files_json([]), do: "" + defp build_input_files_json(files) do + file_jsons = files + |> Enum.map(fn f -> + b64 = read_and_base64(f) + basename = Path.basename(f) + "{\"filename\":\"#{escape_json(basename)}\",\"content\":\"#{b64}\"}" + end) + |> Enum.join(",") + ",\"input_files\":[#{file_jsons}]" + end + defp build_execute_json(language, code, _opts) do "{\"language\":\"#{language}\",\"code\":\"#{escape_json(code)}\"}" end @@ -559,6 +593,18 @@ defmodule Un do get_opt(rest, long, short, default) end + defp get_all_opts(args, flag), do: get_all_opts(args, flag, []) + + defp get_all_opts([], _flag, acc), do: Enum.reverse(acc) + + defp get_all_opts([arg, value | rest], flag, acc) when arg == flag do + get_all_opts(rest, flag, [value | acc]) + end + + defp get_all_opts([_arg | rest], flag, acc) do + get_all_opts(rest, flag, acc) + end + defp check_clock_drift(response) do response_lower = String.downcase(response) diff --git a/un.f90 b/un.f90 index da8e43e..c48487b 100644 --- a/un.f90 +++ b/un.f90 @@ -192,15 +192,16 @@ contains end subroutine handle_execute subroutine handle_session() - character(len=4096) :: full_cmd + character(len=8192) :: full_cmd character(len=256) :: arg, session_id - character(len=1024) :: public_key, secret_key + character(len=1024) :: public_key, secret_key, input_files integer :: i, stat logical :: list_mode, kill_mode list_mode = .false. kill_mode = .false. session_id = '' + input_files = '' ! Parse session arguments do i = 2, command_argument_count() @@ -212,6 +213,15 @@ contains if (i+1 <= command_argument_count()) then call get_command_argument(i+1, session_id) end if + else if (trim(arg) == '-f') then + if (i+1 <= command_argument_count()) then + call get_command_argument(i+1, arg) + if (len_trim(input_files) > 0) then + input_files = trim(input_files) // ',' // trim(arg) + else + input_files = trim(arg) + end if + end if end if end do @@ -258,14 +268,48 @@ contains 'echo -e "\x1b[32mSession terminated: ', trim(session_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else - write(0, '(A)') 'Error: Use --list or --kill ID' - stop 1 + ! Create session with optional input_files + if (len_trim(input_files) > 0) then + write(full_cmd, '(30A)') & + 'INPUT_FILES=""; ', & + 'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', & + 'for f in "${FILES[@]}"; do ', & + 'b64=$(base64 -w0 "$f" 2>/dev/null || base64 "$f"); ', & + 'name=$(basename "$f"); ', & + 'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', & + 'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', & + 'done; ', & + 'BODY=''{"shell":"bash","input_files":[''"$INPUT_FILES"'']}''; ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/sessions:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/sessions ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-d "$BODY" && ', & + 'echo -e "\x1b[33mSession created (WebSocket required)\x1b[0m"' + else + write(full_cmd, '(20A)') & + 'BODY=''{"shell":"bash"}''; ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/sessions:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/sessions ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-d "$BODY" && ', & + 'echo -e "\x1b[33mSession created (WebSocket required)\x1b[0m"' + end if + call execute_command_line(trim(full_cmd), wait=.true.) end if end subroutine handle_session subroutine handle_service() - character(len=2048) :: full_cmd - character(len=256) :: arg, service_id, operation, service_type + character(len=8192) :: full_cmd + character(len=256) :: arg, service_id, operation, service_type, service_name + character(len=1024) :: input_files integer :: i, stat logical :: list_mode @@ -273,16 +317,32 @@ contains operation = '' service_id = '' service_type = '' + service_name = '' + input_files = '' ! Parse service arguments do i = 2, command_argument_count() call get_command_argument(i, arg) if (trim(arg) == '-l' .or. trim(arg) == '--list') then list_mode = .true. + else if (trim(arg) == '--name') then + operation = 'create' + if (i+1 <= command_argument_count()) then + call get_command_argument(i+1, service_name) + end if else if (trim(arg) == '--type') then if (i+1 <= command_argument_count()) then call get_command_argument(i+1, service_type) end if + else if (trim(arg) == '-f') then + if (i+1 <= command_argument_count()) then + call get_command_argument(i+1, arg) + if (len_trim(input_files) > 0) then + input_files = trim(input_files) // ',' // trim(arg) + else + input_files = trim(arg) + end if + end if else if (trim(arg) == '--info') then operation = 'info' if (i+1 <= command_argument_count()) then @@ -385,8 +445,36 @@ contains 'else echo "$STDOUT"; fi; ', & 'else echo -e "\x1b[31mError: Failed to fetch bootstrap\x1b[0m" >&2; exit 1; fi' call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'create' .and. len_trim(service_name) > 0) then + ! Create service with optional input_files + if (len_trim(input_files) > 0) then + write(full_cmd, '(30A)') & + 'INPUT_FILES=""; ', & + 'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', & + 'for f in "${FILES[@]}"; do ', & + 'b64=$(base64 -w0 "$f" 2>/dev/null || base64 "$f"); ', & + 'name=$(basename "$f"); ', & + 'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', & + 'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', & + 'done; ', & + 'curl -s -X POST https://api.unsandbox.com/services ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(api_key), '" ', & + '-d ''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}'' | ', & + 'jq -r ''.id + " created"'' && ', & + 'echo -e "\x1b[32mService created\x1b[0m"' + else + write(full_cmd, '(15A)') & + 'curl -s -X POST https://api.unsandbox.com/services ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(api_key), '" ', & + '-d ''{"name":"', trim(service_name), '"}'' | ', & + 'jq -r ''.id + " created"'' && ', & + 'echo -e "\x1b[32mService created\x1b[0m"' + end if + call execute_command_line(trim(full_cmd), wait=.true.) else - write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, or --dump-bootstrap' + write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, or --name NAME' stop 1 end if end subroutine handle_service diff --git a/un.forth b/un.forth index 2698f3b..d1508bd 100644 --- a/un.forth +++ b/un.forth @@ -400,7 +400,7 @@ s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; -\ Service create (requires --name, optional --ports, --domains, --type, --bootstrap) +\ Service create (requires --name, optional --ports, --domains, --type, --bootstrap, -f) : service-create ( -- ) get-api-key \ Parse arguments (simplified - in real implementation would iterate through args) @@ -413,15 +413,32 @@ s" SECRET_KEY='" r@ write-file throw get-secret-key r@ write-file throw s" '" r@ write-line throw - s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''" r@ write-line throw - s" for ((i=3; i<$#; i+=2)); do" r@ write-line throw - s" case ${!i} in" r@ write-line throw - s" --name) NAME=${!((i+1))} ;;" r@ write-line throw - s" --ports) PORTS=${!((i+1))} ;;" r@ write-line throw - s" --domains) DOMAINS=${!((i+1))} ;;" r@ write-line throw - s" --type) TYPE=${!((i+1))} ;;" r@ write-line throw - s" --bootstrap) BOOTSTRAP=${!((i+1))} ;;" r@ write-line throw + s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''; BOOTSTRAP_FILE=''; INPUT_FILES=''" r@ write-line throw + s" i=3" r@ write-line throw + s" while [ $i -lt $# ]; do" r@ write-line throw + s" arg=${!i}" r@ write-line throw + s" case \"$arg\" in" r@ write-line throw + s" --name) ((i++)); NAME=${!i} ;;" r@ write-line throw + s" --ports) ((i++)); PORTS=${!i} ;;" r@ write-line throw + s" --domains) ((i++)); DOMAINS=${!i} ;;" r@ write-line throw + s" --type) ((i++)); TYPE=${!i} ;;" r@ write-line throw + s" --bootstrap) ((i++)); BOOTSTRAP=${!i} ;;" r@ write-line throw + s" --bootstrap-file) ((i++)); BOOTSTRAP_FILE=${!i} ;;" r@ write-line throw + s" -f) ((i++)); FILE=${!i}" r@ write-line throw + s" if [ -f \"$FILE\" ]; then" r@ write-line throw + s" BASENAME=$(basename \"$FILE\")" r@ write-line throw + s" CONTENT=$(base64 -w0 \"$FILE\")" r@ write-line throw + s" if [ -z \"$INPUT_FILES\" ]; then" r@ write-line throw + s" INPUT_FILES=\"{\\\"filename\\\":\\\"$BASENAME\\\",\\\"content\\\":\\\"$CONTENT\\\"}\"" r@ write-line throw + s" else" r@ write-line throw + s" INPUT_FILES=\"$INPUT_FILES,{\\\"filename\\\":\\\"$BASENAME\\\",\\\"content\\\":\\\"$CONTENT\\\"}\"" r@ write-line throw + s" fi" r@ write-line throw + s" else" r@ write-line throw + s" echo \"Error: File not found: $FILE\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi ;;" r@ write-line throw s" esac" r@ write-line throw + s" ((i++))" r@ write-line throw s" done" r@ write-line throw s" [ -z \"$NAME\" ] && echo 'Error: --name required' && exit 1" r@ write-line throw s" PAYLOAD='{\"name\":\"'\"$NAME\"'\"}'" r@ write-line throw @@ -429,6 +446,13 @@ s" [ -n \"$DOMAINS\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg d \"$DOMAINS\" '. + {domains: ($d | split(\",\"))}')" r@ write-line throw s" [ -n \"$TYPE\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg t \"$TYPE\" '. + {service_type: $t}')" r@ write-line throw s" [ -n \"$BOOTSTRAP\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg b \"$BOOTSTRAP\" '. + {bootstrap: $b}')" r@ write-line throw + s" if [ -n \"$BOOTSTRAP_FILE\" ]; then" r@ write-line throw + s" [ ! -f \"$BOOTSTRAP_FILE\" ] && echo -e '\\x1b[31mError: Bootstrap file not found: '$BOOTSTRAP_FILE'\\x1b[0m' >&2 && exit 1" r@ write-line throw + s" PAYLOAD=$(echo $PAYLOAD | jq --rawfile b \"$BOOTSTRAP_FILE\" '. + {bootstrap_content: $b}')" r@ write-line throw + s" fi" r@ write-line throw + s" if [ -n \"$INPUT_FILES\" ]; then" r@ write-line throw + s" PAYLOAD=$(echo $PAYLOAD | jq --argjson f \"[$INPUT_FILES]\" '. + {input_files: $f}')" r@ write-line throw + s" fi" r@ write-line throw s" TIMESTAMP=$(date +%s)" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:POST:/services:$PAYLOAD\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw @@ -515,6 +539,51 @@ 0 (bye) ; +\ Session create with input_files support +: session-create ( -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" SHELL='bash'" r@ write-line throw + s" INPUT_FILES=''" r@ write-line throw + s" for ((i=2; i<$#; i++)); do" r@ write-line throw + s" case ${!i} in" r@ write-line throw + s" --shell|-s) ((i++)); SHELL=${!i} ;;" r@ write-line throw + s" -f) ((i++)); FILE=${!i}" r@ write-line throw + s" if [ -f \"$FILE\" ]; then" r@ write-line throw + s" BASENAME=$(basename \"$FILE\")" r@ write-line throw + s" CONTENT=$(base64 -w0 \"$FILE\")" r@ write-line throw + s" if [ -z \"$INPUT_FILES\" ]; then" r@ write-line throw + s" INPUT_FILES=\"{\\\"filename\\\":\\\"$BASENAME\\\",\\\"content\\\":\\\"$CONTENT\\\"}\"" r@ write-line throw + s" else" r@ write-line throw + s" INPUT_FILES=\"$INPUT_FILES,{\\\"filename\\\":\\\"$BASENAME\\\",\\\"content\\\":\\\"$CONTENT\\\"}\"" r@ write-line throw + s" fi" r@ write-line throw + s" else" r@ write-line throw + s" echo \"Error: File not found: $FILE\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi ;;" r@ write-line throw + s" esac" r@ write-line throw + s" done" r@ write-line throw + s" if [ -n \"$INPUT_FILES\" ]; then" r@ write-line throw + s" BODY=\"{\\\"shell\\\":\\\"$SHELL\\\",\\\"input_files\\\":[$INPUT_FILES]}\"" r@ write-line throw + s" else" r@ write-line throw + s" BODY=\"{\\\"shell\\\":\\\"$SHELL\\\"}\"" r@ write-line throw + s" fi" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/sessions:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" echo -e '\\x1b[33mCreating session...\\x1b[0m'" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/sessions -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\"" r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh \"$@\" && rm -f /tmp/unsandbox_cmd.sh" system +; + \ Handle session subcommand : handle-session ( -- ) argc @ 3 < if @@ -542,6 +611,22 @@ 0 (bye) then + \ Check for --shell or -f flags (create session) + 2dup s" --shell" compare 0= if + 2drop session-create + 0 (bye) + then + + 2dup s" -s" compare 0= if + 2drop session-create + 0 (bye) + then + + 2dup s" -f" compare 0= if + 2drop session-create + 0 (bye) + then + 2drop s" Error: Use --list or --kill ID" type cr 1 (bye) diff --git a/un.fs b/un.fs index c188647..1f0825c 100644 --- a/un.fs +++ b/un.fs @@ -89,6 +89,7 @@ type Args = { mutable ServicePorts: string option mutable ServiceType: string option mutable ServiceBootstrap: string option + mutable ServiceBootstrapFile: string option mutable ServiceInfo: string option mutable ServiceLogs: string option mutable ServiceTail: string option @@ -358,6 +359,13 @@ let cmdSession (args: Args) = if args.Vcpu > 0 then payload <- payload @ [("vcpu", box args.Vcpu)] + if args.Files.Count > 0 then + let inputFiles = args.Files |> Seq.map (fun filepath -> + let content = File.ReadAllBytes(filepath) + [("filename", box (Path.GetFileName(filepath))); ("content_base64", box (Convert.ToBase64String(content)))] + ) |> Seq.toList + payload <- payload @ [("input_files", box inputFiles)] + printfn "%sCreating session...%s" yellow reset let result = apiRequest "/sessions" "POST" (Some payload) publicKey secretKey match result.TryFind "id" with @@ -526,6 +534,19 @@ let cmdService (args: Args) = payload <- payload @ [("service_type", box args.ServiceType.Value)] if args.ServiceBootstrap.IsSome then payload <- payload @ [("bootstrap", box args.ServiceBootstrap.Value)] + if args.ServiceBootstrapFile.IsSome then + if File.Exists(args.ServiceBootstrapFile.Value) then + let content = File.ReadAllText(args.ServiceBootstrapFile.Value) + payload <- payload @ [("bootstrap_content", box content)] + else + eprintfn "%sError: Bootstrap file not found: %s%s" red args.ServiceBootstrapFile.Value reset + exit 1 + if args.Files.Count > 0 then + let inputFiles = args.Files |> Seq.map (fun filepath -> + let content = File.ReadAllBytes(filepath) + [("filename", box (Path.GetFileName(filepath))); ("content_base64", box (Convert.ToBase64String(content)))] + ) |> Seq.toList + payload <- payload @ [("input_files", box inputFiles)] if args.Network.IsSome then payload <- payload @ [("network", box args.Network.Value)] if args.Vcpu > 0 then @@ -564,6 +585,7 @@ let parseArgs (argv: string[]) = ServicePorts = None ServiceType = None ServiceBootstrap = None + ServiceBootstrapFile = None ServiceInfo = None ServiceLogs = None ServiceTail = None @@ -601,6 +623,7 @@ let parseArgs (argv: string[]) = | "--ports" -> i <- i + 1; args.ServicePorts <- Some argv.[i] | "--type" -> i <- i + 1; args.ServiceType <- Some argv.[i] | "--bootstrap" -> i <- i + 1; args.ServiceBootstrap <- Some argv.[i] + | "--bootstrap-file" -> i <- i + 1; args.ServiceBootstrapFile <- Some argv.[i] | "--info" -> i <- i + 1; args.ServiceInfo <- Some argv.[i] | "--logs" -> i <- i + 1; args.ServiceLogs <- Some argv.[i] | "--tail" -> i <- i + 1; args.ServiceTail <- Some argv.[i] diff --git a/un.go b/un.go index 9822596..037fb56 100644 --- a/un.go +++ b/un.go @@ -330,7 +330,7 @@ func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts boo os.Exit(exitCode) } -func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int, tmux, screen bool, publicKey, secretKey string) { +func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int, tmux, screen bool, files inputFiles, publicKey, secretKey string) { if sessionList != "" { result := apiRequest("/sessions", "GET", nil, publicKey, secretKey) sessions := result["sessions"].([]interface{}) @@ -373,12 +373,29 @@ func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int payload["persistence"] = "screen" } + // Input files + if len(files) > 0 { + var inputFilesList []map[string]string + for _, f := range files { + content, err := os.ReadFile(f) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError reading input file %s: %v%s\n", Red, f, err, Reset) + os.Exit(1) + } + inputFilesList = append(inputFilesList, map[string]string{ + "filename": filepath.Base(f), + "content_base64": base64.StdEncoding.EncodeToString(content), + }) + } + payload["input_files"] = inputFilesList + } + fmt.Printf("%sCreating session...%s\n", Yellow, Reset) result := apiRequest("/sessions", "POST", payload, publicKey, secretKey) fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset) } -func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, network string, vcpu int, publicKey, secretKey string) { +func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, network string, vcpu int, files inputFiles, publicKey, secretKey string) { if serviceList != "" { result := apiRequest("/services", "GET", nil, publicKey, secretKey) services := result["services"].([]interface{}) @@ -503,13 +520,31 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB payload["service_type"] = serviceType } if serviceBootstrap != "" { - // Check if it's a file - if _, err := os.Stat(serviceBootstrap); err == nil { - content, _ := os.ReadFile(serviceBootstrap) - payload["bootstrap"] = string(content) - } else { - payload["bootstrap"] = serviceBootstrap + payload["bootstrap"] = serviceBootstrap + } + if serviceBootstrapFile != "" { + content, err := os.ReadFile(serviceBootstrapFile) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError: Bootstrap file not found: %s%s\n", Red, serviceBootstrapFile, Reset) + os.Exit(1) } + payload["bootstrap_content"] = string(content) + } + // Input files + if len(files) > 0 { + var inputFilesList []map[string]string + for _, f := range files { + content, err := os.ReadFile(f) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError reading input file %s: %v%s\n", Red, f, err, Reset) + os.Exit(1) + } + inputFilesList = append(inputFilesList, map[string]string{ + "filename": filepath.Base(f), + "content_base64": base64.StdEncoding.EncodeToString(content), + }) + } + payload["input_files"] = inputFilesList } if network != "" { payload["network"] = network @@ -706,6 +741,8 @@ func main() { sessionShell := sessionCmd.String("shell", "", "Shell/REPL to use") sessionTmux := sessionCmd.Bool("tmux", false, "Enable tmux persistence") sessionScreen := sessionCmd.Bool("screen", false, "Enable screen persistence") + var sessionFiles inputFiles + sessionCmd.Var(&sessionFiles, "f", "Input file") sessionNetwork := sessionCmd.String("n", "", "Network mode") sessionVcpu := sessionCmd.Int("v", 0, "vCPU count") sessionKey := sessionCmd.String("k", "", "API key") @@ -716,7 +753,10 @@ func main() { servicePorts := serviceCmd.String("ports", "", "Ports (comma-separated)") serviceDomains := serviceCmd.String("domains", "", "Custom domains (comma-separated)") serviceType := serviceCmd.String("type", "", "Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)") - serviceBootstrap := serviceCmd.String("bootstrap", "", "Bootstrap command/file") + serviceBootstrap := serviceCmd.String("bootstrap", "", "Bootstrap command or URI") + serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script") + var serviceFiles inputFiles + serviceCmd.Var(&serviceFiles, "f", "Input file") serviceList := serviceCmd.String("list", "", "List services") serviceInfo := serviceCmd.String("info", "", "Get service info") serviceLogs := serviceCmd.String("logs", "", "Get service logs") @@ -753,7 +793,7 @@ func main() { if vc == 0 { vc = *vcpu } - cmdSession(*sessionList, *sessionKill, *sessionShell, net, vc, *sessionTmux, *sessionScreen, publicKey, secretKey) + cmdSession(*sessionList, *sessionKill, *sessionShell, net, vc, *sessionTmux, *sessionScreen, sessionFiles, publicKey, secretKey) return case "service": @@ -767,7 +807,7 @@ func main() { if vc == 0 { vc = *vcpu } - cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, net, vc, publicKey, secretKey) + cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, net, vc, serviceFiles, publicKey, secretKey) return case "key": diff --git a/un.groovy b/un.groovy index f4dc222..1487c47 100644 --- a/un.groovy +++ b/un.groovy @@ -79,6 +79,7 @@ class Args { String servicePorts = null String serviceType = null String serviceBootstrap = null + String serviceBootstrapFile = null String serviceInfo = null String serviceLogs = null String serviceTail = null @@ -307,6 +308,21 @@ def cmdSession(args) { if (args.vcpu > 0) { json += ""","vcpu":${args.vcpu}""" } + + // Add input files + if (args.files) { + def filesJson = args.files.collect { filepath -> + def f = new File(filepath) + if (!f.exists()) { + System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") + System.exit(1) + } + def content = f.bytes.encodeBase64().toString() + return """{"filename":"${f.name}","content_base64":"${content}"}""" + }.join(',') + json += ""","input_files":[${filesJson}]""" + } + json += '}' println("${YELLOW}Creating session...${RESET}") @@ -539,12 +555,37 @@ def cmdService(args) { def escaped = args.serviceBootstrap.replace('\\', '\\\\').replace('"', '\\"') json += ""","bootstrap":"${escaped}"""" } + if (args.serviceBootstrapFile) { + def file = new File(args.serviceBootstrapFile) + if (file.exists()) { + def content = file.text.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') + json += ""","bootstrap_content":"${content}"""" + } else { + System.err.println("${RED}Error: Bootstrap file not found: ${args.serviceBootstrapFile}${RESET}") + System.exit(1) + } + } if (args.network) { json += ""","network":"${args.network}"""" } if (args.vcpu > 0) { json += ""","vcpu":${args.vcpu}""" } + + // Add input files + if (args.files) { + def filesJson = args.files.collect { filepath -> + def f = new File(filepath) + if (!f.exists()) { + System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") + System.exit(1) + } + def content = f.bytes.encodeBase64().toString() + return """{"filename":"${f.name}","content_base64":"${content}"}""" + }.join(',') + json += ""","input_files":[${filesJson}]""" + } + json += '}' def output = apiRequest('/services', 'POST', json, publicKey, secretKey) @@ -633,6 +674,9 @@ def parseArgs(argv) { case '--bootstrap': args.serviceBootstrap = argv[++i] break + case '--bootstrap-file': + args.serviceBootstrapFile = argv[++i] + break case '--info': args.serviceInfo = argv[++i] break diff --git a/un.hs b/un.hs index dc15cf8..c97a45f 100644 --- a/un.hs +++ b/un.hs @@ -133,6 +133,7 @@ data SessionOpts = SessionOpts , sessShell :: Maybe String , sessNetwork :: Maybe String , sessVcpu :: Maybe Int + , sessFiles :: [String] } data SessionAction = SessionList | SessionKill String | SessionCreate @@ -143,8 +144,10 @@ data ServiceOpts = ServiceOpts , svcPorts :: Maybe String , svcType :: Maybe String , svcBootstrap :: Maybe String + , svcBootstrapFile :: Maybe String , svcNetwork :: Maybe String , svcVcpu :: Maybe Int + , svcFiles :: [String] } data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String @@ -174,7 +177,7 @@ parseKey args = return $ parseKeyArgs args defaultKeyOpts parseSession :: [String] -> IO SessionOpts parseSession args = return $ parseSessionArgs args defaultSessionOpts where - defaultSessionOpts = SessionOpts SessionCreate Nothing Nothing Nothing + defaultSessionOpts = SessionOpts SessionCreate Nothing Nothing Nothing [] parseSessionArgs [] opts = opts parseSessionArgs ("--list":rest) opts = parseSessionArgs rest opts { sessAction = SessionList } parseSessionArgs ("--kill":id:rest) opts = parseSessionArgs rest opts { sessAction = SessionKill id } @@ -182,12 +185,13 @@ parseSession args = return $ parseSessionArgs args defaultSessionOpts parseSessionArgs ("-s":sh:rest) opts = parseSessionArgs rest opts { sessShell = Just sh } parseSessionArgs ("-n":net:rest) opts = parseSessionArgs rest opts { sessNetwork = Just net } parseSessionArgs ("-v":v:rest) opts = parseSessionArgs rest opts { sessVcpu = Just (read v) } + parseSessionArgs ("-f":f:rest) opts = parseSessionArgs rest opts { sessFiles = sessFiles opts ++ [f] } parseSessionArgs (_:rest) opts = parseSessionArgs rest opts parseService :: [String] -> IO ServiceOpts parseService args = return $ parseServiceArgs args defaultServiceOpts where - defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing + defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing [] parseServiceArgs [] opts = opts parseServiceArgs ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList } parseServiceArgs ("--info":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceInfo id } @@ -202,8 +206,10 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts parseServiceArgs ("--ports":p:rest) opts = parseServiceArgs rest opts { svcPorts = Just p } parseServiceArgs ("--type":t:rest) opts = parseServiceArgs rest opts { svcType = Just t } parseServiceArgs ("--bootstrap":b:rest) opts = parseServiceArgs rest opts { svcBootstrap = Just b } + parseServiceArgs ("--bootstrap-file":f:rest) opts = parseServiceArgs rest opts { svcBootstrapFile = Just f } parseServiceArgs ("-n":net:rest) opts = parseServiceArgs rest opts { svcNetwork = Just net } parseServiceArgs ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) } + parseServiceArgs ("-f":f:rest) opts = parseServiceArgs rest opts { svcFiles = svcFiles opts ++ [f] } parseServiceArgs (_:rest) opts = parseServiceArgs rest opts parseExecute :: [String] -> IO Command @@ -317,7 +323,18 @@ sessionCommand opts = 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 ++ "}" + -- Input files + filesJSON <- if null (sessFiles opts) + then return "" + else do + fileEntries <- mapM (\f -> do + content <- BS.readFile f + let b64 = BSC.unpack $ B64.encode content + let fname = takeFileName f + return $ "{\"filename\":\"" ++ fname ++ "\",\"content_base64\":\"" ++ b64 ++ "\"}" + ) (sessFiles opts) + return $ ",\"input_files\":[" ++ intercalate "," fileEntries ++ "]" + let json = "{\"shell\":\"" ++ shell ++ "\"" ++ networkJSON ++ vcpuJSON ++ filesJSON ++ "}" (_, stdout, _) <- curlPost apiKey "https://api.unsandbox.com/sessions" json putStrLn $ yellow ++ "Session created (WebSocket required for interactivity)" ++ reset putStrLn stdout @@ -376,9 +393,25 @@ serviceCommand opts = do let portsJSON = maybe "" (\p -> ",\"ports\":[" ++ p ++ "]") (svcPorts opts) let typeJSON = maybe "" (\t -> ",\"service_type\":\"" ++ t ++ "\"") (svcType opts) let bootstrapJSON = maybe "" (\b -> ",\"bootstrap\":\"" ++ escapeJSON b ++ "\"") (svcBootstrap opts) + bootstrapContentJSON <- case svcBootstrapFile opts of + Just f -> do + content <- readFile f + return $ ",\"bootstrap_content\":\"" ++ escapeJSON content ++ "\"" + Nothing -> return "" 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 ++ "}" + -- Input files + filesJSON <- if null (svcFiles opts) + then return "" + else do + fileEntries <- mapM (\f -> do + content <- BS.readFile f + let b64 = BSC.unpack $ B64.encode content + let fname = takeFileName f + return $ "{\"filename\":\"" ++ fname ++ "\",\"content_base64\":\"" ++ b64 ++ "\"}" + ) (svcFiles opts) + return $ ",\"input_files\":[" ++ intercalate "," fileEntries ++ "]" + let json = "{\"name\":\"" ++ name ++ "\"" ++ portsJSON ++ typeJSON ++ bootstrapJSON ++ bootstrapContentJSON ++ networkJSON ++ vcpuJSON ++ filesJSON ++ "}" (_, stdout, _) <- curlPost apiKey "https://api.unsandbox.com/services" json putStrLn $ green ++ "Service created" ++ reset putStrLn stdout diff --git a/un.jl b/un.jl index fefb215..3e581de 100755 --- a/un.jl +++ b/un.jl @@ -276,8 +276,36 @@ function cmd_session(args) return end - println(stderr, "$(RED)Error: Use --list or --kill$(RESET)") - exit(1) + # Create new session + payload = Dict("shell" => "bash") + + if args["network"] !== nothing + payload["network"] = args["network"] + end + + # Add input files + if args["files"] !== nothing + input_files = [] + for filepath in args["files"] + if !isfile(filepath) + println(stderr, "$(RED)Error: Input file not found: $filepath$(RESET)") + exit(1) + end + content = base64encode(read(filepath)) + push!(input_files, Dict( + "filename" => basename(filepath), + "content_base64" => content + )) + end + if !isempty(input_files) + payload["input_files"] = input_files + end + end + + println("$(YELLOW)Creating session...$(RESET)") + result = api_request("/sessions", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Session created: $(get(result, "id", "N/A"))$(RESET)") + println("$(YELLOW)(Interactive sessions require WebSocket - use un2 for full support)$(RESET)") end function cmd_service(args) @@ -380,11 +408,16 @@ function cmd_service(args) end if args["bootstrap"] !== nothing - bootstrap = args["bootstrap"] - if isfile(bootstrap) - payload["bootstrap"] = read(bootstrap, String) + payload["bootstrap"] = args["bootstrap"] + end + + if args["bootstrap-file"] !== nothing + bootstrap_file = args["bootstrap-file"] + if isfile(bootstrap_file) + payload["bootstrap_content"] = read(bootstrap_file, String) else - payload["bootstrap"] = bootstrap + println(stderr, "$(RED)Error: Bootstrap file not found: $bootstrap_file$(RESET)") + exit(1) end end @@ -396,6 +429,25 @@ function cmd_service(args) payload["vcpu"] = args["vcpu"] end + # Add input files + if args["files"] !== nothing + input_files = [] + for filepath in args["files"] + if !isfile(filepath) + println(stderr, "$(RED)Error: Input file not found: $filepath$(RESET)") + exit(1) + end + content = base64encode(read(filepath)) + push!(input_files, Dict( + "filename" => basename(filepath), + "content_base64" => content + )) + end + if !isempty(input_files) + payload["input_files"] = input_files + end + end + result = api_request("/services", public_key, secret_key, method="POST", data=payload) println("$(GREEN)Service created: $(get(result, "id", "N/A"))$(RESET)") println("Name: $(get(result, "name", "N/A"))") @@ -595,6 +647,13 @@ function main() action = :store_true "--kill" help = "Terminate session" + "--files", "-f" + help = "Add input file" + action = :append_arg + "--network", "-n" + help = "Network mode" + arg_type = String + range_tester = x -> x in ["zerotrust", "semitrusted"] "--api-key", "-k" help = "API key" end @@ -609,7 +668,12 @@ function main() "--type" help = "Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)" "--bootstrap" - help = "Bootstrap command/file" + help = "Bootstrap command or URI" + "--bootstrap-file" + help = "Upload local file as bootstrap script" + "--files", "-f" + help = "Add input file" + action = :append_arg "--network", "-n" help = "Network mode" arg_type = String diff --git a/un.js b/un.js index 9dc7c0a..67ced04 100644 --- a/un.js +++ b/un.js @@ -417,6 +417,22 @@ async function cmdSession(args) { if (args.screen) payload.persistence = "screen"; if (args.audit) payload.audit = true; + // Add input files + if (args.files && args.files.length > 0) { + payload.input_files = args.files.map(filepath => { + try { + const content = fs.readFileSync(filepath); + return { + filename: path.basename(filepath), + content_base64: content.toString('base64') + }; + } catch (e) { + console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); + process.exit(1); + } + }); + } + console.log(`${YELLOW}Creating session...${RESET}`); const result = await apiRequest("/sessions", "POST", payload, publicKey, secretKey); console.log(`${GREEN}Session created: ${result.id || 'N/A'}${RESET}`); @@ -520,11 +536,29 @@ async function cmdService(args) { if (args.domains) payload.domains = args.domains.split(','); if (args.serviceType) payload.service_type = args.serviceType; if (args.bootstrap) { - if (fs.existsSync(args.bootstrap)) { - payload.bootstrap = fs.readFileSync(args.bootstrap, 'utf-8'); - } else { - payload.bootstrap = args.bootstrap; + payload.bootstrap = args.bootstrap; + } + if (args.bootstrapFile) { + if (!fs.existsSync(args.bootstrapFile)) { + console.error(`${RED}Error: Bootstrap file not found: ${args.bootstrapFile}${RESET}`); + process.exit(1); } + payload.bootstrap_content = fs.readFileSync(args.bootstrapFile, 'utf-8'); + } + // Add input files + if (args.files && args.files.length > 0) { + payload.input_files = args.files.map(filepath => { + try { + const content = fs.readFileSync(filepath); + return { + filename: path.basename(filepath), + content_base64: content.toString('base64') + }; + } catch (e) { + console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); + process.exit(1); + } + }); } if (args.network) payload.network = args.network; if (args.vcpu) payload.vcpu = args.vcpu; @@ -639,6 +673,9 @@ function parseArgs(argv) { } else if (arg === '--bootstrap' && i + 1 < argv.length) { args.bootstrap = argv[++i]; i++; + } else if (arg === '--bootstrap-file' && i + 1 < argv.length) { + args.bootstrapFile = argv[++i]; + i++; } else if (arg === '--info' && i + 1 < argv.length) { args.info = argv[++i]; i++; @@ -727,7 +764,8 @@ Service options: --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.kt b/un.kt index dd05a3d..b022a13 100644 --- a/un.kt +++ b/un.kt @@ -90,6 +90,7 @@ data class Args( var servicePorts: String? = null, var serviceType: String? = null, var serviceBootstrap: String? = null, + var serviceBootstrapFile: String? = null, var serviceInfo: String? = null, var serviceLogs: String? = null, var serviceTail: String? = null, @@ -238,6 +239,22 @@ fun cmdSession(args: Args) { payload["vcpu"] = args.vcpu } + // Add input files + if (args.files.isNotEmpty()) { + val inputFiles = args.files.map { filepath -> + val file = java.io.File(filepath) + if (!file.exists()) { + System.err.println("${RED}Error: Input file not found: $filepath${RESET}") + exitProcess(1) + } + mapOf( + "filename" to file.name, + "content_base64" to java.util.Base64.getEncoder().encodeToString(file.readBytes()) + ) + } + payload["input_files"] = inputFiles + } + println("${YELLOW}Creating session...${RESET}") val result = apiRequest("/sessions", "POST", payload, publicKey, secretKey) println("${GREEN}Session created: ${result["id"] ?: "N/A"}${RESET}") @@ -363,6 +380,30 @@ fun cmdService(args: Args) { if (args.serviceBootstrap != null) { payload["bootstrap"] = args.serviceBootstrap!! } + if (args.serviceBootstrapFile != null) { + val file = File(args.serviceBootstrapFile!!) + if (file.exists()) { + payload["bootstrap_content"] = file.readText() + } else { + System.err.println("${RED}Error: Bootstrap file not found: ${args.serviceBootstrapFile}${RESET}") + exitProcess(1) + } + } + // Add input files + if (args.files.isNotEmpty()) { + val inputFiles = args.files.map { filepath -> + val file = java.io.File(filepath) + if (!file.exists()) { + System.err.println("${RED}Error: Input file not found: $filepath${RESET}") + exitProcess(1) + } + mapOf( + "filename" to file.name, + "content_base64" to java.util.Base64.getEncoder().encodeToString(file.readBytes()) + ) + } + payload["input_files"] = inputFiles + } if (args.network != null) { payload["network"] = args.network!! } @@ -695,6 +736,7 @@ fun parseArgs(args: Array): Args { "--ports" -> result.servicePorts = args[++i] "--type" -> result.serviceType = args[++i] "--bootstrap" -> result.serviceBootstrap = args[++i] + "--bootstrap-file" -> result.serviceBootstrapFile = args[++i] "--info" -> result.serviceInfo = args[++i] "--logs" -> result.serviceLogs = args[++i] "--tail" -> result.serviceTail = args[++i] diff --git a/un.lisp b/un.lisp index 4eebbdb..e688fd1 100644 --- a/un.lisp +++ b/un.lisp @@ -88,6 +88,33 @@ (read-sequence contents stream) contents))) +(defun read-file-binary (filename) + "Read file as binary and return as vector of bytes" + (with-open-file (stream filename :element-type '(unsigned-byte 8)) + (let* ((len (file-length stream)) + (data (make-array len :element-type '(unsigned-byte 8)))) + (read-sequence data stream) + data))) + +(defun base64-encode-file (filename) + "Base64 encode a file using shell command" + (let* ((cmd (format nil "base64 -w0 ~a" (uiop:escape-sh-token filename))) + (result (string-trim '(#\Space #\Tab #\Newline #\Return) + (uiop:run-program cmd :output :string)))) + result)) + +(defun build-input-files-json (files) + "Build input_files JSON array from list of filenames" + (if (null files) + "" + (format nil ",\"input_files\":[~{~a~^,~}]" + (mapcar (lambda (f) + (let* ((basename (file-namestring f)) + (content (base64-encode-file f))) + (format nil "{\"filename\":\"~a\",\"content\":\"~a\"}" + basename content))) + files)))) + (defun write-temp-file (data) (let ((tmp-file (format nil "/tmp/un_lisp_~a.json" (random 999999)))) (with-open-file (stream tmp-file :direction :output :if-exists :supersede) @@ -209,7 +236,7 @@ (response (curl-post api-key "/execute" json))) (format t "~a~%" response)))) -(defun session-cmd (action id shell) +(defun session-cmd (action id shell input-files) (let ((api-key (get-api-key))) (cond ((string= action "list") @@ -219,12 +246,13 @@ (format t "~aSession terminated: ~a~a~%" *green* id *reset*)) (t (let* ((sh (or shell "bash")) - (json (format nil "{\"shell\":\"~a\"}" sh)) + (input-files-json (build-input-files-json input-files)) + (json (format nil "{\"shell\":\"~a\"~a}" sh input-files-json)) (response (curl-post api-key "/sessions" json))) (format t "~aSession created (WebSocket required)~a~%" *yellow* *reset*) (format t "~a~%" response)))))) -(defun service-cmd (action id name ports bootstrap service-type) +(defun service-cmd (action id name ports bootstrap bootstrap-file service-type input-files) (let ((api-key (get-api-key))) (cond ((string= action "list") @@ -269,8 +297,12 @@ ((and (string= action "create") name) (let* ((ports-json (if ports (format nil ",\"ports\":[~a]" ports) "")) (bootstrap-json (if bootstrap (format nil ",\"bootstrap\":\"~a\"" (escape-json bootstrap)) "")) + (bootstrap-content-json (if bootstrap-file + (format nil ",\"bootstrap_content\":\"~a\"" (escape-json (read-file bootstrap-file))) + "")) (type-json (if service-type (format nil ",\"service_type\":\"~a\"" service-type) "")) - (json (format nil "{\"name\":\"~a\"~a~a~a}" name ports-json bootstrap-json type-json)) + (input-files-json (build-input-files-json input-files)) + (json (format nil "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json)) (response (curl-post api-key "/services" json))) (format t "~aService created~a~%" *green* *reset*) (format t "~a~%" response))) @@ -364,6 +396,19 @@ (defun key-cmd (extend-flag) (validate-key extend-flag)) +(defun parse-input-files (args) + "Parse -f flags from args and return list of filenames" + (let ((files nil)) + (loop for i from 0 below (1- (length args)) + do (when (string= (nth i args) "-f") + (let ((file (nth (1+ i) args))) + (if (probe-file file) + (push file files) + (progn + (format *error-output* "Error: File not found: ~a~%" file) + (uiop:quit 1)))))) + (nreverse files))) + (defun main () (let ((args (uiop:command-line-arguments))) (if (null args) @@ -377,45 +422,57 @@ ((string= (first args) "session") (cond ((and (> (length args) 1) (string= (second args) "--list")) - (session-cmd "list" nil nil)) + (session-cmd "list" nil nil nil)) ((and (> (length args) 2) (string= (second args) "--kill")) - (session-cmd "kill" (third args) nil)) + (session-cmd "kill" (third args) nil nil)) (t - (session-cmd "create" nil nil)))) + ;; Parse session create options including -f + (let* ((rest-args (cdr args)) + (shell nil) + (input-files (parse-input-files rest-args))) + (loop for i from 0 below (1- (length rest-args)) + do (let ((opt (nth i rest-args)) + (val (nth (1+ i) rest-args))) + (cond + ((or (string= opt "--shell") (string= opt "-s")) (setf shell val))))) + (session-cmd "create" nil shell input-files))))) ((string= (first args) "service") (cond ((and (> (length args) 1) (string= (second args) "--list")) - (service-cmd "list" nil nil nil nil nil)) + (service-cmd "list" nil nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--info")) - (service-cmd "info" (third args) nil nil nil nil)) + (service-cmd "info" (third args) nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--logs")) - (service-cmd "logs" (third args) nil nil nil nil)) + (service-cmd "logs" (third args) nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--freeze")) - (service-cmd "sleep" (third args) nil nil nil nil)) + (service-cmd "sleep" (third args) nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--unfreeze")) - (service-cmd "wake" (third args) nil nil nil nil)) + (service-cmd "wake" (third args) nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--destroy")) - (service-cmd "destroy" (third args) nil nil nil nil)) + (service-cmd "destroy" (third args) nil nil nil nil nil nil)) ((and (> (length args) 3) (string= (second args) "--execute")) - (service-cmd "execute" (third args) nil nil (fourth args) nil)) + (service-cmd "execute" (third args) nil nil (fourth args) nil nil nil)) ((and (> (length args) 3) (string= (second args) "--dump-bootstrap")) - (service-cmd "dump-bootstrap" (third args) nil nil nil (fourth args))) + (service-cmd "dump-bootstrap" (third args) nil nil nil nil (fourth args) nil)) ((and (> (length args) 2) (string= (second args) "--dump-bootstrap")) - (service-cmd "dump-bootstrap" (third args) nil nil nil nil)) + (service-cmd "dump-bootstrap" (third args) nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--name")) (let* ((name (third args)) (rest-args (nthcdr 3 args)) (ports nil) (bootstrap nil) - (service-type nil)) - (loop for i from 0 below (length rest-args) by 2 + (bootstrap-file nil) + (service-type nil) + (input-files (parse-input-files rest-args))) + (loop for i from 0 below (1- (length rest-args)) do (let ((opt (nth i rest-args)) (val (nth (1+ i) rest-args))) (cond ((string= opt "--ports") (setf ports val)) ((string= opt "--bootstrap") (setf bootstrap val)) + ((string= opt "--bootstrap-file") (setf bootstrap-file val)) ((string= opt "--type") (setf service-type val))))) - (service-cmd "create" nil name ports bootstrap service-type))) + (service-cmd "create" nil name ports bootstrap bootstrap-file service-type input-files))) (t (format t "Error: Invalid service command~%") (uiop:quit 1)))) diff --git a/un.lua b/un.lua index bce0902..3acd1c4 100644 --- a/un.lua +++ b/un.lua @@ -343,6 +343,19 @@ local function cmd_session(options) if options.screen then payload.persistence = "screen" end if options.audit then payload.audit = true end + -- Add input files + if options.files and #options.files > 0 then + local input_files = {} + for _, filepath in ipairs(options.files) do + local content = read_file(filepath) + table.insert(input_files, { + filename = filepath:match("([^/]+)$"), + content_base64 = base64_encode(content) + }) + end + payload.input_files = input_files + end + print(YELLOW .. "Creating session..." .. RESET) local result = api_request("/sessions", "POST", payload, keys) print(GREEN .. "Session created: " .. (result.id or "N/A") .. RESET) @@ -584,13 +597,28 @@ local function cmd_service(options) payload.service_type = options.type end if options.bootstrap then - local file = io.open(options.bootstrap, "r") - if file then - payload.bootstrap = file:read("*all") - file:close() - else - payload.bootstrap = options.bootstrap + payload.bootstrap = options.bootstrap + end + if options.bootstrap_file then + local file = io.open(options.bootstrap_file, "r") + if not file then + io.stderr:write(RED .. "Error: Bootstrap file not found: " .. options.bootstrap_file .. RESET .. "\n") + os.exit(1) end + payload.bootstrap_content = file:read("*all") + file:close() + end + -- Add input files + if options.files and #options.files > 0 then + local input_files = {} + for _, filepath in ipairs(options.files) do + local content = read_file(filepath) + table.insert(input_files, { + filename = filepath:match("([^/]+)$"), + content_base64 = base64_encode(content) + }) + end + payload.input_files = input_files end if options.network then payload.network = options.network end if options.vcpu then payload.vcpu = options.vcpu end @@ -629,6 +657,7 @@ local function main() domains = nil, type = nil, bootstrap = nil, + bootstrap_file = nil, info = nil, logs = nil, tail = nil, @@ -700,6 +729,9 @@ local function main() elseif a == "--bootstrap" then i = i + 1 options.bootstrap = arg[i] + elseif a == "--bootstrap-file" then + i = i + 1 + options.bootstrap_file = arg[i] elseif a == "--info" then i = i + 1 options.info = arg[i] @@ -780,7 +812,8 @@ Service options: --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type (minecraft|mumble|teamspeak|source|tcp|udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.m b/un.m index 64edcc3..659518e 100644 --- a/un.m +++ b/un.m @@ -450,6 +450,7 @@ void cmdSession(NSArray* args) { NSString* shell = nil; NSString* network = nil; int vcpu = 0; + NSMutableArray* inputFiles = [NSMutableArray array]; // Parse arguments for (NSUInteger i = 0; i < [args count]; i++) { @@ -460,6 +461,8 @@ void cmdSession(NSArray* args) { killId = args[++i]; } else if ([arg isEqualToString:@"--shell"] && i + 1 < [args count]) { shell = args[++i]; + } else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) { + [inputFiles addObject:args[++i]]; } else if ([arg isEqualToString:@"-n"] && i + 1 < [args count]) { network = args[++i]; } else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) { @@ -499,6 +502,26 @@ void cmdSession(NSArray* args) { if (network) payload[@"network"] = network; if (vcpu > 0) payload[@"vcpu"] = @(vcpu); + // Add input files + if ([inputFiles count] > 0) { + NSFileManager* fm = [NSFileManager defaultManager]; + NSMutableArray* files = [NSMutableArray array]; + for (NSString* filepath in inputFiles) { + if (![fm fileExistsAtPath:filepath]) { + fprintf(stderr, "%sError: Input file not found: %s%s\n", + [RED UTF8String], [filepath UTF8String], [RESET UTF8String]); + exit(1); + } + NSData* content = [NSData dataWithContentsOfFile:filepath]; + NSString* b64Content = [content base64EncodedStringWithOptions:0]; + [files addObject:@{ + @"filename": [filepath lastPathComponent], + @"content_base64": b64Content + }]; + } + payload[@"input_files"] = files; + } + printf("%sCreating session...%s\n", [YELLOW UTF8String], [RESET UTF8String]); NSDictionary* result = apiRequest(@"/sessions", @"POST", payload, publicKey, secretKey); printf("%sSession created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); @@ -521,8 +544,10 @@ void cmdService(NSArray* args) { NSString* ports = nil; NSString* type = nil; NSString* bootstrap = nil; + NSString* bootstrapFile = nil; NSString* network = nil; int vcpu = 0; + NSMutableArray* inputFiles = [NSMutableArray array]; // Parse arguments for (NSUInteger i = 0; i < [args count]; i++) { @@ -551,6 +576,10 @@ void cmdService(NSArray* args) { type = args[++i]; } else if ([arg isEqualToString:@"--bootstrap"] && i + 1 < [args count]) { bootstrap = args[++i]; + } else if ([arg isEqualToString:@"--bootstrap-file"] && i + 1 < [args count]) { + bootstrapFile = args[++i]; + } else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) { + [inputFiles addObject:args[++i]]; } else if ([arg isEqualToString:@"-n"] && i + 1 < [args count]) { network = args[++i]; } else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) { @@ -669,15 +698,41 @@ void cmdService(NSArray* args) { } if (bootstrap) { + payload[@"bootstrap"] = bootstrap; + } + + if (bootstrapFile) { NSFileManager* fm = [NSFileManager defaultManager]; - if ([fm fileExistsAtPath:bootstrap]) { - NSString* content = [NSString stringWithContentsOfFile:bootstrap encoding:NSUTF8StringEncoding error:nil]; - payload[@"bootstrap"] = content; + if ([fm fileExistsAtPath:bootstrapFile]) { + NSString* content = [NSString stringWithContentsOfFile:bootstrapFile encoding:NSUTF8StringEncoding error:nil]; + payload[@"bootstrap_content"] = content; } else { - payload[@"bootstrap"] = bootstrap; + fprintf(stderr, "%sError: Bootstrap file not found: %s%s\n", + [RED UTF8String], [bootstrapFile UTF8String], [RESET UTF8String]); + exit(1); } } + // Add input files + if ([inputFiles count] > 0) { + NSFileManager* fm = [NSFileManager defaultManager]; + NSMutableArray* files = [NSMutableArray array]; + for (NSString* filepath in inputFiles) { + if (![fm fileExistsAtPath:filepath]) { + fprintf(stderr, "%sError: Input file not found: %s%s\n", + [RED UTF8String], [filepath UTF8String], [RESET UTF8String]); + exit(1); + } + NSData* content = [NSData dataWithContentsOfFile:filepath]; + NSString* b64Content = [content base64EncodedStringWithOptions:0]; + [files addObject:@{ + @"filename": [filepath lastPathComponent], + @"content_base64": b64Content + }]; + } + payload[@"input_files"] = files; + } + if (network) payload[@"network"] = network; if (vcpu > 0) payload[@"vcpu"] = @(vcpu); diff --git a/un.ml b/un.ml index 1fa77da..9563ac1 100755 --- a/un.ml +++ b/un.ml @@ -91,6 +91,25 @@ let read_file filename = close_in ic; s +(* Base64 encode a file using shell command *) +let base64_encode_file filename = + let cmd = Printf.sprintf "base64 -w0 %s" (Filename.quote filename) in + let ic = Unix.open_process_in cmd in + let result = try input_line ic with End_of_file -> "" in + let _ = Unix.close_process_in ic in + String.trim result + +(* Build input_files JSON from list of filenames *) +let build_input_files_json files = + if files = [] then "" + else + let entries = List.map (fun f -> + let basename = Filename.basename f in + let content = base64_encode_file f in + Printf.sprintf "{\"filename\":\"%s\",\"content\":\"%s\"}" basename content + ) files in + ",\"input_files\":[" ^ (String.concat "," entries) ^ "]" + (* Get file extension *) let get_extension filename = try @@ -407,7 +426,7 @@ let key_command extend = validate_key api_key extend (* Session command *) -let session_command action shell network vcpu = +let session_command action shell network vcpu input_files = let api_key = get_api_key () in match action with | "list" -> @@ -425,7 +444,8 @@ let session_command action shell network vcpu = let sh = match shell with Some s -> s | None -> "bash" in let network_json = match network with Some n -> Printf.sprintf ",\"network\":\"%s\"" n | None -> "" in let vcpu_json = match vcpu with Some v -> Printf.sprintf ",\"vcpu\":%d" v | None -> "" in - let json = Printf.sprintf "{\"shell\":\"%s\"%s%s}" sh network_json vcpu_json in + let input_files_json = build_input_files_json input_files in + let json = Printf.sprintf "{\"shell\":\"%s\"%s%s%s}" sh network_json vcpu_json input_files_json in let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in let oc = open_out tmp_file in output_string oc json; @@ -445,7 +465,7 @@ let session_command action shell network vcpu = | _ -> () (* Service command *) -let service_command action name ports bootstrap service_type network vcpu = +let service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files = let api_key = get_api_key () in match action with | "list" -> @@ -576,10 +596,17 @@ let service_command action name ports bootstrap service_type network vcpu = | Some n -> let ports_json = match ports with Some p -> Printf.sprintf ",\"ports\":[%s]" p | None -> "" in let bootstrap_json = match bootstrap with Some b -> Printf.sprintf ",\"bootstrap\":\"%s\"" (escape_json b) | None -> "" in + let bootstrap_content_json = match bootstrap_file with + | Some f -> + let content = read_file f in + Printf.sprintf ",\"bootstrap_content\":\"%s\"" (escape_json content) + | None -> "" + in let service_type_json = match service_type with Some t -> Printf.sprintf ",\"service_type\":\"%s\"" t | None -> "" in let network_json = match network with Some net -> Printf.sprintf ",\"network\":\"%s\"" net | None -> "" in let vcpu_json = match vcpu with Some v -> Printf.sprintf ",\"vcpu\":%d" v | None -> "" in - let json = Printf.sprintf "{\"name\":\"%s\"%s%s%s%s%s}" n ports_json bootstrap_json service_type_json network_json vcpu_json in + let input_files_json = build_input_files_json input_files in + let json = Printf.sprintf "{\"name\":\"%s\"%s%s%s%s%s%s%s}" n ports_json bootstrap_json bootstrap_content_json service_type_json network_json vcpu_json input_files_json in let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in let oc = open_out tmp_file in output_string oc json; @@ -601,6 +628,18 @@ let service_command action name ports bootstrap service_type network vcpu = exit 1) | _ -> () +(* Parse -f flags from argument list *) +let rec parse_input_files acc = function + | [] -> List.rev acc + | "-f" :: file :: rest -> + if Sys.file_exists file then + parse_input_files (file :: acc) rest + else begin + Printf.fprintf stderr "Error: File not found: %s\n" file; + exit 1 + end + | _ :: rest -> parse_input_files acc rest + (* Parse arguments *) let () = Random.self_init (); @@ -616,37 +655,42 @@ let () = let extend = List.mem "--extend" rest in key_command extend | "session" :: rest -> + let input_files = parse_input_files [] rest in let rec parse_session action shell network vcpu = function - | [] -> session_command action shell network vcpu + | [] -> session_command action shell network vcpu input_files | "--list" :: rest -> parse_session "list" shell network vcpu rest | "--kill" :: id :: rest -> parse_session "kill" (Some id) network vcpu rest | "--shell" :: sh :: rest | "-s" :: sh :: rest -> parse_session action (Some sh) network vcpu rest | "-n" :: net :: rest -> parse_session action shell (Some net) vcpu rest | "-v" :: v :: rest -> parse_session action shell network (Some (int_of_string v)) rest + | "-f" :: _ :: rest -> parse_session action shell network vcpu rest (* skip -f, already parsed *) | _ :: rest -> parse_session action shell network vcpu rest in parse_session "create" None None None rest | "service" :: rest -> - let rec parse_service action name ports bootstrap service_type network vcpu = function - | [] -> service_command action name ports bootstrap service_type network vcpu - | "--list" :: rest -> parse_service "list" name ports bootstrap service_type network vcpu rest - | "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap service_type network vcpu rest - | "--logs" :: id :: rest -> parse_service "logs" (Some id) ports bootstrap service_type network vcpu rest - | "--freeze" :: id :: rest -> parse_service "sleep" (Some id) ports bootstrap service_type network vcpu rest - | "--unfreeze" :: id :: rest -> parse_service "wake" (Some id) ports bootstrap service_type network vcpu rest - | "--destroy" :: id :: rest -> parse_service "destroy" (Some id) ports bootstrap service_type network vcpu rest - | "--execute" :: id :: "--command" :: cmd :: rest -> parse_service "execute" (Some id) ports (Some cmd) service_type network vcpu rest - | "--dump-bootstrap" :: id :: file :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap (Some file) network vcpu rest - | "--dump-bootstrap" :: id :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap service_type network vcpu rest - | "--name" :: n :: rest -> parse_service "create" (Some n) ports bootstrap service_type network vcpu rest - | "--ports" :: p :: rest -> parse_service action name (Some p) bootstrap service_type network vcpu rest - | "--bootstrap" :: b :: rest -> parse_service action name ports (Some b) service_type network vcpu rest - | "--type" :: t :: rest -> parse_service action name ports bootstrap (Some t) network vcpu rest - | "-n" :: net :: rest -> parse_service action name ports bootstrap service_type (Some net) vcpu rest - | "-v" :: v :: rest -> parse_service action name ports bootstrap service_type network (Some (int_of_string v)) rest - | _ :: rest -> parse_service action name ports bootstrap service_type network vcpu rest + let input_files = parse_input_files [] rest in + let rec parse_service action name ports bootstrap bootstrap_file service_type network vcpu = function + | [] -> service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files + | "--list" :: rest -> parse_service "list" name ports bootstrap bootstrap_file service_type network vcpu rest + | "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest + | "--logs" :: id :: rest -> parse_service "logs" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest + | "--freeze" :: id :: rest -> parse_service "sleep" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest + | "--unfreeze" :: id :: rest -> parse_service "wake" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest + | "--destroy" :: id :: rest -> parse_service "destroy" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest + | "--execute" :: id :: "--command" :: cmd :: rest -> parse_service "execute" (Some id) ports (Some cmd) bootstrap_file service_type network vcpu rest + | "--dump-bootstrap" :: id :: file :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap (Some file) service_type network vcpu rest + | "--dump-bootstrap" :: id :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest + | "--name" :: n :: rest -> parse_service "create" (Some n) ports bootstrap bootstrap_file service_type network vcpu rest + | "--ports" :: p :: rest -> parse_service action name (Some p) bootstrap bootstrap_file service_type network vcpu rest + | "--bootstrap" :: b :: rest -> parse_service action name ports (Some b) bootstrap_file service_type network vcpu rest + | "--bootstrap-file" :: f :: rest -> parse_service action name ports bootstrap (Some f) service_type network vcpu rest + | "--type" :: t :: rest -> parse_service action name ports bootstrap bootstrap_file (Some t) network vcpu rest + | "-n" :: net :: rest -> parse_service action name ports bootstrap bootstrap_file service_type (Some net) vcpu rest + | "-v" :: v :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network (Some (int_of_string v)) rest + | "-f" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu rest (* skip -f, already parsed *) + | _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu rest in - parse_service "create" None None None None None None rest + parse_service "create" None None None None None None None rest | args -> let rec parse_execute file env_vars artifacts out_dir network vcpu = function | [] -> execute_command file env_vars artifacts out_dir network vcpu diff --git a/un.nim b/un.nim index b550fa2..dafe71a 100644 --- a/un.nim +++ b/un.nim @@ -76,6 +76,20 @@ proc escapeJson(s: string): string = of '\t': result.add("\\t") else: result.add(c) +proc base64EncodeFile(filename: string): string = + let cmd = fmt"base64 -w0 '{filename}'" + result = execProcess(cmd).strip() + +proc buildInputFilesJson(files: seq[string]): string = + if files.len == 0: + return "" + var entries: seq[string] = @[] + for f in files: + let basename = extractFilename(f) + let content = base64EncodeFile(f) + entries.add(fmt"""{{"filename":"{basename}","content":"{content}"}}""") + result = fmt""","input_files":[{entries.join(",")}]""" + proc computeHmac(secretKey: string, message: string): string = let cmd = fmt"echo -n '{message}' | openssl dgst -sha256 -hmac '{secretKey}' -hex 2>/dev/null | sed 's/.*= //'" result = execProcess(cmd).strip() @@ -136,7 +150,7 @@ proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: let cmd = fmt"""curl -s -X POST '{API_BASE}/execute' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) -proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, screen: bool, publicKey: string, secretKey: string) = +proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, screen: bool, inputFiles: seq[string], publicKey: string, secretKey: string) = if list: let authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey) let cmd = fmt"""curl -s -X GET '{API_BASE}/sessions' {authHeaders}""" @@ -156,6 +170,7 @@ proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, scree if vcpu > 0: json.add(fmt""","vcpu":{vcpu}""") if tmux: json.add(""","persistence":"tmux"""") if screen: json.add(""","persistence":"screen"""") + json.add(buildInputFilesJson(inputFiles)) json.add("}") echo YELLOW & "Creating session..." & RESET @@ -163,7 +178,7 @@ proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, scree let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) -proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, publicKey: string, secretKey: string) = +proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, inputFiles: seq[string], publicKey: string, secretKey: string) = if list: let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey) let cmd = fmt"""curl -s -X GET '{API_BASE}/services' {authHeaders}""" @@ -294,14 +309,18 @@ proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, l var json = fmt"""{"name":"{name}"""" if ports != "": json.add(fmt""","ports":[{ports}]""") if bootstrap != "": - if fileExists(bootstrap): - let bootCode = readFile(bootstrap) - json.add(fmt""","bootstrap":"{escapeJson(bootCode)}"""") + json.add(fmt""","bootstrap":"{escapeJson(bootstrap)}"""") + if bootstrapFile != "": + if fileExists(bootstrapFile): + let bootCode = readFile(bootstrapFile) + json.add(fmt""","bootstrap_content":"{escapeJson(bootCode)}"""") else: - json.add(fmt""","bootstrap":"{escapeJson(bootstrap)}"""") + stderr.writeLine(RED & "Error: Bootstrap file not found: " & bootstrapFile & RESET) + quit(1) if serviceType != "": json.add(fmt""","service_type":"{serviceType}"""") if network != "": json.add(fmt""","network":"{network}"""") if vcpu > 0: json.add(fmt""","vcpu":{vcpu}""") + json.add(buildInputFilesJson(inputFiles)) json.add("}") echo YELLOW & "Creating service..." & RESET @@ -422,6 +441,7 @@ proc main() = var kill, shell, network = "" var vcpu = 0 var tmux, screen = false + var inputFiles: seq[string] = @[] var i = 1 while i < args.len: case args[i] @@ -433,21 +453,32 @@ proc main() = of "--tmux": tmux = true of "--screen": screen = true of "-k": publicKey = args[i+1]; inc i + of "-f": + let file = args[i+1] + if fileExists(file): + inputFiles.add(file) + else: + stderr.writeLine("Error: File not found: " & file) + quit(1) + inc i + else: discard inc i - cmdSession(list, kill, shell, network, vcpu, tmux, screen, publicKey, secretKey) + cmdSession(list, kill, shell, network, vcpu, tmux, screen, inputFiles, publicKey, secretKey) return if args[0] == "service": - var name, ports, bootstrap, serviceType = "" + var name, ports, bootstrap, bootstrapFile, serviceType = "" var list = false var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = "" var vcpu = 0 + var inputFiles: seq[string] = @[] var i = 1 while i < args.len: case args[i] of "--name": name = args[i+1]; inc i of "--ports": ports = args[i+1]; inc i of "--bootstrap": bootstrap = args[i+1]; inc i + of "--bootstrap-file": bootstrapFile = args[i+1]; inc i of "--type": serviceType = args[i+1]; inc i of "--list": list = true of "--info": info = args[i+1]; inc i @@ -463,8 +494,17 @@ proc main() = of "-n": network = args[i+1]; inc i of "-v": vcpu = parseInt(args[i+1]); inc i of "-k": publicKey = args[i+1]; inc i + of "-f": + let file = args[i+1] + if fileExists(file): + inputFiles.add(file) + else: + stderr.writeLine("Error: File not found: " & file) + quit(1) + inc i + else: discard inc i - cmdService(name, ports, bootstrap, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, publicKey, secretKey) + cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, publicKey, secretKey) return # Execute mode diff --git a/un.php b/un.php index d939a92..840c8e9 100755 --- a/un.php +++ b/un.php @@ -288,6 +288,22 @@ function cmd_session($options) { if ($options['screen']) $payload['persistence'] = 'screen'; if ($options['audit']) $payload['audit'] = true; + // Add input files + if (!empty($options['files'])) { + $input_files = []; + foreach ($options['files'] as $filepath) { + if (!file_exists($filepath)) { + fwrite(STDERR, RED . "Error: Input file not found: $filepath" . RESET . "\n"); + exit(1); + } + $input_files[] = [ + 'filename' => basename($filepath), + 'content_base64' => base64_encode(file_get_contents($filepath)) + ]; + } + $payload['input_files'] = $input_files; + } + echo YELLOW . "Creating session..." . RESET . "\n"; $result = api_request('/sessions', 'POST', $payload, $keys); echo GREEN . "Session created: " . ($result['id'] ?? 'N/A') . RESET . "\n"; @@ -524,11 +540,29 @@ function cmd_service($options) { $payload['service_type'] = $options['type']; } if ($options['bootstrap']) { - if (file_exists($options['bootstrap'])) { - $payload['bootstrap'] = file_get_contents($options['bootstrap']); - } else { - $payload['bootstrap'] = $options['bootstrap']; + $payload['bootstrap'] = $options['bootstrap']; + } + if ($options['bootstrap_file']) { + if (!file_exists($options['bootstrap_file'])) { + fwrite(STDERR, RED . "Error: Bootstrap file not found: {$options['bootstrap_file']}" . RESET . "\n"); + exit(1); } + $payload['bootstrap_content'] = file_get_contents($options['bootstrap_file']); + } + // Add input files + if (!empty($options['files'])) { + $input_files = []; + foreach ($options['files'] as $filepath) { + if (!file_exists($filepath)) { + fwrite(STDERR, RED . "Error: Input file not found: $filepath" . RESET . "\n"); + exit(1); + } + $input_files[] = [ + 'filename' => basename($filepath), + 'content_base64' => base64_encode(file_get_contents($filepath)) + ]; + } + $payload['input_files'] = $input_files; } if ($options['network']) $payload['network'] = $options['network']; if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; @@ -569,6 +603,7 @@ function main() { 'domains' => null, 'type' => null, 'bootstrap' => null, + 'bootstrap_file' => null, 'info' => null, 'logs' => null, 'tail' => null, @@ -650,6 +685,9 @@ function main() { case '--bootstrap': $options['bootstrap'] = $argv[++$i]; break; + case '--bootstrap-file': + $options['bootstrap_file'] = $argv[++$i]; + break; case '--info': $options['info'] = $argv[++$i]; break; @@ -731,7 +769,8 @@ Service options: --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type (minecraft|mumble|teamspeak|source|tcp|udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.pl b/un.pl index 6ae252f..e8d77ef 100644 --- a/un.pl +++ b/un.pl @@ -281,6 +281,26 @@ sub cmd_session { $payload->{persistence} = 'screen' if $options->{screen}; $payload->{audit} = JSON::PP::true if $options->{audit}; + # Add input files + if ($options->{files} && @{$options->{files}}) { + my @input_files; + foreach my $filepath (@{$options->{files}}) { + unless (-e $filepath) { + print STDERR "${RED}Error: Input file not found: $filepath${RESET}\n"; + exit 1; + } + open my $f, '<:raw', $filepath or die "Cannot read file: $!"; + local $/; + my $content = <$f>; + close $f; + push @input_files, { + filename => basename($filepath), + content_base64 => encode_base64($content, '') + }; + } + $payload->{input_files} = \@input_files; + } + print "${YELLOW}Creating session...${RESET}\n"; my $result = api_request('/sessions', 'POST', $payload, $public_key, $secret_key); print "${GREEN}Session created: ", ($result->{id} // 'N/A'), "${RESET}\n"; @@ -396,14 +416,36 @@ sub cmd_service { $payload->{service_type} = $options->{type}; } if ($options->{bootstrap}) { - if (-e $options->{bootstrap}) { - open my $fh, '<', $options->{bootstrap} or die "Cannot read file: $!"; - local $/; - $payload->{bootstrap} = <$fh>; - close $fh; - } else { - $payload->{bootstrap} = $options->{bootstrap}; + $payload->{bootstrap} = $options->{bootstrap}; + } + if ($options->{bootstrap_file}) { + if (! -e $options->{bootstrap_file}) { + print STDERR "${RED}Error: Bootstrap file not found: $options->{bootstrap_file}${RESET}\n"; + exit 1; } + open my $fh, '<', $options->{bootstrap_file} or die "Cannot read file: $!"; + local $/; + $payload->{bootstrap_content} = <$fh>; + close $fh; + } + # Add input files + if ($options->{files} && @{$options->{files}}) { + my @input_files; + foreach my $filepath (@{$options->{files}}) { + unless (-e $filepath) { + print STDERR "${RED}Error: Input file not found: $filepath${RESET}\n"; + exit 1; + } + open my $f, '<:raw', $filepath or die "Cannot read file: $!"; + local $/; + my $content = <$f>; + close $f; + push @input_files, { + filename => basename($filepath), + content_base64 => encode_base64($content, '') + }; + } + $payload->{input_files} = \@input_files; } $payload->{network} = $options->{network} if $options->{network}; $payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; @@ -520,6 +562,7 @@ sub main { domains => undef, type => undef, bootstrap => undef, + bootstrap_file => undef, info => undef, logs => undef, tail => undef, @@ -576,6 +619,8 @@ sub main { $options{type} = $ARGV[++$i]; } elsif ($arg eq '--bootstrap') { $options{bootstrap} = $ARGV[++$i]; + } elsif ($arg eq '--bootstrap-file') { + $options{bootstrap_file} = $ARGV[++$i]; } elsif ($arg eq '--info') { $options{info} = $ARGV[++$i]; } elsif ($arg eq '--logs') { @@ -644,7 +689,8 @@ Service options: --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type (minecraft|mumble|teamspeak|source|tcp|udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.pro b/un.pro index 9de79d2..71baca1 100644 --- a/un.pro +++ b/un.pro @@ -150,6 +150,32 @@ session_kill(SessionId) :- [SessionId, SecretKey, SessionId, PublicKey, SessionId]), shell(Cmd, 0). +% Session create with optional input files +session_create(Shell, InputFiles) :- + get_public_key(PublicKey), + get_secret_key(SecretKey), + ( Shell \= '' + -> ShellVal = Shell + ; ShellVal = 'bash' + ), + % Build file arguments for bash script + build_file_args(InputFiles, FileArgs), + format(atom(Cmd), + 'echo -e "\\x1b[33mCreating session...\\x1b[0m"; SHELL_VAL="~w"; INPUT_FILES=""; ~w if [ -n "$INPUT_FILES" ]; then BODY="{\\\"shell\\\":\\\"$SHELL_VAL\\\",\\\"input_files\\\":[$INPUT_FILES]}"; else BODY="{\\\"shell\\\":\\\"$SHELL_VAL\\\"}"; fi; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/sessions:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/sessions -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" | jq .', + [ShellVal, FileArgs, SecretKey, PublicKey]), + shell(Cmd, 0). + +% Build bash commands to base64 encode files +build_file_args([], ''). +build_file_args(Files, Args) :- + Files \= [], + maplist(build_single_file_arg, Files, ArgList), + atomic_list_concat(ArgList, ' ', Args). + +build_single_file_arg(FilePath, Arg) :- + file_base_name(FilePath, Basename), + format(atom(Arg), 'CONTENT=$(base64 -w0 "~w"); if [ -z "$INPUT_FILES" ]; then INPUT_FILES="{\\\"filename\\\":\\\"~w\\\",\\\"content\\\":\\\"$CONTENT\\\"}"; else INPUT_FILES="$INPUT_FILES,{\\\"filename\\\":\\\"~w\\\",\\\"content\\\":\\\"$CONTENT\\\"}"; fi;', [FilePath, Basename, Basename]). + % Service list service_list :- get_public_key(PublicKey), @@ -220,8 +246,8 @@ service_dump_bootstrap(ServiceId, DumpFile) :- ), shell(Cmd, 0). -% Service create -service_create(Name, Ports, Bootstrap, ServiceType) :- +% Service create with optional input files +service_create(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles) :- get_public_key(PublicKey), get_secret_key(SecretKey), % Build JSON payload @@ -233,15 +259,24 @@ service_create(Name, Ports, Bootstrap, ServiceType) :- -> format(atom(BootstrapJson), ',"bootstrap":"~w"', [Bootstrap]) ; BootstrapJson = '' ), + ( BootstrapFile \= '' + -> ( exists_file(BootstrapFile) + -> read_file_content(BootstrapFile, BootstrapContent), + format(atom(BootstrapContentJson), ',"bootstrap_content":"~w"', [BootstrapContent]) + ; format(user_error, 'Error: Bootstrap file not found: ~w~n', [BootstrapFile]), + halt(1) + ) + ; BootstrapContentJson = '' + ), ( ServiceType \= '' -> format(atom(ServiceTypeJson), ',"service_type":"~w"', [ServiceType]) ; ServiceTypeJson = '' ), - format(atom(Json), '{"name":"~w"~w~w~w}', [Name, PortsJson, BootstrapJson, ServiceTypeJson]), - % Execute curl command with HMAC + % Build file arguments for bash script + build_file_args(InputFiles, FileArgs), format(atom(Cmd), - 'BODY=\'~w\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/services -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" && echo -e "\\x1b[32mService created\\x1b[0m"', - [Json, SecretKey, PublicKey]), + 'echo -e "\\x1b[33mCreating service...\\x1b[0m"; INPUT_FILES=""; ~w if [ -n "$INPUT_FILES" ]; then INPUT_FILES_JSON=",\\\"input_files\\\":[$INPUT_FILES]"; else INPUT_FILES_JSON=""; fi; BODY="{\\\"name\\\":\\\"~w\\\"~w~w~w~w$INPUT_FILES_JSON}"; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/services -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" | jq . && echo -e "\\x1b[32mService created\\x1b[0m"', + [FileArgs, Name, PortsJson, BootstrapJson, BootstrapContentJson, ServiceTypeJson, SecretKey, PublicKey]), shell(Cmd, 0). % Key validate @@ -269,53 +304,78 @@ handle_key(_) :- validate_key(false). handle_session(['--list'|_]) :- session_list. handle_session(['-l'|_]) :- session_list. handle_session(['--kill', SessionId|_]) :- session_kill(SessionId). -handle_session(_) :- - write(user_error, 'Error: Use --list or --kill ID\n'), - halt(1). +handle_session(Args) :- + parse_session_args(Args, '', [], Shell, InputFiles), + session_create(Shell, InputFiles). + +% Parse session arguments for -f and --shell +parse_session_args([], Shell, Files, Shell, Files). +parse_session_args(['--shell', ShellVal|Rest], _, Files, Shell, InputFiles) :- + parse_session_args(Rest, ShellVal, Files, Shell, InputFiles). +parse_session_args(['-s', ShellVal|Rest], _, Files, Shell, InputFiles) :- + parse_session_args(Rest, ShellVal, Files, Shell, InputFiles). +parse_session_args(['-f', FilePath|Rest], Shell, Files, ShellOut, InputFiles) :- + ( exists_file(FilePath) + -> append(Files, [FilePath], NewFiles), + parse_session_args(Rest, Shell, NewFiles, ShellOut, InputFiles) + ; format(user_error, 'Error: File not found: ~w~n', [FilePath]), + halt(1) + ). +parse_session_args([_|Rest], Shell, Files, ShellOut, InputFiles) :- + parse_session_args(Rest, Shell, Files, ShellOut, InputFiles). % Handle service subcommand handle_service(Args) :- - parse_service_args(Args, '', '', '', '', Action), - execute_service_action(Action). + parse_service_args(Args, '', '', '', '', '', [], Action, InputFiles), + execute_service_action(Action, InputFiles). % Parse service arguments -parse_service_args([], Name, Ports, Bootstrap, ServiceType, create) :- +parse_service_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFiles) :- ( Name \= '' - -> service_create(Name, Ports, Bootstrap, ServiceType) + -> service_create(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles) ; write(user_error, 'Error: --name required for service creation\n'), halt(1) ). -parse_service_args([], _, _, _, _, Action) :- +parse_service_args([], _, _, _, _, _, InputFiles, Action, InputFiles) :- ( Action = list -> service_list ; write(user_error, 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, or --name\n'), halt(1) ). -parse_service_args(['--list'|_], _, _, _, _, _) :- service_list. -parse_service_args(['-l'|_], _, _, _, _, _) :- service_list. -parse_service_args(['--info', ServiceId|_], _, _, _, _, _) :- service_info(ServiceId). -parse_service_args(['--logs', ServiceId|_], _, _, _, _, _) :- service_logs(ServiceId). -parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _) :- service_sleep(ServiceId). -parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _) :- service_wake(ServiceId). -parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _) :- service_destroy(ServiceId). -parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _) :- +parse_service_args(['--list'|_], _, _, _, _, _, _, _, _) :- service_list. +parse_service_args(['-l'|_], _, _, _, _, _, _, _, _) :- service_list. +parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _) :- service_info(ServiceId). +parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _) :- service_logs(ServiceId). +parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_sleep(ServiceId). +parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_wake(ServiceId). +parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _) :- service_destroy(ServiceId). +parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _) :- ( Rest = ['--dump-file', DumpFile|_] -> service_dump_bootstrap(ServiceId, DumpFile) ; service_dump_bootstrap(ServiceId, '') ). -parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, ServiceType, _) :- - parse_service_args(Rest, Name, Ports, Bootstrap, ServiceType, create). -parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, ServiceType, Action) :- - parse_service_args(Rest, Name, PortsList, Bootstrap, ServiceType, Action). -parse_service_args(['--bootstrap', BootstrapFile|Rest], Name, Ports, _, ServiceType, Action) :- - parse_service_args(Rest, Name, Ports, BootstrapFile, ServiceType, Action). -parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, _, Action) :- - parse_service_args(Rest, Name, Ports, Bootstrap, Type, Action). -parse_service_args([_|Rest], Name, Ports, Bootstrap, ServiceType, Action) :- - parse_service_args(Rest, Name, Ports, Bootstrap, ServiceType, Action). +parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, _, InputFilesOut) :- + parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFilesOut). +parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- + parse_service_args(Rest, Name, PortsList, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). +parse_service_args(['--bootstrap', BootstrapVal|Rest], Name, Ports, _, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- + parse_service_args(Rest, Name, Ports, BootstrapVal, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). +parse_service_args(['--bootstrap-file', BootstrapFileVal|Rest], Name, Ports, Bootstrap, _, ServiceType, InputFiles, Action, InputFilesOut) :- + parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFileVal, ServiceType, InputFiles, Action, InputFilesOut). +parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, BootstrapFile, _, InputFiles, Action, InputFilesOut) :- + parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, Type, InputFiles, Action, InputFilesOut). +parse_service_args(['-f', FilePath|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- + ( exists_file(FilePath) + -> append(InputFiles, [FilePath], NewInputFiles), + parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, NewInputFiles, Action, InputFilesOut) + ; format(user_error, 'Error: File not found: ~w~n', [FilePath]), + halt(1) + ). +parse_service_args([_|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :- + parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut). % Execute service action (not used, but kept for structure) -execute_service_action(_). +execute_service_action(_, _). % Main program main(Argv) :- diff --git a/un.ps1 b/un.ps1 index 5497e71..3538602 100644 --- a/un.ps1 +++ b/un.ps1 @@ -193,8 +193,32 @@ function Invoke-Session { $shell = $Args[$idx + 1] } - $payload = @{ shell = $shell } | ConvertTo-Json - $result = Invoke-Api -Endpoint "/sessions" -Method "POST" -Body $payload + # Parse input files + $inputFiles = @() + for ($i = 0; $i -lt $Args.Count; $i++) { + if ($Args[$i] -eq "-f" -and ($i + 1) -lt $Args.Count) { + $filepath = $Args[$i + 1] + if (-not (Test-Path $filepath)) { + Write-Error "Error: Input file not found: $filepath" + exit 1 + } + $content = [System.IO.File]::ReadAllBytes($filepath) + $b64Content = [Convert]::ToBase64String($content) + $inputFiles += @{ + filename = [System.IO.Path]::GetFileName($filepath) + content_base64 = $b64Content + } + $i++ + } + } + + $payload = @{ shell = $shell } + if ($inputFiles.Count -gt 0) { + $payload["input_files"] = $inputFiles + } + + $body = $payload | ConvertTo-Json -Depth 10 + $result = Invoke-Api -Endpoint "/sessions" -Method "POST" -Body $body Write-Host "`e[33mSession created (WebSocket required for interactive)`e[0m" $result | ConvertTo-Json -Depth 5 } @@ -353,12 +377,45 @@ function Invoke-Service { $payload["bootstrap"] = $Args[$bIdx + 1] } + if ($Args -contains "--bootstrap-file") { + $bfIdx = [array]::IndexOf($Args, "--bootstrap-file") + $bootstrapFile = $Args[$bfIdx + 1] + if (Test-Path $bootstrapFile) { + $payload["bootstrap_content"] = Get-Content -Raw $bootstrapFile + } else { + Write-Error "Error: Bootstrap file not found: $bootstrapFile" + exit 1 + } + } + if ($Args -contains "--type") { $tIdx = [array]::IndexOf($Args, "--type") $payload["service_type"] = $Args[$tIdx + 1] } - $body = $payload | ConvertTo-Json -Depth 5 + # Parse input files + $inputFiles = @() + for ($i = 0; $i -lt $Args.Count; $i++) { + if ($Args[$i] -eq "-f" -and ($i + 1) -lt $Args.Count) { + $filepath = $Args[$i + 1] + if (-not (Test-Path $filepath)) { + Write-Error "Error: Input file not found: $filepath" + exit 1 + } + $content = [System.IO.File]::ReadAllBytes($filepath) + $b64Content = [Convert]::ToBase64String($content) + $inputFiles += @{ + filename = [System.IO.Path]::GetFileName($filepath) + content_base64 = $b64Content + } + $i++ + } + } + if ($inputFiles.Count -gt 0) { + $payload["input_files"] = $inputFiles + } + + $body = $payload | ConvertTo-Json -Depth 10 $result = Invoke-Api -Endpoint "/services" -Method "POST" -Body $body Write-Host "`e[32mService created`e[0m" $result | ConvertTo-Json -Depth 5 @@ -385,12 +442,14 @@ Session options: --list, -l List sessions --kill ID Terminate session --shell NAME Shell/REPL to use + -f FILE Input file (can be repeated) Service options: --name NAME Service name --ports PORTS Comma-separated ports --type TYPE Service type (minecraft, mumble, teamspeak, source, tcp, udp) --bootstrap CMD Bootstrap command + -f FILE Input file (can be repeated) --list, -l List services --info ID Get service info --logs ID Get logs diff --git a/un.py b/un.py index 1c696d0..706f9fd 100644 --- a/un.py +++ b/un.py @@ -316,6 +316,23 @@ def cmd_session(args): if args.audit: payload["audit"] = True + # Add input files + if args.files: + input_files = [] + for filepath in args.files: + try: + with open(filepath, 'rb') as f: + content = base64.b64encode(f.read()).decode('utf-8') + input_files.append({ + "filename": os.path.basename(filepath), + "content_base64": content + }) + except FileNotFoundError: + print(f"{RED}Error: Input file not found: {filepath}{RESET}", file=sys.stderr) + sys.exit(1) + if input_files: + payload["input_files"] = input_files + print(f"{YELLOW}Creating session...{RESET}") result = api_request("/sessions", method="POST", data=payload, public_key=public_key, secret_key=secret_key) print(f"{GREEN}Session created: {result.get('id', 'N/A')}{RESET}") @@ -494,12 +511,28 @@ def cmd_service(args): if args.service_type: payload["service_type"] = args.service_type if args.bootstrap: - # Check if bootstrap is a file - if os.path.exists(args.bootstrap): - with open(args.bootstrap, 'r') as f: - payload["bootstrap"] = f.read() - else: - payload["bootstrap"] = args.bootstrap + payload["bootstrap"] = args.bootstrap + if args.bootstrap_file: + if not os.path.exists(args.bootstrap_file): + print(f"{RED}Error: Bootstrap file not found: {args.bootstrap_file}{RESET}", file=sys.stderr) + sys.exit(1) + with open(args.bootstrap_file, 'r') as f: + payload["bootstrap_content"] = f.read() + if args.files: + input_files = [] + for filepath in args.files: + try: + with open(filepath, 'rb') as f: + content = base64.b64encode(f.read()).decode('utf-8') + input_files.append({ + "filename": os.path.basename(filepath), + "content_base64": content + }) + except FileNotFoundError: + print(f"{RED}Error: Input file not found: {filepath}{RESET}", file=sys.stderr) + sys.exit(1) + if input_files: + payload["input_files"] = input_files if args.network: payload["network"] = args.network if args.vcpu: @@ -530,6 +563,7 @@ Examples: %(prog)s session --shell python3 Python REPL %(prog)s session --list List active sessions %(prog)s service --name web --ports 80 --bootstrap "python -m http.server" + %(prog)s service --name app --ports 8000 --bootstrap-file ./setup.sh %(prog)s service --list List all services """ ) @@ -555,6 +589,7 @@ Examples: session_parser.add_argument("--audit", action="store_true", help="Record session") session_parser.add_argument("--tmux", action="store_true", help="Enable tmux persistence") session_parser.add_argument("--screen", action="store_true", help="Enable screen persistence") + session_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file") session_parser.add_argument("-n", "--network", choices=["zerotrust", "semitrusted"]) session_parser.add_argument("-v", "--vcpu", type=int, choices=range(1, 9)) session_parser.add_argument("-k", "--api-key") @@ -565,7 +600,9 @@ Examples: service_parser.add_argument("--ports", help="Comma-separated ports") service_parser.add_argument("--domains", help="Comma-separated custom domains") service_parser.add_argument("--type", dest="service_type", help="Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)") - service_parser.add_argument("--bootstrap", help="Bootstrap command/file") + service_parser.add_argument("--bootstrap", help="Bootstrap command or URI") + service_parser.add_argument("--bootstrap-file", dest="bootstrap_file", help="Upload local file as bootstrap script") + service_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file") service_parser.add_argument("-l", "--list", action="store_true", help="List services") service_parser.add_argument("--info", metavar="ID", help="Get service details") service_parser.add_argument("--tail", metavar="ID", help="Get last 9000 lines of logs") diff --git a/un.r b/un.r index e3a945f..3ed15b8 100644 --- a/un.r +++ b/un.r @@ -281,8 +281,36 @@ cmd_session <- function(args) { return() } - cat(sprintf("%sError: Use --list or --kill%s\n", RED, RESET), file = stderr()) - quit(status = 1) + # Create new session + payload <- list(shell = "bash") + + if (!is.null(args$network)) { + payload$network <- args$network + } + + # Add input files + if (!is.null(args$files)) { + input_files <- list() + for (filepath in args$files) { + if (!file.exists(filepath)) { + cat(sprintf("%sError: Input file not found: %s%s\n", RED, filepath, RESET), file = stderr()) + quit(status = 1) + } + content <- base64enc::base64encode(filepath) + input_files[[length(input_files) + 1]] <- list( + filename = basename(filepath), + content_base64 = content + ) + } + if (length(input_files) > 0) { + payload$input_files <- input_files + } + } + + cat(sprintf("%sCreating session...%s\n", YELLOW, RESET)) + result <- api_request("/sessions", public_key, secret_key, method = "POST", data = payload) + cat(sprintf("%sSession created: %s%s\n", GREEN, if (!is.null(result$id)) result$id else "N/A", RESET)) + cat(sprintf("%s(Interactive sessions require WebSocket - use un2 for full support)%s\n", YELLOW, RESET)) } cmd_key <- function(args) { @@ -485,10 +513,15 @@ cmd_service <- function(args) { } if (!is.null(args$bootstrap)) { - if (file.exists(args$bootstrap)) { - payload$bootstrap <- paste(readLines(args$bootstrap, warn = FALSE), collapse = "\n") + payload$bootstrap <- args$bootstrap + } + + if (!is.null(args$bootstrap_file)) { + if (file.exists(args$bootstrap_file)) { + payload$bootstrap_content <- paste(readLines(args$bootstrap_file, warn = FALSE), collapse = "\n") } else { - payload$bootstrap <- args$bootstrap + cat(sprintf("%sError: Bootstrap file not found: %s%s\n", RED, args$bootstrap_file, RESET), file = stderr()) + quit(status = 1) } } @@ -500,6 +533,25 @@ cmd_service <- function(args) { payload$vcpu <- args$vcpu } + # Add input files + if (!is.null(args$files)) { + input_files <- list() + for (filepath in args$files) { + if (!file.exists(filepath)) { + cat(sprintf("%sError: Input file not found: %s%s\n", RED, filepath, RESET), file = stderr()) + quit(status = 1) + } + content <- base64enc::base64encode(filepath) + input_files[[length(input_files) + 1]] <- list( + filename = basename(filepath), + content_base64 = content + ) + } + if (length(input_files) > 0) { + payload$input_files <- input_files + } + } + result <- api_request("/services", public_key, secret_key, method = "POST", data = payload) cat(sprintf("%sService created: %s%s\n", GREEN, if (!is.null(result$id)) result$id else "N/A", RESET)) cat(sprintf("Name: %s\n", if (!is.null(result$name)) result$name else "N/A")) @@ -539,6 +591,7 @@ parse_args <- function() { domains = NULL, type = NULL, bootstrap = NULL, + bootstrap_file = NULL, vcpu = NULL, extend = FALSE ) @@ -634,6 +687,10 @@ parse_args <- function() { i <- i + 1 result$bootstrap <- args[i] i <- i + 1 + } else if (arg == "--bootstrap-file") { + i <- i + 1 + result$bootstrap_file <- args[i] + i <- i + 1 } else if (arg %in% c("-v", "--vcpu")) { i <- i + 1 result$vcpu <- as.integer(args[i]) diff --git a/un.raku b/un.raku index b50f80b..cd2082c 100644 --- a/un.raku +++ b/un.raku @@ -283,6 +283,7 @@ sub cmd-session(@args) { my $shell = ''; my $network = ''; my $vcpu = 0; + my @input-files; # Parse arguments my $i = 0; @@ -307,6 +308,10 @@ sub cmd-session(@args) { $i++; $vcpu = @args[$i].Int; } + when '-f' { + $i++; + @input-files.push(@args[$i]); + } } $i++; } @@ -337,6 +342,23 @@ sub cmd-session(@args) { %payload = $network if $network; %payload = $vcpu if $vcpu > 0; + # Add input files + if @input-files { + my @files; + for @input-files -> $filepath { + unless $filepath.IO.e { + note "{$RED}Error: Input file not found: $filepath{$RESET}"; + exit 1; + } + my $content = $filepath.IO.slurp(:bin); + @files.push({ + filename => $filepath.IO.basename, + content_base64 => $content.encode('latin1').decode('latin1').encode.base64 + }); + } + %payload = @files; + } + say "{$YELLOW}Creating session...{$RESET}"; my %result = api-request('/sessions', 'POST', %payload, :$public-key, :$secret-key); say "{$GREEN}Session created: {%result}{$RESET}"; @@ -357,8 +379,10 @@ sub cmd-service(@args) { my $ports = ''; my $type = ''; my $bootstrap = ''; + my $bootstrap-file = ''; my $network = ''; my $vcpu = 0; + my @input-files; # Parse arguments my $i = 0; @@ -411,6 +435,10 @@ sub cmd-service(@args) { $i++; $bootstrap = @args[$i]; } + when '--bootstrap-file' { + $i++; + $bootstrap-file = @args[$i]; + } when '-n' { $i++; $network = @args[$i]; @@ -419,6 +447,10 @@ sub cmd-service(@args) { $i++; $vcpu = @args[$i].Int; } + when '-f' { + $i++; + @input-files.push(@args[$i]); + } } $i++; } @@ -506,17 +538,38 @@ sub cmd-service(@args) { } if $bootstrap { - # Check if bootstrap is a file - if $bootstrap.IO.e && $bootstrap.IO.f { - %payload = $bootstrap.IO.slurp; + %payload = $bootstrap; + } + + if $bootstrap-file { + if $bootstrap-file.IO.e && $bootstrap-file.IO.f { + %payload = $bootstrap-file.IO.slurp; } else { - %payload = $bootstrap; + note "{$RED}Error: Bootstrap file not found: $bootstrap-file{$RESET}"; + exit 1; } } %payload = $network if $network; %payload = $vcpu if $vcpu > 0; + # Add input files + if @input-files { + my @files; + for @input-files -> $filepath { + unless $filepath.IO.e { + note "{$RED}Error: Input file not found: $filepath{$RESET}"; + exit 1; + } + my $content = $filepath.IO.slurp(:bin); + @files.push({ + filename => $filepath.IO.basename, + content_base64 => $content.encode('latin1').decode('latin1').encode.base64 + }); + } + %payload = @files; + } + my %result = api-request('/services', 'POST', %payload, :$public-key, :$secret-key); say "{$GREEN}Service created: {%result}{$RESET}"; say "Name: {%result}"; diff --git a/un.rb b/un.rb index cce2af3..048656c 100644 --- a/un.rb +++ b/un.rb @@ -268,6 +268,21 @@ def cmd_session(options) payload[:persistence] = 'screen' if options[:screen] payload[:audit] = true if options[:audit] + # Add input files + if options[:files] && !options[:files].empty? + input_files = options[:files].map do |filepath| + unless File.exist?(filepath) + warn "#{RED}Error: Input file not found: #{filepath}#{RESET}" + exit 1 + end + { + filename: File.basename(filepath), + content_base64: Base64.strict_encode64(File.read(filepath, mode: 'rb')) + } + end + payload[:input_files] = input_files + end + puts "#{YELLOW}Creating session...#{RESET}" result = api_request('/sessions', method: 'POST', data: payload, keys: keys) puts "#{GREEN}Session created: #{result['id'] || 'N/A'}#{RESET}" @@ -461,12 +476,27 @@ def cmd_service(options) payload[:ports] = options[:ports].split(',').map(&:to_i) if options[:ports] payload[:domains] = options[:domains].split(',') if options[:domains] payload[:service_type] = options[:type] if options[:type] - if options[:bootstrap] - payload[:bootstrap] = if File.exist?(options[:bootstrap]) - File.read(options[:bootstrap]) - else - options[:bootstrap] - end + payload[:bootstrap] = options[:bootstrap] if options[:bootstrap] + if options[:bootstrap_file] + unless File.exist?(options[:bootstrap_file]) + warn "#{RED}Error: Bootstrap file not found: #{options[:bootstrap_file]}#{RESET}" + exit 1 + end + payload[:bootstrap_content] = File.read(options[:bootstrap_file]) + end + # Add input files + if options[:files] && !options[:files].empty? + input_files = options[:files].map do |filepath| + unless File.exist?(filepath) + warn "#{RED}Error: Input file not found: #{filepath}#{RESET}" + exit 1 + end + { + filename: File.basename(filepath), + content_base64: Base64.strict_encode64(File.read(filepath, mode: 'rb')) + } + end + payload[:input_files] = input_files end payload[:network] = options[:network] if options[:network] payload[:vcpu] = options[:vcpu] if options[:vcpu] @@ -577,6 +607,9 @@ def main when '--bootstrap' i += 1 options[:bootstrap] = ARGV[i] + when '--bootstrap-file' + i += 1 + options[:bootstrap_file] = ARGV[i] when '--info' i += 1 options[:info] = ARGV[i] @@ -659,7 +692,8 @@ def main --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type (minecraft|mumble|teamspeak|source|tcp|udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.rs b/un.rs index 22f6eb1..f7830ba 100644 --- a/un.rs +++ b/un.rs @@ -348,6 +348,7 @@ fn cmd_session( vcpu: Option, tmux: bool, screen: bool, + files: &[String], public_key: &str, secret_key: &str, ) { @@ -381,6 +382,25 @@ fn cmd_session( if screen { json.push_str(r#","persistence":"screen""#); } + + // Input files + if !files.is_empty() { + json.push_str(r#","input_files":["#); + for (i, f) in files.iter().enumerate() { + if i > 0 { + json.push(','); + } + let content = fs::read(f).unwrap_or_else(|e| { + eprintln!("{}Error reading input file {}: {}{}", RED, f, e, RESET); + process::exit(1); + }); + let b64 = base64::encode(&content); + let filename = Path::new(f).file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); + json.push_str(&format!(r#"{{"filename":"{}","content_base64":"{}"}}"#, filename, b64)); + } + json.push(']'); + } + json.push('}'); println!("{}Creating session...{}", YELLOW, RESET); @@ -395,6 +415,8 @@ fn cmd_service( domains: Option<&str>, service_type: Option<&str>, bootstrap: Option<&str>, + bootstrap_file: Option<&str>, + files: &[String], list: bool, info: Option<&str>, logs: Option<&str>, @@ -533,12 +555,38 @@ fn cmd_service( } if let Some(b) = bootstrap { - let cmd = if Path::new(b).exists() { - fs::read_to_string(b).unwrap_or(b.to_string()) + json.push_str(&format!(r#","bootstrap":"{}""#, escape_json(b))); + } + + if let Some(bf) = bootstrap_file { + if Path::new(bf).exists() { + let content = fs::read_to_string(bf).unwrap_or_else(|e| { + eprintln!("{}Error reading bootstrap file: {}{}", RED, e, RESET); + process::exit(1); + }); + json.push_str(&format!(r#","bootstrap_content":"{}""#, escape_json(&content))); } else { - b.to_string() - }; - json.push_str(&format!(r#","bootstrap":"{}""#, escape_json(&cmd))); + eprintln!("{}Error: Bootstrap file not found: {}{}", RED, bf, RESET); + process::exit(1); + } + } + + // Input files + if !files.is_empty() { + json.push_str(r#","input_files":["#); + for (i, f) in files.iter().enumerate() { + if i > 0 { + json.push(','); + } + let content = fs::read(f).unwrap_or_else(|e| { + eprintln!("{}Error reading input file {}: {}{}", RED, f, e, RESET); + process::exit(1); + }); + let b64 = base64::encode(&content); + let filename = Path::new(f).file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); + json.push_str(&format!(r#"{{"filename":"{}","content_base64":"{}"}}"#, filename, b64)); + } + json.push(']'); } if let Some(net) = network { @@ -703,6 +751,17 @@ fn main() { } "session" => { let (public_key, secret_key) = get_api_keys(api_key.as_deref()); + // Collect -f files for session + let mut session_files: Vec = Vec::new(); + let mut j = i + 1; + while j < args.len() { + if args[j] == "-f" && j + 1 < args.len() { + session_files.push(args[j + 1].clone()); + j += 2; + } else { + j += 1; + } + } cmd_session( args.contains(&"--list".to_string()), args.iter().position(|x| x == "--kill").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), @@ -711,6 +770,7 @@ fn main() { vcpu, args.contains(&"--tmux".to_string()), args.contains(&"--screen".to_string()), + &session_files, &public_key, &secret_key, ); @@ -718,12 +778,25 @@ fn main() { } "service" => { let (public_key, secret_key) = get_api_keys(api_key.as_deref()); + // Collect -f files for service + let mut service_files: Vec = Vec::new(); + let mut j = i + 1; + while j < args.len() { + if args[j] == "-f" && j + 1 < args.len() { + service_files.push(args[j + 1].clone()); + j += 2; + } else { + j += 1; + } + } cmd_service( args.iter().position(|x| x == "--name").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--ports").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--domains").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--type").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--bootstrap").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), + args.iter().position(|x| x == "--bootstrap-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), + &service_files, args.contains(&"--list".to_string()), args.iter().position(|x| x == "--info").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--logs").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), diff --git a/un.scm b/un.scm index 39ab8cd..5cd7d45 100644 --- a/un.scm +++ b/un.scm @@ -93,6 +93,30 @@ (string-join (reverse lines) "\n") (loop (cons line lines)))))))) +(define (base64-encode-file filename) + "Base64 encode a file using shell command" + (let* ((cmd (format #f "base64 -w0 ~a" filename)) + (port (open-input-pipe cmd)) + (result (let loop ((chars '())) + (let ((char (read-char port))) + (if (eof-object? char) + (list->string (reverse chars)) + (loop (cons char chars))))))) + (close-pipe port) + (string-trim-both result))) + +(define (build-input-files-json files) + "Build input_files JSON array from list of filenames" + (if (null? files) + "" + (let ((entries (map (lambda (f) + (let* ((basename (basename f)) + (content (base64-encode-file f))) + (format #f "{\"filename\":\"~a\",\"content\":\"~a\"}" + basename content))) + files))) + (format #f ",\"input_files\":[~a]" (string-join entries ","))))) + (define (write-temp-file data) (let ((tmp-file (format #f "/tmp/un_scm_~a.json" (random 999999)))) (call-with-output-file tmp-file @@ -340,7 +364,7 @@ (display response) (newline)))) -(define (session-cmd action id shell) +(define (session-cmd action id shell input-files) (let ((api-key (get-api-key))) (cond ((equal? action "list") @@ -351,13 +375,14 @@ (format #t "~aSession terminated: ~a~a\n" green id reset)) (else (let* ((sh (or shell "bash")) - (json (format #f "{\"shell\":\"~a\"}" sh)) + (input-files-json (build-input-files-json input-files)) + (json (format #f "{\"shell\":\"~a\"~a}" sh input-files-json)) (response (curl-post api-key "/sessions" json))) (format #t "~aSession created (WebSocket required)~a\n" yellow reset) (display response) (newline)))))) -(define (service-cmd action id name ports bootstrap type) +(define (service-cmd action id name ports bootstrap bootstrap-file type input-files) (let ((api-key (get-api-key))) (cond ((equal? action "list") @@ -405,8 +430,12 @@ ((and (equal? action "create") name) (let* ((ports-json (if ports (format #f ",\"ports\":[~a]" ports) "")) (bootstrap-json (if bootstrap (format #f ",\"bootstrap\":\"~a\"" (escape-json bootstrap)) "")) + (bootstrap-content-json (if bootstrap-file + (format #f ",\"bootstrap_content\":\"~a\"" (escape-json (read-file bootstrap-file))) + "")) (type-json (if type (format #f ",\"service_type\":\"~a\"" type) "")) - (json (format #f "{\"name\":\"~a\"~a~a~a}" name ports-json bootstrap-json type-json)) + (input-files-json (build-input-files-json input-files)) + (json (format #f "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json)) (response (curl-post api-key "/services" json))) (format #t "~aService created~a\n" green reset) (display response) @@ -415,6 +444,20 @@ (display "Error: --name required to create service\n" (current-error-port)) (exit 1))))) +(define (parse-input-files args) + "Parse -f flags from args and return list of filenames" + (let loop ((args args) (files '())) + (if (null? args) + (reverse files) + (if (and (equal? (car args) "-f") (pair? (cdr args))) + (let ((file (cadr args))) + (if (file-exists? file) + (loop (cddr args) (cons file files)) + (begin + (format (current-error-port) "Error: File not found: ~a\n" file) + (exit 1)))) + (loop (cdr args) files))))) + (define (main args) (if (null? args) (begin @@ -429,39 +472,69 @@ (validate-key-cmd extend))) ((equal? (car args) "session") (if (and (> (length args) 1) (equal? (cadr args) "--list")) - (session-cmd "list" #f #f) + (session-cmd "list" #f #f '()) (if (and (> (length args) 2) (equal? (cadr args) "--kill")) - (session-cmd "kill" (caddr args) #f) - (session-cmd "create" #f #f)))) + (session-cmd "kill" (caddr args) #f '()) + ;; Parse session create options including -f + (let* ((rest-args (cdr args)) + (input-files (parse-input-files rest-args)) + (shell #f)) + ;; Parse --shell option + (let loop ((args rest-args)) + (when (and (pair? args) (pair? (cdr args))) + (cond + ((or (equal? (car args) "--shell") (equal? (car args) "-s")) + (set! shell (cadr args))) + (else (loop (cdr args)))))) + (session-cmd "create" #f shell input-files))))) ((equal? (car args) "service") (cond ((and (> (length args) 1) (equal? (cadr args) "--list")) - (service-cmd "list" #f #f #f #f #f)) + (service-cmd "list" #f #f #f #f #f #f '())) ((and (> (length args) 2) (equal? (cadr args) "--info")) - (service-cmd "info" (caddr args) #f #f #f #f)) + (service-cmd "info" (caddr args) #f #f #f #f #f '())) ((and (> (length args) 2) (equal? (cadr args) "--logs")) - (service-cmd "logs" (caddr args) #f #f #f #f)) + (service-cmd "logs" (caddr args) #f #f #f #f #f '())) ((and (> (length args) 2) (equal? (cadr args) "--freeze")) - (service-cmd "sleep" (caddr args) #f #f #f #f)) + (service-cmd "sleep" (caddr args) #f #f #f #f #f '())) ((and (> (length args) 2) (equal? (cadr args) "--unfreeze")) - (service-cmd "wake" (caddr args) #f #f #f #f)) + (service-cmd "wake" (caddr args) #f #f #f #f #f '())) ((and (> (length args) 2) (equal? (cadr args) "--destroy")) - (service-cmd "destroy" (caddr args) #f #f #f #f)) + (service-cmd "destroy" (caddr args) #f #f #f #f #f '())) ((and (> (length args) 3) (equal? (cadr args) "--execute")) - (service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f)) + (service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '())) ((and (> (length args) 3) (equal? (cadr args) "--dump-bootstrap")) - (service-cmd "dump-bootstrap" (caddr args) #f #f #f (list-ref args 3))) + (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '())) ((and (> (length args) 2) (equal? (cadr args) "--dump-bootstrap")) - (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f)) + (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '())) ((and (> (length args) 2) (equal? (cadr args) "--name")) - (let ((name (caddr args)) - (ports (if (and (> (length args) 4) (equal? (list-ref args 3) "--ports")) - (list-ref args 4) #f)) - (bootstrap (if (and (> (length args) 6) (equal? (list-ref args 5) "--bootstrap")) - (list-ref args 6) #f)) - (type (if (and (> (length args) 8) (equal? (list-ref args 7) "--type")) - (list-ref args 8) #f))) - (service-cmd "create" #f name ports bootstrap type))) + (let* ((name (caddr args)) + (rest-args (cdddr args)) + (ports #f) + (bootstrap #f) + (bootstrap-file #f) + (type #f) + (input-files (parse-input-files rest-args))) + ;; Parse remaining args + (let loop ((args rest-args)) + (when (and (pair? args) (pair? (cdr args))) + (cond + ((equal? (car args) "--ports") + (set! ports (cadr args)) + (loop (cddr args))) + ((equal? (car args) "--bootstrap") + (set! bootstrap (cadr args)) + (loop (cddr args))) + ((equal? (car args) "--bootstrap-file") + (set! bootstrap-file (cadr args)) + (loop (cddr args))) + ((equal? (car args) "--type") + (set! type (cadr args)) + (loop (cddr args))) + ((equal? (car args) "-f") + (loop (cddr args))) ; skip -f, already parsed + (else (loop (cdr args)))))) + (service-cmd "create" #f name ports bootstrap bootstrap-file type input-files))) (else (display "Error: Invalid service command\n" (current-error-port)) (exit 1)))) diff --git a/un.sh b/un.sh index 2e37c02..8d20caa 100755 --- a/un.sh +++ b/un.sh @@ -343,6 +343,7 @@ cmd_session() { local network="" local vcpu="" local api_key="${UNSANDBOX_API_KEY:-}" + local -a input_files=() while [[ $# -gt 0 ]]; do case "$1" in @@ -374,6 +375,10 @@ cmd_session() { screen=true shift ;; + -f) + input_files+=("$2") + shift 2 + ;; -n) network="$2" shift 2 @@ -427,6 +432,22 @@ cmd_session() { [[ "$screen" == true ]] && payload=$(echo "$payload" | jq '. + {persistence: "screen"}') [[ "$audit" == true ]] && payload=$(echo "$payload" | jq '. + {audit: true}') + # Add input files + if [[ ${#input_files[@]} -gt 0 ]]; then + local files_json="[" + for file in "${input_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo -e "${RED}Error: Input file not found: $file${RESET}" >&2 + exit 1 + fi + local filename=$(basename "$file") + local content_b64=$(base64 -w0 < "$file") + files_json+="{\"filename\":\"$filename\",\"content_base64\":\"$content_b64\"}," + done + files_json="${files_json%,}]" + payload=$(echo "$payload" | jq --argjson files "$files_json" '. + {input_files: $files}') + fi + echo -e "${YELLOW}Creating session...${RESET}" local result=$(api_request "/sessions" "POST" "$payload" "$api_key") local session_id=$(echo "$result" | jq -r '.id // "N/A"') @@ -440,6 +461,7 @@ cmd_service() { local domains="" local service_type="" local bootstrap="" + local bootstrap_file="" local list=false local info="" local logs="" @@ -452,6 +474,7 @@ cmd_service() { local network="" local vcpu="" local api_key="${UNSANDBOX_API_KEY:-}" + local -a input_files=() while [[ $# -gt 0 ]]; do case "$1" in @@ -475,6 +498,14 @@ cmd_service() { bootstrap="$2" shift 2 ;; + --bootstrap-file) + bootstrap_file="$2" + shift 2 + ;; + -f) + input_files+=("$2") + shift 2 + ;; -l|--list) list=true shift @@ -639,12 +670,32 @@ cmd_service() { fi if [[ -n "$bootstrap" ]]; then - if [[ -f "$bootstrap" ]]; then - local bootstrap_content=$(cat "$bootstrap") - payload=$(echo "$payload" | jq --arg b "$bootstrap_content" '. + {bootstrap: $b}') - else - payload=$(echo "$payload" | jq --arg b "$bootstrap" '. + {bootstrap: $b}') + payload=$(echo "$payload" | jq --arg b "$bootstrap" '. + {bootstrap: $b}') + fi + + if [[ -n "$bootstrap_file" ]]; then + if [[ ! -f "$bootstrap_file" ]]; then + echo -e "${RED}Error: Bootstrap file not found: $bootstrap_file${RESET}" >&2 + return 1 fi + local file_content=$(cat "$bootstrap_file") + payload=$(echo "$payload" | jq --arg b "$file_content" '. + {bootstrap_content: $b}') + fi + + # Add input files + if [[ ${#input_files[@]} -gt 0 ]]; then + local files_json="[" + for file in "${input_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo -e "${RED}Error: Input file not found: $file${RESET}" >&2 + exit 1 + fi + local filename=$(basename "$file") + local content_b64=$(base64 -w0 < "$file") + files_json+="{\"filename\":\"$filename\",\"content_base64\":\"$content_b64\"}," + done + files_json="${files_json%,}]" + payload=$(echo "$payload" | jq --argjson files "$files_json" '. + {input_files: $files}') fi [[ -n "$network" ]] && payload=$(echo "$payload" | jq --arg n "$network" '. + {network: $n}') @@ -842,7 +893,8 @@ Service options: --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.tcl b/un.tcl index 3176936..b121386 100755 --- a/un.tcl +++ b/un.tcl @@ -319,6 +319,7 @@ proc cmd_session {args} { set shell "" set network "" set vcpu 0 + set input_files [list] # Parse arguments for {set i 0} {$i < [llength $args]} {incr i} { @@ -343,6 +344,10 @@ proc cmd_session {args} { incr i set vcpu [lindex $args $i] } + -f { + incr i + lappend input_files [lindex $args $i] + } } } @@ -384,6 +389,25 @@ proc cmd_session {args} { lappend payload vcpu $vcpu } + # Add input files + if {[llength $input_files] > 0} { + set files_json [list] + foreach filepath $input_files { + if {![file exists $filepath]} { + puts stderr "${::RED}Error: Input file not found: $filepath${::RESET}" + exit 1 + } + set fp [open $filepath rb] + set content [read $fp] + close $fp + set b64_content [::base64::encode $content] + lappend files_json [::json::write object \ + filename [::json::write string [file tail $filepath]] \ + content_base64 [::json::write string $b64_content]] + } + lappend payload input_files [::json::write array {*}$files_json] + } + puts "${::YELLOW}Creating session...${::RESET}" set result [api_request "/sessions" "POST" $payload $public_key $secret_key] puts "${::GREEN}Session created: [dict get $result id]${::RESET}" @@ -498,8 +522,10 @@ proc cmd_service {args} { set ports "" set service_type "" set bootstrap "" + set bootstrap_file "" set network "" set vcpu 0 + set input_files [list] # Parse arguments for {set i 0} {$i < [llength $args]} {incr i} { @@ -552,6 +578,10 @@ proc cmd_service {args} { incr i set bootstrap [lindex $args $i] } + --bootstrap-file { + incr i + set bootstrap_file [lindex $args $i] + } -n { incr i set network [lindex $args $i] @@ -560,6 +590,10 @@ proc cmd_service {args} { incr i set vcpu [lindex $args $i] } + -f { + incr i + lappend input_files [lindex $args $i] + } } } @@ -657,14 +691,18 @@ proc cmd_service {args} { } if {$bootstrap ne ""} { - # Check if bootstrap is a file - if {[file exists $bootstrap]} { - set fp [open $bootstrap r] + lappend payload bootstrap [::json::write string $bootstrap] + } + + if {$bootstrap_file ne ""} { + if {[file exists $bootstrap_file]} { + set fp [open $bootstrap_file r] set bootstrap_content [read $fp] close $fp - lappend payload bootstrap [::json::write string $bootstrap_content] + lappend payload bootstrap_content [::json::write string $bootstrap_content] } else { - lappend payload bootstrap [::json::write string $bootstrap] + puts stderr "${::RED}Error: Bootstrap file not found: $bootstrap_file${::RESET}" + exit 1 } } @@ -675,6 +713,25 @@ proc cmd_service {args} { lappend payload vcpu $vcpu } + # Add input files + if {[llength $input_files] > 0} { + set files_json [list] + foreach filepath $input_files { + if {![file exists $filepath]} { + puts stderr "${::RED}Error: Input file not found: $filepath${::RESET}" + exit 1 + } + set fp [open $filepath rb] + set content [read $fp] + close $fp + set b64_content [::base64::encode $content] + lappend files_json [::json::write object \ + filename [::json::write string [file tail $filepath]] \ + content_base64 [::json::write string $b64_content]] + } + lappend payload input_files [::json::write array {*}$files_json] + } + set result [api_request "/services" "POST" $payload $public_key $secret_key] puts "${::GREEN}Service created: [dict get $result id]${::RESET}" puts "Name: [dict get $result name]" diff --git a/un.ts b/un.ts index e731e69..78f8a4a 100644 --- a/un.ts +++ b/un.ts @@ -102,6 +102,7 @@ interface Args { domains: string | null; type: string | null; bootstrap: string | null; + bootstrapFile: string | null; info: string | null; logs: string | null; tail: string | null; @@ -363,6 +364,22 @@ async function cmdSession(args: Args): Promise { if (args.screen) payload.persistence = "screen"; if (args.audit) payload.audit = true; + // Add input files + if (args.files && args.files.length > 0) { + payload.input_files = args.files.map(filepath => { + try { + const content = fs.readFileSync(filepath); + return { + filename: path.basename(filepath), + content_base64: content.toString('base64') + }; + } catch (e) { + console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); + process.exit(1); + } + }); + } + console.log(`${YELLOW}Creating session...${RESET}`); const result = await apiRequest("/sessions", "POST", payload, keys); console.log(`${GREEN}Session created: ${result.id || 'N/A'}${RESET}`); @@ -466,11 +483,29 @@ async function cmdService(args: Args): Promise { if (args.domains) payload.domains = args.domains.split(','); if (args.type) payload.service_type = args.type; if (args.bootstrap) { - if (fs.existsSync(args.bootstrap)) { - payload.bootstrap = fs.readFileSync(args.bootstrap, 'utf-8'); - } else { - payload.bootstrap = args.bootstrap; + payload.bootstrap = args.bootstrap; + } + if (args.bootstrapFile) { + if (!fs.existsSync(args.bootstrapFile)) { + console.error(`${RED}Error: Bootstrap file not found: ${args.bootstrapFile}${RESET}`); + process.exit(1); } + payload.bootstrap_content = fs.readFileSync(args.bootstrapFile, 'utf-8'); + } + // Add input files + if (args.files && args.files.length > 0) { + payload.input_files = args.files.map(filepath => { + try { + const content = fs.readFileSync(filepath); + return { + filename: path.basename(filepath), + content_base64: content.toString('base64') + }; + } catch (e) { + console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); + process.exit(1); + } + }); } if (args.network) payload.network = args.network; if (args.vcpu) payload.vcpu = args.vcpu; @@ -579,6 +614,7 @@ function parseArgs(argv: string[]): Args { domains: null, type: null, bootstrap: null, + bootstrapFile: null, info: null, logs: null, tail: null, @@ -656,6 +692,9 @@ function parseArgs(argv: string[]): Args { } else if (arg === '--bootstrap' && i + 1 < argv.length) { args.bootstrap = argv[++i]; i++; + } else if (arg === '--bootstrap-file' && i + 1 < argv.length) { + args.bootstrapFile = argv[++i]; + i++; } else if (arg === '--info' && i + 1 < argv.length) { args.info = argv[++i]; i++; @@ -744,7 +783,8 @@ Service options: --ports PORTS Comma-separated ports --domains DOMAINS Custom domains --type TYPE Service type (minecraft|mumble|teamspeak|source|tcp|udp) - --bootstrap CMD Bootstrap command/file + --bootstrap CMD Bootstrap command or URI + --bootstrap-file FILE Upload local file as bootstrap script -l, --list List services --info ID Get service details --logs ID Get all logs diff --git a/un.v b/un.v index a07b765..08bfe91 100644 --- a/un.v +++ b/un.v @@ -95,6 +95,25 @@ fn escape_json(s string) string { return result } +fn base64_encode_file(filename string) string { + cmd := "base64 -w0 '${filename}'" + result := os.execute(cmd) + return result.output.trim_space() +} + +fn build_input_files_json(files []string) string { + if files.len == 0 { + return '' + } + mut entries := []string{} + for f in files { + basename := os.file_name(f) + content := base64_encode_file(f) + entries << '{"filename":"${basename}","content":"${content}"}' + } + return ',"input_files":[' + entries.join(',') + ']' +} + fn exec_curl(cmd string) string { result := os.execute(cmd) output := result.output @@ -252,7 +271,7 @@ fn cmd_execute(source_file string, envs []string, artifacts bool, network string println(exec_curl(cmd)) } -fn cmd_session(list bool, kill string, shell string, network string, vcpu int, tmux bool, screen bool, api_key string) { +fn cmd_session(list bool, kill string, shell string, network string, vcpu int, tmux bool, screen bool, input_files []string, api_key string) { pub_key := get_public_key() secret_key := get_secret_key() @@ -283,6 +302,7 @@ fn cmd_session(list bool, kill string, shell string, network string, vcpu int, t if screen { json += ',"persistence":"screen"' } + json += build_input_files_json(input_files) json += '}' println('${yellow}Creating session...${reset}') @@ -290,7 +310,7 @@ fn cmd_session(list bool, kill string, shell string, network string, vcpu int, t println(exec_curl(cmd)) } -fn cmd_service(name string, ports string, service_type string, bootstrap string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, api_key string) { +fn cmd_service(name string, ports string, service_type string, bootstrap string, bootstrap_file string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, input_files []string, api_key string) { pub_key := get_public_key() secret_key := get_secret_key() @@ -389,11 +409,18 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, json += ',"service_type":"${service_type}"' } if bootstrap != '' { - if os.exists(bootstrap) { - boot_code := os.read_file(bootstrap) or { bootstrap } - json += ',"bootstrap":"${escape_json(boot_code)}"' + json += ',"bootstrap":"${escape_json(bootstrap)}"' + } + if bootstrap_file != '' { + if os.exists(bootstrap_file) { + boot_code := os.read_file(bootstrap_file) or { + eprintln('${red}Error: Could not read bootstrap file: ${bootstrap_file}${reset}') + exit(1) + } + json += ',"bootstrap_content":"${escape_json(boot_code)}"' } else { - json += ',"bootstrap":"${escape_json(bootstrap)}"' + eprintln('${red}Error: Bootstrap file not found: ${bootstrap_file}${reset}') + exit(1) } } if network != '' { @@ -402,6 +429,7 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, if vcpu > 0 { json += ',"vcpu":${vcpu}' } + json += build_input_files_json(input_files) json += '}' println('${yellow}Creating service...${reset}') @@ -459,6 +487,7 @@ fn main() { mut tmux := false mut screen := false + mut input_files := []string{} mut i := 2 for i < os.args.len { match os.args[i] { @@ -485,12 +514,22 @@ fn main() { i++ api_key = os.args[i] } + '-f' { + i++ + f := os.args[i] + if os.exists(f) { + input_files << f + } else { + eprintln('Error: File not found: ${f}') + exit(1) + } + } else {} } i++ } - cmd_session(list, kill, shell, network, vcpu, tmux, screen, api_key) + cmd_session(list, kill, shell, network, vcpu, tmux, screen, input_files, api_key) return } @@ -499,6 +538,7 @@ fn main() { mut ports := '' mut service_type := '' mut bootstrap := '' + mut bootstrap_file := '' mut list := false mut info := '' mut logs := '' @@ -512,6 +552,7 @@ fn main() { mut dump_file := '' mut network := '' mut vcpu := 0 + mut input_files := []string{} mut i := 2 for i < os.args.len { @@ -532,6 +573,10 @@ fn main() { i++ bootstrap = os.args[i] } + '--bootstrap-file' { + i++ + bootstrap_file = os.args[i] + } '--list' { list = true } '--info' { i++ @@ -585,13 +630,23 @@ fn main() { i++ api_key = os.args[i] } + '-f' { + i++ + f := os.args[i] + if os.exists(f) { + input_files << f + } else { + eprintln('Error: File not found: ${f}') + exit(1) + } + } else {} } i++ } - cmd_service(name, ports, service_type, bootstrap, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, - vcpu, api_key) + cmd_service(name, ports, service_type, bootstrap, bootstrap_file, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, + vcpu, input_files, api_key) return } diff --git a/un.zig b/un.zig index e336063..ed9e93c 100644 --- a/un.zig +++ b/un.zig @@ -101,6 +101,55 @@ fn buildAuthCmd(allocator: std.mem.Allocator, method: []const u8, path: []const return try std.fmt.allocPrint(allocator, "-H 'Authorization: Bearer {s}' -H 'X-Timestamp: {s}' -H 'X-Signature: {s}'", .{ public_key, timestamp_str, signature }); } +fn base64EncodeFile(allocator: std.mem.Allocator, filename: []const u8) ![]u8 { + const cmd = try std.fmt.allocPrint(allocator, "base64 -w0 '{s}'", .{filename}); + defer allocator.free(cmd); + + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &[_][]const u8{ "sh", "-c", cmd }, + }) catch return try allocator.dupe(u8, ""); + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + const trimmed = mem.trim(u8, result.stdout, &std.ascii.whitespace); + return try allocator.dupe(u8, trimmed); +} + +fn buildInputFilesJson(allocator: std.mem.Allocator, files: std.ArrayList([]const u8)) ![]u8 { + if (files.items.len == 0) { + return try allocator.dupe(u8, ""); + } + + var list = std.ArrayList(u8).init(allocator); + defer list.deinit(); + + try list.appendSlice(",\"input_files\":["); + + for (files.items, 0..) |file, i| { + if (i > 0) try list.append(','); + + // Get basename + var basename: []const u8 = file; + if (mem.lastIndexOfScalar(u8, file, '/')) |idx| { + basename = file[idx + 1 ..]; + } + + // Base64 encode file content + const content = try base64EncodeFile(allocator, file); + defer allocator.free(content); + + const entry = try std.fmt.allocPrint(allocator, "{{\"filename\":\"{s}\",\"content\":\"{s}\"}}", .{ basename, content }); + defer allocator.free(entry); + + try list.appendSlice(entry); + } + + try list.append(']'); + + return list.toOwnedSlice(); +} + pub fn main() !u8 { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -133,6 +182,8 @@ pub fn main() !u8 { var list = false; var kill: ?[]const u8 = null; var shell: ?[]const u8 = null; + var input_files = std.ArrayList([]const u8).init(allocator); + defer input_files.deinit(); var i: usize = 2; while (i < args.len) : (i += 1) { if (mem.eql(u8, args[i], "--list")) { @@ -147,6 +198,15 @@ pub fn main() !u8 { i += 1; allocator.free(public_key); public_key = try allocator.dupe(u8, args[i]); + } else if (mem.eql(u8, args[i], "-f") and i + 1 < args.len) { + i += 1; + const file = args[i]; + // Check if file exists + fs.cwd().access(file, .{}) catch { + std.debug.print("Error: File not found: {s}\n", .{file}); + return 1; + }; + try input_files.append(file); } } @@ -168,7 +228,9 @@ pub fn main() !u8 { std.debug.print("\x1b[32mSession terminated: {s}\x1b[0m\n", .{k}); } else { const sh = shell orelse "bash"; - const json = try std.fmt.allocPrint(allocator, "{{\"shell\":\"{s}\"}}", .{sh}); + const input_files_json = try buildInputFilesJson(allocator, input_files); + defer allocator.free(input_files_json); + const json = try std.fmt.allocPrint(allocator, "{{\"shell\":\"{s}\"{s}}}", .{ sh, input_files_json }); defer allocator.free(json); const auth_headers = try buildAuthCmd(allocator, "POST", "/sessions", json, public_key, secret_key); defer allocator.free(auth_headers); @@ -187,11 +249,15 @@ pub fn main() !u8 { var name: ?[]const u8 = null; var ports: ?[]const u8 = null; var service_type: ?[]const u8 = null; + var bootstrap: ?[]const u8 = null; + var bootstrap_file: ?[]const u8 = null; var info: ?[]const u8 = null; var execute: ?[]const u8 = null; var command: ?[]const u8 = null; var dump_bootstrap: ?[]const u8 = null; var dump_file: ?[]const u8 = null; + var input_files = std.ArrayList([]const u8).init(allocator); + defer input_files.deinit(); var i: usize = 2; while (i < args.len) : (i += 1) { if (mem.eql(u8, args[i], "--list")) { @@ -205,6 +271,12 @@ pub fn main() !u8 { } else if (mem.eql(u8, args[i], "--type") and i + 1 < args.len) { i += 1; service_type = args[i]; + } else if (mem.eql(u8, args[i], "--bootstrap") and i + 1 < args.len) { + i += 1; + bootstrap = args[i]; + } else if (mem.eql(u8, args[i], "--bootstrap-file") and i + 1 < args.len) { + i += 1; + bootstrap_file = args[i]; } else if (mem.eql(u8, args[i], "--info") and i + 1 < args.len) { i += 1; info = args[i]; @@ -224,6 +296,15 @@ pub fn main() !u8 { i += 1; allocator.free(public_key); public_key = try allocator.dupe(u8, args[i]); + } else if (mem.eql(u8, args[i], "-f") and i + 1 < args.len) { + i += 1; + const file = args[i]; + // Check if file exists + fs.cwd().access(file, .{}) catch { + std.debug.print("Error: File not found: {s}\n", .{file}); + return 1; + }; + try input_files.append(file); } } @@ -306,7 +387,7 @@ pub fn main() !u8 { return 1; } } else if (name) |n| { - var json_buf: [4096]u8 = undefined; + var json_buf: [65536]u8 = undefined; var json_stream = std.io.fixedBufferStream(&json_buf); const writer = json_stream.writer(); try writer.print("{{\"name\":\"{s}\"", .{n}); @@ -316,6 +397,45 @@ pub fn main() !u8 { if (service_type) |t| { try writer.print(",\"service_type\":\"{s}\"", .{t}); } + if (bootstrap) |b| { + try writer.writeAll(",\"bootstrap\":\""); + // Escape JSON + for (b) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => try writer.writeByte(c), + } + } + try writer.writeAll("\""); + } + if (bootstrap_file) |bf| { + const boot_content = fs.cwd().readFileAlloc(allocator, bf, 10 * 1024 * 1024) catch |err| { + std.debug.print("\x1b[31mError: Bootstrap file not found: {s} ({})\x1b[0m\n", .{ bf, err }); + return 1; + }; + defer allocator.free(boot_content); + try writer.writeAll(",\"bootstrap_content\":\""); + // Escape JSON + for (boot_content) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => try writer.writeByte(c), + } + } + try writer.writeAll("\""); + } + // Add input_files JSON + const input_files_json = try buildInputFilesJson(allocator, input_files); + defer allocator.free(input_files_json); + try writer.writeAll(input_files_json); try writer.writeAll("}"); const json_str = json_stream.getWritten();