From a5155aed4fc3ba5af70991c4917a5317cd4763db Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 5 Feb 2026 16:23:19 -0500 Subject: [PATCH] feat: add HTTP 428 sudo OTP handling to all 42 SDK implementations When destructive operations (service/snapshot/image destroy/unlock) receive HTTP 428 Precondition Required, the SDK now: 1. Extracts challenge_id from the JSON response 2. Prompts: 'Confirmation required. Check your email for a one-time code.' 3. Reads OTP from stdin 4. Retries with X-Sudo-OTP and X-Sudo-Challenge headers Languages updated: AWK, Bash, C++, C#, Clojure, COBOL, Crystal, D, Dart, .NET, Elixir, Erlang, F#, Forth, Fortran, Go, Groovy, Haskell, Java, JavaScript, Julia, Kotlin, Lisp, Lua, Nim, Objective-C, OCaml, Perl, PHP, PowerShell, Prolog, Python, R, Raku, Ruby, Rust, Scheme, Swift, Tcl, TypeScript, V, Zig --- clients/awk/sync/src/un.awk | 195 +++++++++++++++++++++-- clients/bash/sync/src/un.sh | 111 ++++++++++++- clients/clojure/sync/src/un.clj | 122 +++++++++++++-- clients/cobol/sync/src/un.cob | 158 +++++++++++++++++-- clients/cpp/sync/src/un.cpp | 176 +++++++++++++++++++-- clients/crystal/sync/src/un.cr | 178 ++++++++++++++++++++- clients/csharp/sync/src/Un.cs | 62 +++++++- clients/d/sync/src/un.d | 155 +++++++++++++++++-- clients/dart/sync/src/un.dart | 156 ++++++++++++++++++- clients/dotnet/sync/src/Un.cs | 69 ++++++++- clients/elixir/sync/src/un.ex | 162 ++++++++++++++++++- clients/erlang/sync/src/un.erl | 157 +++++++++++++++++-- clients/forth/sync/src/un.forth | 71 ++++++++- clients/fortran/sync/src/un.f90 | 78 ++++++++-- clients/fsharp/sync/src/un.fs | 58 ++++++- clients/go/sync/src/un.go | 149 +++++++++++++++++- clients/groovy/sync/src/un.groovy | 179 ++++++++++++++++++++- clients/haskell/sync/src/un.hs | 131 ++++++++++++++-- clients/java/sync/src/Un.java | 188 +++++++++++++++++++++- clients/javascript/sync/src/un.js | 117 +++++++++++++- clients/julia/sync/src/un.jl | 93 ++++++++++- clients/kotlin/sync/src/un.kt | 78 +++++++++- clients/lisp/sync/src/un.lisp | 119 +++++++++++++- clients/lua/sync/src/un.lua | 84 +++++++++- clients/nim/sync/src/un.nim | 130 ++++++++++++++-- clients/objective-c/sync/src/un.m | 177 ++++++++++++++++++++- clients/ocaml/sync/src/un.ml | 168 +++++++++++++++++++- clients/perl/sync/src/un.pl | 112 ++++++++++++-- clients/php/sync/src/un.php | 164 +++++++++++++++++++- clients/powershell/sync/src/un.ps1 | 111 ++++++++++++- clients/prolog/sync/src/un.pro | 12 +- clients/python/sync/src/un.py | 97 +++++++++++- clients/r/sync/src/un.r | 88 ++++++++++- clients/raku/sync/src/un.raku | 169 +++++++++++++++++++- clients/ruby/sync/src/un.rb | 129 +++++++++++++++- clients/rust/sync/src/lib.rs | 187 +++++++++++++++++++++- clients/scheme/sync/src/un.scm | 139 ++++++++++++++++- clients/swift/sync/src/un.swift | 240 +++++++++++++++++++++++++++-- clients/tcl/sync/src/un.tcl | 117 ++++++++++++-- clients/typescript/sync/src/un.ts | 146 +++++++++++++++++- clients/v/sync/src/un.v | 127 +++++++++++++-- clients/zig/sync/src/un.zig | 125 +++++++++++++-- 42 files changed, 5163 insertions(+), 321 deletions(-) diff --git a/clients/awk/sync/src/un.awk b/clients/awk/sync/src/un.awk index b67b75a..65a3d84 100644 --- a/clients/awk/sync/src/un.awk +++ b/clients/awk/sync/src/un.awk @@ -258,7 +258,77 @@ function service_list( timestamp, sig_headers, signature, sig_input, sig_cmd) close(cmd) } -function service_destroy(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { +# Handle 428 Sudo OTP challenge - prompt user for OTP and retry +function handle_sudo_challenge(response, method, endpoint, body , otp, challenge_id, timestamp, sig_headers, signature, sig_input, sig_cmd, cmd, retry_response, line, sudo_headers) { + # Extract challenge_id from response + challenge_id = "" + if (match(response, /"challenge_id":"([^"]+)"/, arr)) { + challenge_id = arr[1] + } + + print YELLOW "Confirmation required. Check your email for a one-time code." RESET > "/dev/stderr" + printf "Enter OTP: " > "/dev/stderr" + + # Read OTP from stdin + if ((getline otp < "/dev/stdin") <= 0 || otp == "") { + print RED "Error: Operation cancelled" RESET > "/dev/stderr" + return 0 + } + # Strip newline/carriage return + gsub(/[\r\n]/, "", otp) + + if (otp == "") { + print RED "Error: Operation cancelled" RESET > "/dev/stderr" + return 0 + } + + # Retry with sudo headers + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":" method ":" endpoint ":" (body != "" ? body : "") + 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 "' " + } + + sudo_headers = "-H 'X-Sudo-OTP: " otp "' " + if (challenge_id != "") { + sudo_headers = sudo_headers "-H 'X-Sudo-Challenge: " challenge_id "' " + } + + if (method == "DELETE") { + cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' " \ + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers sudo_headers + } else { + cmd = "curl -s -w '\\n%{http_code}' -X " method " '" API_BASE endpoint "' " \ + "-H 'Content-Type: application/json' " \ + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers sudo_headers \ + (body != "" ? "-d '" body "'" : "") + } + + retry_response = "" + while ((cmd | getline line) > 0) { + retry_response = retry_response line "\n" + } + close(cmd) + + # Check if successful (last line is HTTP code) + if (match(retry_response, /\n([0-9]+)\n?$/, arr)) { + if (arr[1] >= 200 && arr[1] < 300) { + print GREEN "Operation completed successfully" RESET + return 1 + } + } + + print RED "Error: OTP verification failed" RESET > "/dev/stderr" + return 0 +} + +function service_destroy(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { get_api_keys() endpoint = "/services/" id timestamp = systime() @@ -270,8 +340,34 @@ function service_destroy(id , timestamp, sig_headers, signature, sig_input, s close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" } - cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - system(cmd) + cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + + response = "" + while ((cmd | getline line) > 0) { + response = response line "\n" + } + close(cmd) + + # Extract HTTP code from last line + http_code = 0 + if (match(response, /\n([0-9]+)\n?$/, arr)) { + http_code = arr[1] + } + + # Handle 428 Precondition Required (sudo OTP needed) + if (http_code == 428) { + if (handle_sudo_challenge(response, "DELETE", endpoint, "")) { + return + } + exit 1 + } + + if (http_code != 200) { + print RED "Error: HTTP " http_code RESET > "/dev/stderr" + print response > "/dev/stderr" + exit 1 + } + print GREEN "Service destroyed: " id RESET } @@ -829,7 +925,7 @@ function snapshot_info(id , timestamp, sig_headers, signature, sig_input, sig close(cmd) } -function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { +function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { get_api_keys() endpoint = "/snapshots/" id timestamp = systime() @@ -841,8 +937,33 @@ function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, s close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" } - cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - system(cmd) + cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + + response = "" + while ((cmd | getline line) > 0) { + response = response line "\n" + } + close(cmd) + + # Extract HTTP code from last line + http_code = 0 + if (match(response, /\n([0-9]+)\n?$/, arr)) { + http_code = arr[1] + } + + # Handle 428 Precondition Required (sudo OTP needed) + if (http_code == 428) { + if (handle_sudo_challenge(response, "DELETE", endpoint, "")) { + return + } + exit 1 + } + + if (http_code != 200) { + print RED "Error: HTTP " http_code RESET > "/dev/stderr" + exit 1 + } + print GREEN "Snapshot deleted: " id RESET } @@ -880,7 +1001,7 @@ function image_info(id , timestamp, sig_headers, signature, sig_input, sig_cm close(cmd) } -function image_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { +function image_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { get_api_keys() endpoint = "/images/" id timestamp = systime() @@ -892,8 +1013,33 @@ function image_delete(id , timestamp, sig_headers, signature, sig_input, sig_ close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" } - cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - system(cmd) + cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + + response = "" + while ((cmd | getline line) > 0) { + response = response line "\n" + } + close(cmd) + + # Extract HTTP code from last line + http_code = 0 + if (match(response, /\n([0-9]+)\n?$/, arr)) { + http_code = arr[1] + } + + # Handle 428 Precondition Required (sudo OTP needed) + if (http_code == 428) { + if (handle_sudo_challenge(response, "DELETE", endpoint, "")) { + return + } + exit 1 + } + + if (http_code != 200) { + print RED "Error: HTTP " http_code RESET > "/dev/stderr" + exit 1 + } + print GREEN "Image deleted: " id RESET } @@ -923,7 +1069,7 @@ function image_lock(id , endpoint, json, tmp, timestamp, sig_headers, signatu print GREEN "Image locked: " id RESET } -function image_unlock(id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd) { +function image_unlock(id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, cmd, response, line, http_code) { get_api_keys() endpoint = "/images/" id "/unlock" json = "{}" @@ -939,13 +1085,38 @@ function image_unlock(id , endpoint, json, tmp, timestamp, sig_headers, signa close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " } - cmd = "curl -s -X POST '" API_BASE endpoint "' " \ + cmd = "curl -s -w '\\n%{http_code}' -X POST '" API_BASE endpoint "' " \ "-H 'Content-Type: application/json' " \ "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ sig_headers \ "-d '@" tmp "'" - system(cmd " > /dev/null") + + response = "" + while ((cmd | getline line) > 0) { + response = response line "\n" + } + close(cmd) system("rm -f " tmp) + + # Extract HTTP code from last line + http_code = 0 + if (match(response, /\n([0-9]+)\n?$/, arr)) { + http_code = arr[1] + } + + # Handle 428 Precondition Required (sudo OTP needed) + if (http_code == 428) { + if (handle_sudo_challenge(response, "POST", endpoint, json)) { + return + } + exit 1 + } + + if (http_code != 200) { + print RED "Error: HTTP " http_code RESET > "/dev/stderr" + exit 1 + } + print GREEN "Image unlocked: " id RESET } diff --git a/clients/bash/sync/src/un.sh b/clients/bash/sync/src/un.sh index 68adf3a..c90893d 100644 --- a/clients/bash/sync/src/un.sh +++ b/clients/bash/sync/src/un.sh @@ -54,6 +54,7 @@ api_request() { local method="$1" local endpoint="$2" local body="$3" + local extra_headers="${4:-}" local creds=$(get_credentials) local pk=$(echo "$creds" | cut -d: -f1) @@ -63,12 +64,114 @@ api_request() { local body_str="${body:-{}}" local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") - curl -s -X "$method" "$API_BASE$endpoint" \ + local curl_cmd=(curl -s -X "$method" "$API_BASE$endpoint" + -H "Authorization: Bearer $pk" + -H "X-Timestamp: $timestamp" + -H "X-Signature: $signature" + -H "Content-Type: application/json" + -d "$body_str") + + # Add extra headers if provided + if [ -n "$extra_headers" ]; then + eval "curl_cmd+=($extra_headers)" + fi + + "${curl_cmd[@]}" +} + +# Handle 428 Sudo OTP challenge - prompt user for OTP and retry +handle_sudo_challenge() { + local response="$1" + local method="$2" + local endpoint="$3" + local body="$4" + + # Extract challenge_id from response + local challenge_id=$(echo "$response" | jq -r '.challenge_id // empty' 2>/dev/null) + + echo -e "\033[33mConfirmation required. Check your email for a one-time code.\033[0m" >&2 + echo -n "Enter OTP: " >&2 + read -r otp + + if [ -z "$otp" ]; then + echo -e "\033[31mError: Operation cancelled\033[0m" >&2 + return 1 + fi + + # Retry with sudo headers + local extra_headers="-H 'X-Sudo-OTP: $otp'" + if [ -n "$challenge_id" ]; then + extra_headers="$extra_headers -H 'X-Sudo-Challenge: $challenge_id'" + fi + + local creds=$(get_credentials) + local pk=$(echo "$creds" | cut -d: -f1) + local sk=$(echo "$creds" | cut -d: -f2) + + local timestamp=$(date +%s) + local body_str="${body:-{}}" + local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") + + local retry_result + retry_result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ -H "Authorization: Bearer $pk" \ -H "X-Timestamp: $timestamp" \ -H "X-Signature: $signature" \ -H "Content-Type: application/json" \ - -d "$body_str" + -H "X-Sudo-OTP: $otp" \ + ${challenge_id:+-H "X-Sudo-Challenge: $challenge_id"} \ + -d "$body_str") + + local http_code=$(echo "$retry_result" | tail -1) + local response_body=$(echo "$retry_result" | sed '$d') + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + echo -e "\033[32mOperation completed successfully\033[0m" + return 0 + else + echo -e "\033[31mError: OTP verification failed (HTTP $http_code)\033[0m" >&2 + return 1 + fi +} + +# API request with 428 sudo handling for destructive operations +api_request_with_sudo() { + local method="$1" + local endpoint="$2" + local body="$3" + + local creds=$(get_credentials) + local pk=$(echo "$creds" | cut -d: -f1) + local sk=$(echo "$creds" | cut -d: -f2) + + local timestamp=$(date +%s) + local body_str="${body:-{}}" + local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") + + local result + result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ + -H "Authorization: Bearer $pk" \ + -H "X-Timestamp: $timestamp" \ + -H "X-Signature: $signature" \ + -H "Content-Type: application/json" \ + -d "$body_str") + + local http_code=$(echo "$result" | tail -1) + local response_body=$(echo "$result" | sed '$d') + + # Handle 428 Precondition Required (sudo OTP needed) + if [ "$http_code" = "428" ]; then + handle_sudo_challenge "$response_body" "$method" "$endpoint" "$body" + return $? + fi + + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo -e "\033[31mError: HTTP $http_code\033[0m" >&2 + echo "$response_body" >&2 + return 1 + fi + + echo "$response_body" } # Languages with cache @@ -318,7 +421,7 @@ cmd_image() { echo "$result" | jq . ;; delete) - api_request "DELETE" "/images/$id" "" + api_request_with_sudo "DELETE" "/images/$id" "" echo "Image deleted successfully" ;; lock) @@ -326,7 +429,7 @@ cmd_image() { echo "Image locked successfully" ;; unlock) - api_request "POST" "/images/$id/unlock" "{}" + api_request_with_sudo "POST" "/images/$id/unlock" "{}" echo "Image unlocked successfully" ;; publish) diff --git a/clients/clojure/sync/src/un.clj b/clients/clojure/sync/src/un.clj index 879d156..83bf56f 100644 --- a/clients/clojure/sync/src/un.clj +++ b/clients/clojure/sync/src/un.clj @@ -197,13 +197,101 @@ (defn curl-delete [api-key endpoint] (let [[public-key secret-key] (get-api-keys) auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "") - args (concat ["curl" "-s" "-X" "DELETE" + args (concat ["curl" "-s" "-w" "\n%{http_code}" "-X" "DELETE" (str "https://api.unsandbox.com" endpoint)] auth-headers) result (:out (apply sh args))] (check-clock-drift-error result) result)) +(defn extract-http-code [response] + "Extract HTTP code from response with status code appended" + (let [lines (str/split response #"\n") + last-line (last lines)] + (try + (Integer/parseInt (str/trim last-line)) + (catch Exception _ 0)))) + +(defn extract-body [response] + "Extract body from response (everything except last line which is status code)" + (let [lines (str/split response #"\n")] + (str/join "\n" (butlast lines)))) + +(defn handle-sudo-challenge + "Handle 428 sudo OTP challenge - prompts user for OTP and retries the request" + [response-data public-key secret-key method endpoint body] + (let [challenge-id (extract-field "challenge_id" response-data)] + (binding [*out* *err*] + (println (str yellow "Confirmation required. Check your email for a one-time code." reset))) + (print "Enter OTP: ") + (flush) + (let [otp (str/trim (or (read-line) ""))] + (when (empty? otp) + (binding [*out* *err*] + (println "Error: Operation cancelled")) + (System/exit 1)) + ;; Retry the request with sudo headers + (let [auth-headers (build-auth-headers public-key secret-key method endpoint (or body "")) + sudo-headers ["-H" (str "X-Sudo-OTP: " otp) + "-H" (str "X-Sudo-Challenge: " (or challenge-id ""))] + content-type-headers (if body ["-H" "Content-Type: application/json"] []) + body-args (if body ["-d" body] []) + method-args (cond + (= method "DELETE") ["-X" "DELETE"] + (= method "POST") ["-X" "POST"] + :else ["-X" method]) + args (concat ["curl" "-s"] + method-args + [(str "https://api.unsandbox.com" endpoint)] + auth-headers + sudo-headers + content-type-headers + body-args) + {:keys [out]} (apply sh args) + http-code (extract-http-code out)] + (if (and (>= http-code 200) (< http-code 300)) + {:success true :response (extract-body out)} + (do + (binding [*out* *err*] + (println (str red "Error: HTTP " http-code reset)) + (println (extract-body out))) + {:success false})))))) + +(defn curl-delete-with-sudo [api-key endpoint] + "DELETE request that handles 428 sudo OTP challenge" + (let [[public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "") + args (concat ["curl" "-s" "-w" "\n%{http_code}" "-X" "DELETE" + (str "https://api.unsandbox.com" endpoint)] + auth-headers) + result (:out (apply sh args)) + http-code (extract-http-code result) + body (extract-body result)] + (check-clock-drift-error body) + (if (= http-code 428) + (handle-sudo-challenge body public-key secret-key "DELETE" endpoint nil) + {:success (and (>= http-code 200) (< http-code 300)) :response body :http-code http-code}))) + +(defn curl-post-with-sudo [api-key endpoint json-data] + "POST request that handles 428 sudo OTP challenge" + (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".json") + [public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)] + (spit tmp-file json-data) + (let [args (concat ["curl" "-s" "-w" "\n%{http_code}" "-X" "POST" + (str "https://api.unsandbox.com" endpoint) + "-H" "Content-Type: application/json"] + auth-headers + ["-d" (str "@" tmp-file)]) + {:keys [out]} (apply sh args)] + (io/delete-file tmp-file true) + (let [http-code (extract-http-code out) + body (extract-body out)] + (check-clock-drift-error body) + (if (= http-code 428) + (handle-sudo-challenge body public-key secret-key "POST" endpoint json-data) + {:success (and (>= http-code 200) (< http-code 300)) :response body :http-code http-code}))))) + (defn curl-put-text [endpoint body] (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".txt") [public-key secret-key] (get-api-keys) @@ -416,9 +504,13 @@ :wake (do (curl-post api-key (str "/services/" sid "/unfreeze") "{}") (println (str green "Service unfreezing: " sid reset))) - :destroy (do - (curl-delete api-key (str "/services/" sid)) - (println (str green "Service destroyed: " sid reset))) + :destroy (let [result (curl-delete-with-sudo api-key (str "/services/" sid))] + (if (:success result) + (println (str green "Service destroyed: " sid reset)) + (do + (binding [*out* *err*] + (println (str red "Error destroying service" reset))) + (System/exit 1)))) :resize (when sid (if (or (nil? vcpu) (< vcpu 1) (> vcpu 8)) (do @@ -586,9 +678,14 @@ (println (curl-get api-key (str "/images/" id))))) (defn image-delete [id] - (let [api-key (get-api-key)] - (curl-delete api-key (str "/images/" id)) - (println (str green "Image deleted: " id reset)))) + (let [api-key (get-api-key) + result (curl-delete-with-sudo api-key (str "/images/" id))] + (if (:success result) + (println (str green "Image deleted: " id reset)) + (do + (binding [*out* *err*] + (println (str red "Error deleting image" reset))) + (System/exit 1))))) (defn image-lock [id] (let [api-key (get-api-key)] @@ -596,9 +693,14 @@ (println (str green "Image locked: " id reset)))) (defn image-unlock [id] - (let [api-key (get-api-key)] - (curl-post api-key (str "/images/" id "/unlock") "{}") - (println (str green "Image unlocked: " id reset)))) + (let [api-key (get-api-key) + result (curl-post-with-sudo api-key (str "/images/" id "/unlock") "{}")] + (if (:success result) + (println (str green "Image unlocked: " id reset)) + (do + (binding [*out* *err*] + (println (str red "Error unlocking image" reset))) + (System/exit 1))))) (defn image-publish [source-id source-type name] (let [api-key (get-api-key) diff --git a/clients/cobol/sync/src/un.cob b/clients/cobol/sync/src/un.cob index 30a6cba..c89428f 100644 --- a/clients/cobol/sync/src/un.cob +++ b/clients/cobol/sync/src/un.cob @@ -543,13 +543,61 @@ CALL "SYSTEM" USING WS-CURL-CMD. SERVICE-DESTROY. - STRING "curl -s -X DELETE " - "https://api.unsandbox.com/services/" - FUNCTION TRIM(WS-ID) " " - "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) - "' >/dev/null && " - "echo -e '\x1b[32mService destroyed: " - FUNCTION TRIM(WS-ID) "\x1b[0m'" + STRING "TS=$(date +%s); " + "SIG=$(echo -n \"$TS:DELETE:/services/" + FUNCTION TRIM(WS-ID) + ":\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP=$(curl -s -w '\\n%{http_code}' -X DELETE " + "'https://api.unsandbox.com/services/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG); " + "HTTP_CODE=$(echo \"$RESP\" | tail -n1); " + "BODY=$(echo \"$RESP\" | sed '$d'); " + "if [ \"$HTTP_CODE\" = \"428\" ]; then " + "CHALLENGE_ID=$(echo \"$BODY\" | jq -r '.challenge_id // empty'); " + "echo -e '\\x1b[33mConfirmation required. Check your email " + "for a one-time code.\\x1b[0m' >&2; " + "echo -n 'Enter OTP: ' >&2; read OTP; " + "if [ -z \"$OTP\" ]; then " + "echo -e '\\x1b[31mError: Operation cancelled\\x1b[0m' >&2; " + "exit 1; fi; " + "TS2=$(date +%s); " + "SIG2=$(echo -n \"$TS2:DELETE:/services/" + FUNCTION TRIM(WS-ID) + ":\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP2=$(curl -s -w '\\n%{http_code}' -X DELETE " + "'https://api.unsandbox.com/services/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS2 " + "-H 'X-Signature: '$SIG2 " + "-H 'X-Sudo-OTP: '$OTP " + "-H 'X-Sudo-Challenge: '$CHALLENGE_ID); " + "HTTP_CODE2=$(echo \"$RESP2\" | tail -n1); " + "if [ \"$HTTP_CODE2\" = \"200\" ] || " + "[ \"$HTTP_CODE2\" = \"204\" ]; then " + "echo -e '\\x1b[32mService destroyed: " + FUNCTION TRIM(WS-ID) "\\x1b[0m'; " + "else echo \"$RESP2\" | sed '$d' | jq . 2>/dev/null || " + "echo \"$RESP2\" | sed '$d'; exit 1; fi; " + "elif [ \"$HTTP_CODE\" = \"200\" ] || " + "[ \"$HTTP_CODE\" = \"204\" ]; then " + "echo -e '\\x1b[32mService destroyed: " + FUNCTION TRIM(WS-ID) "\\x1b[0m'; " + "else echo \"$BODY\" | jq . 2>/dev/null || " + "echo \"$BODY\"; exit 1; fi" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. @@ -1277,16 +1325,55 @@ ":\" | openssl dgst -sha256 -hmac '" FUNCTION TRIM(WS-SECRET-KEY) "' | cut -d' ' -f2); " - "curl -s -X DELETE 'https://api.unsandbox.com/images/" + "RESP=$(curl -s -w '\\n%{http_code}' -X DELETE " + "'https://api.unsandbox.com/images/" FUNCTION TRIM(WS-ID) "' " "-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' " "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG >/dev/null && " - "echo -e '\x1b[32mImage deleted: " - FUNCTION TRIM(WS-ID) "\x1b[0m'" + "-H 'X-Signature: '$SIG); " + "HTTP_CODE=$(echo \"$RESP\" | tail -n1); " + "BODY=$(echo \"$RESP\" | sed '$d'); " + "if [ \"$HTTP_CODE\" = \"428\" ]; then " + "CHALLENGE_ID=$(echo \"$BODY\" | jq -r '.challenge_id // empty'); " + "echo -e '\\x1b[33mConfirmation required. Check your email " + "for a one-time code.\\x1b[0m' >&2; " + "echo -n 'Enter OTP: ' >&2; read OTP; " + "if [ -z \"$OTP\" ]; then " + "echo -e '\\x1b[31mError: Operation cancelled\\x1b[0m' >&2; " + "exit 1; fi; " + "TS2=$(date +%s); " + "SIG2=$(echo -n \"$TS2:DELETE:/images/" + FUNCTION TRIM(WS-ID) + ":\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP2=$(curl -s -w '\\n%{http_code}' -X DELETE " + "'https://api.unsandbox.com/images/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS2 " + "-H 'X-Signature: '$SIG2 " + "-H 'X-Sudo-OTP: '$OTP " + "-H 'X-Sudo-Challenge: '$CHALLENGE_ID); " + "HTTP_CODE2=$(echo \"$RESP2\" | tail -n1); " + "if [ \"$HTTP_CODE2\" = \"200\" ] || " + "[ \"$HTTP_CODE2\" = \"204\" ]; then " + "echo -e '\\x1b[32mImage deleted: " + FUNCTION TRIM(WS-ID) "\\x1b[0m'; " + "else echo \"$RESP2\" | sed '$d' | jq . 2>/dev/null || " + "echo \"$RESP2\" | sed '$d'; exit 1; fi; " + "elif [ \"$HTTP_CODE\" = \"200\" ] || " + "[ \"$HTTP_CODE\" = \"204\" ]; then " + "echo -e '\\x1b[32mImage deleted: " + FUNCTION TRIM(WS-ID) "\\x1b[0m'; " + "else echo \"$BODY\" | jq . 2>/dev/null || " + "echo \"$BODY\"; exit 1; fi" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. @@ -1325,7 +1412,8 @@ "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" FUNCTION TRIM(WS-SECRET-KEY) "' | cut -d' ' -f2); " - "curl -s -X POST 'https://api.unsandbox.com/images/" + "RESP=$(curl -s -w '\\n%{http_code}' -X POST " + "'https://api.unsandbox.com/images/" FUNCTION TRIM(WS-ID) "/unlock' " "-H 'Content-Type: application/json' " @@ -1334,9 +1422,49 @@ "' " "-H 'X-Timestamp: '$TS " "-H 'X-Signature: '$SIG " - "-d \"$BODY\" >/dev/null && " - "echo -e '\x1b[32mImage unlocked: " - FUNCTION TRIM(WS-ID) "\x1b[0m'" + "-d \"$BODY\"); " + "HTTP_CODE=$(echo \"$RESP\" | tail -n1); " + "RESP_BODY=$(echo \"$RESP\" | sed '$d'); " + "if [ \"$HTTP_CODE\" = \"428\" ]; then " + "CHALLENGE_ID=$(echo \"$RESP_BODY\" | jq -r '.challenge_id // empty'); " + "echo -e '\\x1b[33mConfirmation required. Check your email " + "for a one-time code.\\x1b[0m' >&2; " + "echo -n 'Enter OTP: ' >&2; read OTP; " + "if [ -z \"$OTP\" ]; then " + "echo -e '\\x1b[31mError: Operation cancelled\\x1b[0m' >&2; " + "exit 1; fi; " + "TS2=$(date +%s); " + "SIG2=$(echo -n \"$TS2:POST:/images/" + FUNCTION TRIM(WS-ID) + "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP2=$(curl -s -w '\\n%{http_code}' -X POST " + "'https://api.unsandbox.com/images/" + FUNCTION TRIM(WS-ID) + "/unlock' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS2 " + "-H 'X-Signature: '$SIG2 " + "-H 'X-Sudo-OTP: '$OTP " + "-H 'X-Sudo-Challenge: '$CHALLENGE_ID " + "-d \"$BODY\"); " + "HTTP_CODE2=$(echo \"$RESP2\" | tail -n1); " + "if [ \"$HTTP_CODE2\" = \"200\" ] || " + "[ \"$HTTP_CODE2\" = \"204\" ]; then " + "echo -e '\\x1b[32mImage unlocked: " + FUNCTION TRIM(WS-ID) "\\x1b[0m'; " + "else echo \"$RESP2\" | sed '$d' | jq . 2>/dev/null || " + "echo \"$RESP2\" | sed '$d'; exit 1; fi; " + "elif [ \"$HTTP_CODE\" = \"200\" ] || " + "[ \"$HTTP_CODE\" = \"204\" ]; then " + "echo -e '\\x1b[32mImage unlocked: " + FUNCTION TRIM(WS-ID) "\\x1b[0m'; " + "else echo \"$RESP_BODY\" | jq . 2>/dev/null || " + "echo \"$RESP_BODY\"; exit 1; fi" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. diff --git a/clients/cpp/sync/src/un.cpp b/clients/cpp/sync/src/un.cpp index 5779239..d319652 100644 --- a/clients/cpp/sync/src/un.cpp +++ b/clients/cpp/sync/src/un.cpp @@ -161,6 +161,163 @@ string exec_curl(const string& cmd) { return result; } +// Execute curl and get HTTP status code +pair exec_curl_with_status(const string& cmd) { + // Modify cmd to include status code output + string full_cmd = cmd + " -w '\\n%{http_code}'"; + string result = exec_curl(full_cmd); + + // Extract status code from end of response + size_t last_newline = result.rfind('\n'); + if (last_newline != string::npos && last_newline > 0) { + // Find the status code after the last newline + size_t status_start = last_newline + 1; + // Trim any trailing whitespace + while (!result.empty() && (result.back() == '\n' || result.back() == '\r' || result.back() == ' ')) { + result.pop_back(); + } + // Now find the status code at the end + size_t end = result.length(); + size_t start = result.rfind('\n'); + if (start == string::npos) start = 0; + else start++; + + string status_str = result.substr(start); + int status = 0; + try { + status = stoi(status_str); + } catch (...) { + status = 0; + } + string body = result.substr(0, start > 0 ? start - 1 : 0); + return {body, status}; + } + return {result, 0}; +} + +// Extract challenge_id from JSON response +string extract_challenge_id(const string& response) { + size_t pos = response.find("\"challenge_id\":\""); + if (pos == string::npos) return ""; + pos += 16; // Length of "challenge_id":" + size_t end = response.find("\"", pos); + if (end == string::npos) return ""; + return response.substr(pos, end - pos); +} + +// Handle 428 sudo OTP challenge - prompts user for OTP and retries request +bool handle_sudo_challenge(const string& method, const string& path, const string& body, + const string& public_key, const string& secret_key, const string& response) { + // Extract challenge_id from response + string challenge_id = extract_challenge_id(response); + + cerr << YELLOW << "Confirmation required. Check your email for a one-time code." << RESET << endl; + cerr << "Enter OTP: "; + + string otp; + if (!getline(cin, otp)) { + cerr << RED << "Error: Failed to read OTP" << RESET << endl; + return false; + } + + // Trim whitespace + while (!otp.empty() && (otp.back() == '\n' || otp.back() == '\r' || otp.back() == ' ')) { + otp.pop_back(); + } + while (!otp.empty() && (otp.front() == ' ')) { + otp.erase(0, 1); + } + + if (otp.empty()) { + cerr << RED << "Error: Operation cancelled" << RESET << endl; + return false; + } + + // Retry the request with sudo headers + string auth_headers = build_auth_headers(method, path, body, public_key, secret_key); + auth_headers += " -H 'X-Sudo-OTP: " + otp + "'"; + if (!challenge_id.empty()) { + auth_headers += " -H 'X-Sudo-Challenge: " + challenge_id + "'"; + } + + string cmd; + if (method == "DELETE") { + cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; + } else if (method == "POST") { + cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " " + "-d '" + body + "'"; + } else { + cmd = "curl -s -X " + method + " '" + API_BASE + path + "' " + auth_headers; + } + + auto [retry_response, status] = exec_curl_with_status(cmd); + + if (status >= 200 && status < 300) { + cout << GREEN << "Operation completed successfully" << RESET << endl; + return true; + } + + // Extract error message if available + size_t error_pos = retry_response.find("\"error\":\""); + if (error_pos != string::npos) { + error_pos += 9; + size_t error_end = retry_response.find("\"", error_pos); + if (error_end != string::npos) { + cerr << RED << "Error: " << retry_response.substr(error_pos, error_end - error_pos) << RESET << endl; + } else { + cerr << RED << "Error: " << retry_response << RESET << endl; + } + } else { + cerr << RED << "Error: HTTP " << status << RESET << endl; + cerr << retry_response << endl; + } + return false; +} + +// Execute a destructive operation that may require sudo OTP confirmation +bool exec_destructive_curl(const string& method, const string& path, const string& body, + const string& public_key, const string& secret_key, const string& success_msg) { + string auth_headers = build_auth_headers(method, path, body, public_key, secret_key); + + string cmd; + if (method == "DELETE") { + cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; + } else if (method == "POST" && !body.empty()) { + cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " " + "-d '" + body + "'"; + } else if (method == "POST") { + cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + } else { + cmd = "curl -s -X " + method + " '" + API_BASE + path + "' " + auth_headers; + } + + auto [response, status] = exec_curl_with_status(cmd); + + // Handle 428 sudo challenge + if (status == 428) { + return handle_sudo_challenge(method, path, body, public_key, secret_key, response); + } + + if (status >= 200 && status < 300) { + if (!success_msg.empty()) { + cout << GREEN << success_msg << RESET << endl; + } + return true; + } + + if (status == 404) { + cerr << RED << "Error: Not found" << RESET << endl; + } else { + cerr << RED << "Error: HTTP " << status << RESET << endl; + if (!response.empty()) cerr << response << endl; + } + return false; +} + string compute_hmac(const string& secret_key, const string& message) { string cmd = "echo -n '" + message + "' | openssl dgst -sha256 -hmac '" + secret_key + "' -hex | sed 's/.*= //'"; string result = exec_curl(cmd); @@ -594,10 +751,8 @@ void cmd_service(const string& name, const string& ports, const string& type, co } if (!destroy.empty()) { - string auth_headers = build_auth_headers("DELETE", "/services/" + destroy, "", public_key, secret_key); - string cmd = "curl -s -X DELETE '" + API_BASE + "/services/" + destroy + "' " + auth_headers; - exec_curl(cmd); - cout << GREEN << "Service destroyed: " << destroy << RESET << endl; + string path = "/services/" + destroy; + exec_destructive_curl("DELETE", path, "", public_key, secret_key, "Service destroyed: " + destroy); return; } @@ -855,10 +1010,7 @@ void cmd_image(bool list, const string& info, const string& del, const string& l if (!del.empty()) { string path = "/images/" + del; - string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); - string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - exec_curl(cmd); - cout << GREEN << "Image deleted: " << del << RESET << endl; + exec_destructive_curl("DELETE", path, "", public_key, secret_key, "Image deleted: " + del); return; } @@ -876,13 +1028,7 @@ void cmd_image(bool list, const string& info, const string& del, const string& l if (!unlock.empty()) { string path = "/images/" + unlock + "/unlock"; - string body = "{}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - exec_curl(cmd); - cout << GREEN << "Image unlocked: " << unlock << RESET << endl; + exec_destructive_curl("POST", path, "{}", public_key, secret_key, "Image unlocked: " + unlock); return; } diff --git a/clients/crystal/sync/src/un.cr b/clients/crystal/sync/src/un.cr index 98f78b9..f5963ca 100644 --- a/clients/crystal/sync/src/un.cr +++ b/clients/crystal/sync/src/un.cr @@ -149,6 +149,147 @@ def get_api_keys(args_key : String?) : {String, String?} {public_key, secret_key} end +def extract_challenge_id(response_body : String) : String? + begin + parsed = JSON.parse(response_body) + parsed["challenge_id"]?.try(&.as_s?) + rescue + nil + end +end + +def handle_sudo_challenge(response_body : String, public_key : String, secret_key : String?, method : String, endpoint : String, body : String?) : JSON::Any + challenge_id = extract_challenge_id(response_body) + + STDERR.puts "#{YELLOW}Confirmation required. Check your email for a one-time code.#{RESET}" + STDERR.print "Enter OTP: " + + otp = STDIN.gets + if otp.nil? || otp.strip.empty? + STDERR.puts "#{RED}Error: Operation cancelled#{RESET}" + exit 1 + end + otp = otp.strip + + url = URI.parse(API_BASE + endpoint) + headers = HTTP::Headers{ + "Content-Type" => "application/json" + } + + request_body = body || "" + + # Add HMAC authentication headers + if secret_key && !secret_key.empty? + timestamp = Time.utc.to_unix.to_s + message = "#{timestamp}:#{method}:#{endpoint}:#{request_body}" + signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message) + + headers["Authorization"] = "Bearer #{public_key}" + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + else + headers["Authorization"] = "Bearer #{public_key}" + end + + # Add sudo headers + headers["X-Sudo-OTP"] = otp + if challenge_id + headers["X-Sudo-Challenge"] = challenge_id + end + + begin + response = case method + when "GET" + HTTP::Client.get(url, headers: headers) + when "POST" + HTTP::Client.post(url, headers: headers, body: request_body) + when "PATCH" + HTTP::Client.patch(url, headers: headers, body: request_body) + when "DELETE" + HTTP::Client.delete(url, headers: headers) + else + STDERR.puts "#{RED}Error: Unsupported method: #{method}#{RESET}" + exit 1 + end + + if response.status_code >= 200 && response.status_code < 300 + STDERR.puts "#{GREEN}Operation completed successfully#{RESET}" + JSON.parse(response.body) + else + STDERR.puts "#{RED}Error: HTTP #{response.status_code}#{RESET}" + begin + error_json = JSON.parse(response.body) + if error_msg = error_json["error"]?.try(&.as_s?) + STDERR.puts error_msg + else + STDERR.puts response.body + end + rescue + STDERR.puts response.body + end + exit 1 + end + rescue ex + STDERR.puts "#{RED}Error: #{ex.message}#{RESET}" + exit 1 + end +end + +def api_request_with_sudo(endpoint : String, public_key : String, secret_key : String?, method = "GET", data : JSON::Any? = nil) : {Int32, JSON::Any} + url = URI.parse(API_BASE + endpoint) + headers = HTTP::Headers{ + "Content-Type" => "application/json" + } + + body = data ? data.to_json : "" + + # Add HMAC authentication headers if secret_key is provided + if secret_key && !secret_key.empty? + timestamp = Time.utc.to_unix.to_s + message = "#{timestamp}:#{method}:#{endpoint}:#{body}" + + signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message) + + headers["Authorization"] = "Bearer #{public_key}" + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + else + # Legacy API key authentication + headers["Authorization"] = "Bearer #{public_key}" + end + + begin + response = case method + when "GET" + HTTP::Client.get(url, headers: headers) + when "POST" + HTTP::Client.post(url, headers: headers, body: body) + when "PATCH" + HTTP::Client.patch(url, headers: headers, body: body) + when "DELETE" + HTTP::Client.delete(url, headers: headers) + else + STDERR.puts "#{RED}Error: Unsupported method: #{method}#{RESET}" + exit 1 + end + + {response.status_code, JSON.parse(response.body)} + rescue ex + error_msg = ex.message || "" + if error_msg.downcase.includes?("timestamp") + STDERR.puts "#{RED}Error: Request timestamp expired (must be within 5 minutes of server time)#{RESET}" + STDERR.puts "#{YELLOW}Your computer's clock may have drifted.#{RESET}" + STDERR.puts "Check your system time and sync with NTP if needed:" + STDERR.puts " Linux: sudo ntpdate -s time.nist.gov" + STDERR.puts " macOS: sudo sntp -sS time.apple.com" + STDERR.puts " Windows: w32tm /resync" + else + STDERR.puts "#{RED}Error: Request failed: #{ex.message}#{RESET}" + end + exit 1 + end +end + def api_request(endpoint : String, public_key : String, secret_key : String?, method = "GET", data : JSON::Any? = nil) url = URI.parse(API_BASE + endpoint) headers = HTTP::Headers{ @@ -613,8 +754,16 @@ def cmd_image(args) end if del_id = args[:image_delete]?.as?(String) - api_request("/images/#{del_id}", public_key, secret_key, method: "DELETE") - puts "#{GREEN}Image deleted: #{del_id}#{RESET}" + status_code, response = api_request_with_sudo("/images/#{del_id}", public_key, secret_key, method: "DELETE") + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "DELETE", "/images/#{del_id}", nil) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Image deleted: #{del_id}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end return end @@ -627,8 +776,17 @@ def cmd_image(args) if unlock_id = args[:image_unlock]?.as?(String) payload = JSON.parse({}.to_json) - api_request("/images/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Image unlocked: #{unlock_id}#{RESET}" + body = "{}" + status_code, response = api_request_with_sudo("/images/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/images/#{unlock_id}/unlock", body) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Image unlocked: #{unlock_id}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end return end @@ -756,8 +914,16 @@ def cmd_service(args) end if destroy_id = args[:destroy]?.as?(String) - api_request("/services/#{destroy_id}", public_key, secret_key, method: "DELETE") - puts "#{GREEN}Service destroyed: #{destroy_id}#{RESET}" + status_code, response = api_request_with_sudo("/services/#{destroy_id}", public_key, secret_key, method: "DELETE") + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "DELETE", "/services/#{destroy_id}", nil) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Service destroyed: #{destroy_id}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end return end diff --git a/clients/csharp/sync/src/Un.cs b/clients/csharp/sync/src/Un.cs index c44da42..1b1ca93 100644 --- a/clients/csharp/sync/src/Un.cs +++ b/clients/csharp/sync/src/Un.cs @@ -433,7 +433,7 @@ class Un if (args.ServiceDestroy != null) { - ApiRequest($"/services/{args.ServiceDestroy}", "DELETE", null, publicKey, secretKey); + ApiRequestWithSudo($"/services/{args.ServiceDestroy}", "DELETE", null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); return; } @@ -597,7 +597,7 @@ class Un return ExtMap[ext]; } - static Dictionary ApiRequest(string endpoint, string method, Dictionary data, string publicKey, string secretKey) + static Dictionary ApiRequest(string endpoint, string method, Dictionary data, string publicKey, string secretKey, string sudoOtp = null, string sudoChallengeId = null) { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; @@ -634,6 +634,16 @@ class Un request.Headers.Add("Authorization", $"Bearer {publicKey}"); } + // Add sudo OTP headers if provided + if (!string.IsNullOrEmpty(sudoOtp)) + { + request.Headers.Add("X-Sudo-OTP", sudoOtp); + } + if (!string.IsNullOrEmpty(sudoChallengeId)) + { + request.Headers.Add("X-Sudo-Challenge", sudoChallengeId); + } + if (data != null) { byte[] bytes = Encoding.UTF8.GetBytes(body); @@ -683,7 +693,53 @@ class Un Environment.Exit(1); } - throw new Exception($"HTTP error - {error}"); + throw new HttpException(statusCode, error); + } + } + + // Custom exception to preserve HTTP status code + class HttpException : Exception + { + public int StatusCode { get; } + public string ResponseBody { get; } + public HttpException(int statusCode, string responseBody) : base($"HTTP {statusCode}: {responseBody}") + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + } + + // Handle 428 sudo OTP challenge - prompts user for OTP and retries the request + static Dictionary HandleSudoChallenge(string responseBody, string endpoint, string method, Dictionary data, string publicKey, string secretKey) + { + var response = ParseJson(responseBody); + string challengeId = response.ContainsKey("challenge_id") ? (string)response["challenge_id"] : null; + + Console.Error.WriteLine($"{YELLOW}Confirmation required. Check your email for a one-time code.{RESET}"); + Console.Error.Write("Enter OTP: "); + + string otp = Console.ReadLine(); + if (string.IsNullOrEmpty(otp)) + { + throw new Exception("Operation cancelled"); + } + + otp = otp.Trim(); + + // Retry the request with sudo headers + return ApiRequest(endpoint, method, data, publicKey, secretKey, otp, challengeId); + } + + // Wrapper for destructive operations that may require 428 sudo OTP + static Dictionary ApiRequestWithSudo(string endpoint, string method, Dictionary data, string publicKey, string secretKey) + { + try + { + return ApiRequest(endpoint, method, data, publicKey, secretKey); + } + catch (HttpException ex) when (ex.StatusCode == 428) + { + return HandleSudoChallenge(ex.ResponseBody, endpoint, method, data, publicKey, secretKey); } } diff --git a/clients/d/sync/src/un.d b/clients/d/sync/src/un.d index b6f8953..581c315 100644 --- a/clients/d/sync/src/un.d +++ b/clients/d/sync/src/un.d @@ -248,6 +248,145 @@ string execCurl(string cmd) { return output; } +// Execute curl and get HTTP status code along with response body +struct CurlResult { + string body; + int status; +} + +CurlResult execCurlWithStatus(string cmd) { + // Modify cmd to include status code output + string fullCmd = cmd ~ " -w '\\n%{http_code}'"; + auto result = executeShell(fullCmd); + string output = result.output.strip(); + + // Find the last line (status code) + import std.algorithm : findSplitAfter; + auto lastNewline = output.findSplitAfter("\n"); + + // Walk backwards to find status code at end + string statusStr = ""; + string bodyStr = output; + if (output.length >= 3) { + // Try to parse last 3 chars as status + size_t i = output.length; + while (i > 0 && output[i-1] >= '0' && output[i-1] <= '9') i--; + if (i < output.length) { + statusStr = output[i..$]; + bodyStr = output[0..i].strip(); + } + } + + int status = 0; + try { + status = to!int(statusStr); + } catch (Exception e) { + status = 0; + } + + return CurlResult(bodyStr, status); +} + +// Handle 428 sudo OTP challenge - prompts user for OTP and retries request +bool handleSudoChallenge(string method, string path, string bodyContent, string publicKey, string secretKey, string response) { + // Extract challenge_id from response + string challengeId = extractJsonField(response, "challenge_id"); + + stderr.writefln("%sConfirmation required. Check your email for a one-time code.%s", YELLOW, RESET); + stderr.write("Enter OTP: "); + stderr.flush(); + + import std.stdio : stdin; + string otp; + try { + otp = stdin.readln(); + if (otp is null) { + stderr.writefln("%sError: Failed to read OTP%s", RED, RESET); + return false; + } + otp = otp.strip(); + } catch (Exception e) { + stderr.writefln("%sError: Failed to read OTP%s", RED, RESET); + return false; + } + + if (otp.empty) { + stderr.writefln("%sError: Operation cancelled%s", RED, RESET); + return false; + } + + // Retry the request with sudo headers + string authHeaders = buildAuthHeaders(method, path, bodyContent, publicKey, secretKey); + authHeaders ~= format(" -H 'X-Sudo-OTP: %s'", otp); + if (!challengeId.empty) { + authHeaders ~= format(" -H 'X-Sudo-Challenge: %s'", challengeId); + } + + string cmd; + if (method == "DELETE") { + cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); + } else if (method == "POST") { + cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, bodyContent); + } else { + cmd = format(`curl -s -X %s '%s%s' %s`, method, API_BASE, path, authHeaders); + } + + auto retryResult = execCurlWithStatus(cmd); + + if (retryResult.status >= 200 && retryResult.status < 300) { + writefln("%sOperation completed successfully%s", GREEN, RESET); + return true; + } + + // Extract error message if available + string errorMsg = extractJsonField(retryResult.body, "error"); + if (!errorMsg.empty) { + stderr.writefln("%sError: %s%s", RED, errorMsg, RESET); + } else { + stderr.writefln("%sError: HTTP %d%s", RED, retryResult.status, RESET); + if (!retryResult.body.empty) stderr.writeln(retryResult.body); + } + return false; +} + +// Execute a destructive operation that may require sudo OTP confirmation +bool execDestructiveCurl(string method, string path, string bodyContent, string publicKey, string secretKey, string successMsg) { + string authHeaders = buildAuthHeaders(method, path, bodyContent, publicKey, secretKey); + + string cmd; + if (method == "DELETE") { + cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); + } else if (method == "POST" && !bodyContent.empty) { + cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, bodyContent); + } else if (method == "POST") { + cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + } else { + cmd = format(`curl -s -X %s '%s%s' %s`, method, API_BASE, path, authHeaders); + } + + auto result = execCurlWithStatus(cmd); + + // Handle 428 sudo challenge + if (result.status == 428) { + return handleSudoChallenge(method, path, bodyContent, publicKey, secretKey, result.body); + } + + if (result.status >= 200 && result.status < 300) { + if (!successMsg.empty) { + writefln("%s%s%s", GREEN, successMsg, RESET); + } + return true; + } + + if (result.status == 404) { + stderr.writefln("%sError: Not found%s", RED, RESET); + } else { + stderr.writefln("%sError: HTTP %d%s", RED, result.status, RESET); + if (!result.body.empty) stderr.writeln(result.body); + } + return false; +} + bool execCurlPut(string endpoint, string body, string publicKey, string secretKey) { import std.file : write, remove; import std.random : uniform; @@ -527,10 +666,7 @@ void cmdService(string name, string ports, string bootstrap, string bootstrapFil if (!destroy.empty) { string path = format("/services/%s", destroy); - string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X DELETE '%s/services/%s' %s`, API_BASE, destroy, authHeaders); - execCurl(cmd); - writefln("%sService destroyed: %s%s", GREEN, destroy, RESET); + execDestructiveCurl("DELETE", path, "", publicKey, secretKey, format("Service destroyed: %s", destroy)); return; } @@ -690,10 +826,7 @@ void cmdImage(bool list, string info, string del, string lock, string unlock, if (!del.empty) { string path = format("/images/%s", del); - string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X DELETE '%s/images/%s' %s`, API_BASE, del, authHeaders); - execCurl(cmd); - writefln("%sImage deleted: %s%s", GREEN, del, RESET); + execDestructiveCurl("DELETE", path, "", publicKey, secretKey, format("Image deleted: %s", del)); return; } @@ -709,11 +842,7 @@ void cmdImage(bool list, string info, string del, string lock, string unlock, if (!unlock.empty) { string path = format("/images/%s/unlock", unlock); - string json = "{}"; - string authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/images/%s/unlock' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, unlock, authHeaders, json); - execCurl(cmd); - writefln("%sImage unlocked: %s%s", GREEN, unlock, RESET); + execDestructiveCurl("POST", path, "{}", publicKey, secretKey, format("Image unlocked: %s", unlock)); return; } diff --git a/clients/dart/sync/src/un.dart b/clients/dart/sync/src/un.dart index 68b3950..ed4578c 100644 --- a/clients/dart/sync/src/un.dart +++ b/clients/dart/sync/src/un.dart @@ -281,6 +281,114 @@ Future> apiRequestCurl(String endpoint, String method, Stri } } +/// Make authenticated API request returning status code and body +Future<(int, String)> apiRequestCurlWithStatus(String endpoint, String method, String? jsonData, String publicKey, String? secretKey, {String? baseUrl, String? sudoOtp, String? sudoChallenge}) async { + final base = baseUrl ?? apiBase; + final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.json').create(); + + try { + final body = jsonData ?? ''; + if (jsonData != null) { + await tempFile.writeAsString(jsonData); + } + + final args = ['curl', '-s', '-w', '%{http_code}', '-X', method, '$base$endpoint', + '-H', 'Content-Type: application/json']; + + // Add HMAC authentication headers if secretKey is provided + if (secretKey != null && secretKey.isNotEmpty) { + final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + final message = '$timestamp:$method:$endpoint:$body'; + + final key = utf8.encode(secretKey); + final bytes = utf8.encode(message); + final hmacSha256 = Hmac(sha256, key); + final digest = hmacSha256.convert(bytes); + final signature = digest.toString(); + + args.addAll(['-H', 'Authorization: Bearer $publicKey']); + args.addAll(['-H', 'X-Timestamp: $timestamp']); + args.addAll(['-H', 'X-Signature: $signature']); + } else { + // Legacy API key authentication + args.addAll(['-H', 'Authorization: Bearer $publicKey']); + } + + // Add sudo OTP headers if provided + if (sudoOtp != null && sudoChallenge != null) { + args.addAll(['-H', 'X-Sudo-OTP: $sudoOtp']); + args.addAll(['-H', 'X-Sudo-Challenge: $sudoChallenge']); + } + + if (jsonData != null) { + args.addAll(['-d', '@${tempFile.path}']); + } + + final result = await Process.run(args[0], args.sublist(1)); + + if (result.exitCode != 0) { + throw Exception('curl failed: ${result.stderr}'); + } + + final output = result.stdout as String; + + // Extract status code from end of output (last 3 chars) + int statusCode = 0; + String responseBody = output; + if (output.length >= 3) { + final codeStr = output.substring(output.length - 3); + statusCode = int.tryParse(codeStr) ?? 0; + responseBody = output.substring(0, output.length - 3); + } + + return (statusCode, responseBody); + } finally { + await tempFile.delete(); + } +} + +/// Handle HTTP 428 sudo OTP challenge +/// Returns true if retry succeeded, false otherwise +Future handleSudoChallenge(String responseBody, String endpoint, String method, String? jsonData, String publicKey, String? secretKey) async { + // Extract challenge_id from response + String? challengeId; + try { + final resp = jsonDecode(responseBody) as Map; + challengeId = resp['challenge_id'] as String?; + } catch (e) { + stderr.writeln('${red}Error: Could not parse challenge response$reset'); + return false; + } + + if (challengeId == null || challengeId.isEmpty) { + stderr.writeln('${red}Error: Could not extract challenge_id from response$reset'); + return false; + } + + // Prompt user for OTP + stderr.writeln('${yellow}Confirmation required. Check your email for a one-time code.$reset'); + stderr.write('Enter OTP: '); + final otp = stdin.readLineSync()?.trim(); + + if (otp == null || otp.isEmpty) { + stderr.writeln('${red}Error: No OTP provided$reset'); + return false; + } + + // Retry request with sudo headers + final (statusCode, _) = await apiRequestCurlWithStatus( + endpoint, method, jsonData, publicKey, secretKey, + sudoOtp: otp, sudoChallenge: challengeId + ); + + if (statusCode >= 200 && statusCode < 300) { + return true; + } else { + stderr.writeln('${red}Error: OTP verification failed$reset'); + return false; + } +} + Future?> apiRequestTextCurl(String endpoint, String method, String body, String publicKey, String? secretKey) async { final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.txt').create(); @@ -668,8 +776,20 @@ Future cmdService(Args args) async { } if (args.serviceDestroy != null) { - await apiRequestCurl('/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey); - print('${green}Service destroyed: ${args.serviceDestroy}$reset'); + final (statusCode, responseBody) = await apiRequestCurlWithStatus('/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey); + if (statusCode == 428) { + if (await handleSudoChallenge(responseBody, '/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey)) { + print('${green}Service destroyed: ${args.serviceDestroy}$reset'); + } else { + stderr.writeln('${red}Error: Failed to destroy service (OTP verification failed)$reset'); + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + print('${green}Service destroyed: ${args.serviceDestroy}$reset'); + } else { + stderr.writeln('${red}Error: Failed to destroy service (HTTP $statusCode)$reset'); + exit(1); + } return; } @@ -855,8 +975,20 @@ Future cmdImage(Args args) async { } if (args.imageDelete != null) { - await apiRequestCurl('/images/${args.imageDelete}', 'DELETE', null, publicKey, secretKey); - print('${green}Image deleted: ${args.imageDelete}$reset'); + final (statusCode, responseBody) = await apiRequestCurlWithStatus('/images/${args.imageDelete}', 'DELETE', null, publicKey, secretKey); + if (statusCode == 428) { + if (await handleSudoChallenge(responseBody, '/images/${args.imageDelete}', 'DELETE', null, publicKey, secretKey)) { + print('${green}Image deleted: ${args.imageDelete}$reset'); + } else { + stderr.writeln('${red}Error: Failed to delete image (OTP verification failed)$reset'); + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + print('${green}Image deleted: ${args.imageDelete}$reset'); + } else { + stderr.writeln('${red}Error: Failed to delete image (HTTP $statusCode)$reset'); + exit(1); + } return; } @@ -867,8 +999,20 @@ Future cmdImage(Args args) async { } if (args.imageUnlock != null) { - await apiRequestCurl('/images/${args.imageUnlock}/unlock', 'POST', null, publicKey, secretKey); - print('${green}Image unlocked: ${args.imageUnlock}$reset'); + final (statusCode, responseBody) = await apiRequestCurlWithStatus('/images/${args.imageUnlock}/unlock', 'POST', null, publicKey, secretKey); + if (statusCode == 428) { + if (await handleSudoChallenge(responseBody, '/images/${args.imageUnlock}/unlock', 'POST', null, publicKey, secretKey)) { + print('${green}Image unlocked: ${args.imageUnlock}$reset'); + } else { + stderr.writeln('${red}Error: Failed to unlock image (OTP verification failed)$reset'); + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + print('${green}Image unlocked: ${args.imageUnlock}$reset'); + } else { + stderr.writeln('${red}Error: Failed to unlock image (HTTP $statusCode)$reset'); + exit(1); + } return; } diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs index a2a5cf7..522dd10 100644 --- a/clients/dotnet/sync/src/Un.cs +++ b/clients/dotnet/sync/src/Un.cs @@ -362,7 +362,7 @@ void CmdService(Args args) if (args.ServiceDestroy != null) { - ApiRequest($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequestWithSudo($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); return; } @@ -376,7 +376,7 @@ void CmdService(Args args) if (args.ServiceUnlock != null) { - ApiRequest($"/services/{args.ServiceUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + ApiRequestWithSudo($"/services/{args.ServiceUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service unlocked: {args.ServiceUnlock}{RESET}"); return; } @@ -544,7 +544,7 @@ void CmdSnapshot(Args args) if (args.SnapshotDelete != null) { - ApiRequest($"/snapshots/{args.SnapshotDelete}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequestWithSudo($"/snapshots/{args.SnapshotDelete}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Snapshot deleted: {args.SnapshotDelete}{RESET}"); return; } @@ -558,7 +558,7 @@ void CmdSnapshot(Args args) if (args.SnapshotUnlock != null) { - ApiRequest($"/snapshots/{args.SnapshotUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + ApiRequestWithSudo($"/snapshots/{args.SnapshotUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Snapshot unlocked: {args.SnapshotUnlock}{RESET}"); return; } @@ -608,7 +608,7 @@ void CmdImage(Args args) if (args.ImageDelete != null) { - ApiRequest($"/images/{args.ImageDelete}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequestWithSudo($"/images/{args.ImageDelete}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Image deleted: {args.ImageDelete}{RESET}"); return; } @@ -622,7 +622,7 @@ void CmdImage(Args args) if (args.ImageUnlock != null) { - ApiRequest($"/images/{args.ImageUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + ApiRequestWithSudo($"/images/{args.ImageUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Image unlocked: {args.ImageUnlock}{RESET}"); return; } @@ -731,7 +731,19 @@ void CmdLanguages(Args args) Console.WriteLine(lang); } -Dictionary ApiRequest(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) +// HTTP exception with status code for sudo handling +class HttpStatusException : Exception +{ + public int StatusCode { get; } + public string ResponseBody { get; } + public HttpStatusException(int statusCode, string responseBody) : base($"HTTP {statusCode}: {responseBody}") + { + StatusCode = statusCode; + ResponseBody = responseBody; + } +} + +Dictionary ApiRequest(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey, string? sudoOtp = null, string? sudoChallengeId = null) { var body = data != null ? JsonSerializer.Serialize(data, jsonOptions) : ""; @@ -754,6 +766,12 @@ Dictionary ApiRequest(string endpoint, HttpMethod method, Dictio request.Headers.Add("Authorization", $"Bearer {publicKey}"); } + // Add sudo OTP headers if provided + if (!string.IsNullOrEmpty(sudoOtp)) + request.Headers.Add("X-Sudo-OTP", sudoOtp); + if (!string.IsNullOrEmpty(sudoChallengeId)) + request.Headers.Add("X-Sudo-Challenge", sudoChallengeId); + // Synchronous HTTP call var response = httpClient.Send(request); using var reader = new StreamReader(response.Content.ReadAsStream()); @@ -767,7 +785,7 @@ Dictionary ApiRequest(string endpoint, HttpMethod method, Dictio Console.Error.WriteLine($"{YELLOW}Your computer's clock may have drifted.{RESET}"); Environment.Exit(1); } - throw new Exception($"HTTP {(int)response.StatusCode}: {responseBody}"); + throw new HttpStatusException((int)response.StatusCode, responseBody); } if (string.IsNullOrWhiteSpace(responseBody)) return new Dictionary(); @@ -783,6 +801,41 @@ Dictionary ApiRequest(string endpoint, HttpMethod method, Dictio } } +// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +Dictionary HandleSudoChallenge(string responseBody, string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) +{ + string? challengeId = null; + try + { + var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("challenge_id", out var cid)) + challengeId = cid.GetString(); + } + catch { } + + Console.Error.WriteLine($"{YELLOW}Confirmation required. Check your email for a one-time code.{RESET}"); + Console.Error.Write("Enter OTP: "); + + var otp = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(otp)) + throw new Exception("Operation cancelled"); + + return ApiRequest(endpoint, method, data, publicKey, secretKey, otp, challengeId); +} + +// Wrapper for destructive operations that may require 428 sudo OTP +Dictionary ApiRequestWithSudo(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) +{ + try + { + return ApiRequest(endpoint, method, data, publicKey, secretKey); + } + catch (HttpStatusException ex) when (ex.StatusCode == 428) + { + return HandleSudoChallenge(ex.ResponseBody, endpoint, method, data, publicKey, secretKey); + } +} + bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string secretKey) { if (envContent.Length > 65536) { Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}"); return false; } diff --git a/clients/elixir/sync/src/un.ex b/clients/elixir/sync/src/un.ex index f2a1751..9dc6ef9 100755 --- a/clients/elixir/sync/src/un.ex +++ b/clients/elixir/sync/src/un.ex @@ -240,8 +240,17 @@ defmodule Un do defp service_command(["--destroy", service_id | _]) do api_key = get_api_key() - curl_delete(api_key, "/services/#{service_id}") - IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") + case curl_delete_with_sudo(api_key, "/services/#{service_id}") do + {:ok, _, _} -> + IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") + {:ok, _} -> + IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") + {:error, :cancelled} -> + System.halt(1) + {:error, msg} -> + IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") + System.halt(1) + end end defp service_command(["--resize", service_id | rest]) do @@ -461,8 +470,17 @@ defmodule Un do defp snapshot_command(["--delete", snapshot_id | _]) do api_key = get_api_key() - curl_delete(api_key, "/snapshots/#{snapshot_id}") - IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") + case curl_delete_with_sudo(api_key, "/snapshots/#{snapshot_id}") do + {:ok, _, _} -> + IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") + {:ok, _} -> + IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") + {:error, :cancelled} -> + System.halt(1) + {:error, msg} -> + IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") + System.halt(1) + end end defp snapshot_command(["--clone", snapshot_id | rest]) do @@ -512,8 +530,17 @@ defmodule Un do defp image_command(["--delete", image_id | _]) do api_key = get_api_key() - curl_delete(api_key, "/images/#{image_id}") - IO.puts("#{@green}Image deleted: #{image_id}#{@reset}") + case curl_delete_with_sudo(api_key, "/images/#{image_id}") do + {:ok, _, _} -> + IO.puts("#{@green}Image deleted: #{image_id}#{@reset}") + {:ok, _} -> + IO.puts("#{@green}Image deleted: #{image_id}#{@reset}") + {:error, :cancelled} -> + System.halt(1) + {:error, msg} -> + IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") + System.halt(1) + end end defp image_command(["--lock", image_id | _]) do @@ -524,8 +551,17 @@ defmodule Un do defp image_command(["--unlock", image_id | _]) do api_key = get_api_key() - curl_post(api_key, "/images/#{image_id}/unlock", "{}") - IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}") + case curl_post_with_sudo(api_key, "/images/#{image_id}/unlock", "{}") do + {:ok, _, _} -> + IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}") + {:ok, _} -> + IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}") + {:error, :cancelled} -> + System.halt(1) + {:error, msg} -> + IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") + System.halt(1) + end end defp image_command(["--publish", source_id | rest]) do @@ -1141,6 +1177,116 @@ defmodule Un do System.halt(1) end end + + # Handle 428 sudo OTP challenge - prompts user for OTP and retries the request + defp handle_sudo_challenge(response, method, endpoint, body) do + challenge_id = extract_json_value(response, "challenge_id") + + IO.puts(:stderr, "#{@yellow}Confirmation required. Check your email for a one-time code.#{@reset}") + IO.write(:stderr, "Enter OTP: ") + + otp = IO.gets("") |> String.trim() + + if otp == "" do + IO.puts(:stderr, "#{@red}Error: Operation cancelled#{@reset}") + {:error, :cancelled} + else + # Retry the request with sudo headers + {public_key, secret_key} = get_api_keys() + body_str = body || "" + headers = build_auth_headers(public_key, secret_key, method, endpoint, body_str) + + # Add sudo headers + sudo_headers = ["-H", "X-Sudo-OTP: #{otp}"] + sudo_headers = if challenge_id do + sudo_headers ++ ["-H", "X-Sudo-Challenge: #{challenge_id}"] + else + sudo_headers + end + + args = case method do + "DELETE" -> + ["-s", "-X", "DELETE", "https://api.unsandbox.com#{endpoint}"] ++ headers ++ sudo_headers + "POST" -> + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, body_str) + result = ["-s", "-X", "POST", "https://api.unsandbox.com#{endpoint}", + "-H", "Content-Type: application/json"] ++ headers ++ sudo_headers ++ ["-d", "@#{tmp_file}"] + result + _ -> + ["-s", "https://api.unsandbox.com#{endpoint}"] ++ headers ++ sudo_headers + end + + {output, exit_code} = System.cmd("curl", args, stderr_to_stdout: true) + + # Clean up temp file for POST requests + if method == "POST" do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.rm(tmp_file) + end + + if exit_code == 0 and not String.contains?(output, "\"error\"") do + {:ok, output} + else + {:error, output} + end + end + end + + # Curl with 428 handling for destructive operations + defp curl_delete_with_sudo(api_key, endpoint) do + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "") + + args = ["-s", "-X", "DELETE", "-w", "\n%{http_code}", + "https://api.unsandbox.com#{endpoint}"] ++ headers + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + # Split response body and status code + lines = String.split(output, "\n") + {body_lines, [status_code]} = Enum.split(lines, -1) + body = Enum.join(body_lines, "\n") + http_code = String.to_integer(String.trim(status_code)) + + check_clock_drift(body) + + if http_code == 428 do + handle_sudo_challenge(body, "DELETE", endpoint, nil) + else + {:ok, body, http_code} + end + end + + defp curl_post_with_sudo(api_key, endpoint, json) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, json) + + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) + + args = ["-s", "-X", "POST", "-w", "\n%{http_code}", + "https://api.unsandbox.com#{endpoint}", + "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + File.rm(tmp_file) + + # Split response body and status code + lines = String.split(output, "\n") + {body_lines, [status_code]} = Enum.split(lines, -1) + body = Enum.join(body_lines, "\n") + http_code = String.to_integer(String.trim(status_code)) + + check_clock_drift(body) + + if http_code == 428 do + handle_sudo_challenge(body, "POST", endpoint, json) + else + {:ok, body, http_code} + end + end end Un.main(System.argv()) diff --git a/clients/erlang/sync/src/un.erl b/clients/erlang/sync/src/un.erl index 9bf14cb..235f299 100755 --- a/clients/erlang/sync/src/un.erl +++ b/clients/erlang/sync/src/un.erl @@ -218,8 +218,17 @@ service_command(["--unfreeze", ServiceId | _]) -> service_command(["--destroy", ServiceId | _]) -> ApiKey = get_api_key(), - _ = curl_delete(ApiKey, "/services/" ++ ServiceId), - io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); + case curl_delete_with_sudo(ApiKey, "/services/" ++ ServiceId) of + {ok, _, _} -> + io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); + {ok, _} -> + io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); + {error, cancelled} -> + halt(1); + {error, Msg} -> + io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), + halt(1) + end; service_command(["--resize", ServiceId, "--vcpu", VcpuStr | _]) -> service_resize(ServiceId, VcpuStr); @@ -395,8 +404,17 @@ snapshot_command(["--info", SnapshotId | _]) -> snapshot_command(["--delete", SnapshotId | _]) -> ApiKey = get_api_key(), - _ = curl_delete(ApiKey, "/snapshots/" ++ SnapshotId), - io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]); + case curl_delete_with_sudo(ApiKey, "/snapshots/" ++ SnapshotId) of + {ok, _, _} -> + io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]); + {ok, _} -> + io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]); + {error, cancelled} -> + halt(1); + {error, Msg} -> + io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), + halt(1) + end; snapshot_command(["--clone", SnapshotId | Rest]) -> ApiKey = get_api_key(), @@ -466,10 +484,20 @@ image_command(["--info", ImageId | _]) -> halt(0); image_command(["--delete", ImageId | _]) -> - {PublicKey, SecretKey} = get_api_keys(), - api_request("/images/" ++ ImageId, "DELETE", "", PublicKey, SecretKey), - io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]), - halt(0); + ApiKey = get_api_key(), + case curl_delete_with_sudo(ApiKey, "/images/" ++ ImageId) of + {ok, _, _} -> + io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]), + halt(0); + {ok, _} -> + io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]), + halt(0); + {error, cancelled} -> + halt(1); + {error, Msg} -> + io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), + halt(1) + end; image_command(["--lock", ImageId | _]) -> {PublicKey, SecretKey} = get_api_keys(), @@ -478,10 +506,20 @@ image_command(["--lock", ImageId | _]) -> halt(0); image_command(["--unlock", ImageId | _]) -> - {PublicKey, SecretKey} = get_api_keys(), - api_request("/images/" ++ ImageId ++ "/unlock", "POST", "", PublicKey, SecretKey), - io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]), - halt(0); + ApiKey = get_api_key(), + case curl_post_with_sudo(ApiKey, "/images/" ++ ImageId ++ "/unlock", "{}") of + {ok, _, _} -> + io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]), + halt(0); + {ok, _} -> + io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]), + halt(0); + {error, cancelled} -> + halt(1); + {error, Msg} -> + io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), + halt(1) + end; image_command(["--publish", SourceId | Rest]) -> SourceType = get_image_source_type(Rest), @@ -918,6 +956,101 @@ curl_delete(ApiKey, Endpoint) -> check_clock_drift_error(Result), Result. +%% Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +handle_sudo_challenge(Response, Method, Endpoint, Body) -> + ChallengeId = extract_json_field(Response, "challenge_id"), + io:format(standard_error, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m~n", []), + io:format(standard_error, "Enter OTP: ", []), + case io:get_line("") of + eof -> + io:format(standard_error, "Error: Failed to read OTP~n", []), + {error, cancelled}; + OtpRaw -> + Otp = string:trim(OtpRaw), + case Otp of + "" -> + io:format(standard_error, "Error: Operation cancelled~n", []), + {error, cancelled}; + _ -> + %% Retry the request with sudo headers + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, Method, Endpoint, Body), + SudoHeaders = " -H 'X-Sudo-OTP: " ++ Otp ++ "'", + ChallengeHeader = case ChallengeId of + "" -> ""; + _ -> " -H 'X-Sudo-Challenge: " ++ ChallengeId ++ "'" + end, + Cmd = case Method of + "DELETE" -> + "curl -s -X DELETE https://api.unsandbox.com" ++ Endpoint ++ + AuthHeaders ++ SudoHeaders ++ ChallengeHeader; + "POST" -> + TmpFile = write_temp_file(Body), + Result = "curl -s -X POST https://api.unsandbox.com" ++ Endpoint ++ + " -H 'Content-Type: application/json'" ++ + AuthHeaders ++ SudoHeaders ++ ChallengeHeader ++ + " -d @" ++ TmpFile, + file:delete(TmpFile), + Result; + _ -> + "curl -s https://api.unsandbox.com" ++ Endpoint ++ + AuthHeaders ++ SudoHeaders ++ ChallengeHeader + end, + RetryResult = os:cmd(Cmd), + case string:str(RetryResult, "\"error\"") of + 0 -> {ok, RetryResult}; + _ -> {error, RetryResult} + end + end + end. + +%% Curl with 428 handling for destructive operations +curl_delete_with_sudo(ApiKey, Endpoint) -> + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""), + Cmd = "curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com" ++ Endpoint ++ + AuthHeaders, + Result = os:cmd(Cmd), + %% Split response and status code + Lines = string:split(Result, "\n", all), + case lists:reverse(Lines) of + [StatusCodeStr | BodyLinesRev] -> + StatusCode = list_to_integer(string:trim(StatusCodeStr)), + Body = string:join(lists:reverse(BodyLinesRev), "\n"), + check_clock_drift_error(Body), + case StatusCode of + 428 -> handle_sudo_challenge(Body, "DELETE", Endpoint, ""); + _ -> {ok, Body, StatusCode} + end; + _ -> + {ok, Result, 200} + end. + +curl_post_with_sudo(ApiKey, Endpoint, Json) -> + TmpFile = write_temp_file(Json), + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json), + Cmd = "curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com" ++ Endpoint ++ + " -H 'Content-Type: application/json'" ++ + AuthHeaders ++ + " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + file:delete(TmpFile), + %% Split response and status code + Lines = string:split(Result, "\n", all), + case lists:reverse(Lines) of + [StatusCodeStr | BodyLinesRev] -> + StatusCode = list_to_integer(string:trim(StatusCodeStr)), + Body = string:join(lists:reverse(BodyLinesRev), "\n"), + check_clock_drift_error(Body), + case StatusCode of + 428 -> handle_sudo_challenge(Body, "POST", Endpoint, Json); + _ -> {ok, Body, StatusCode} + end; + _ -> + {ok, Result, 200} + end. + curl_patch(ApiKey, Endpoint, TmpFile) -> {ok, Body} = file:read_file(TmpFile), BodyStr = binary_to_list(Body), diff --git a/clients/forth/sync/src/un.forth b/clients/forth/sync/src/un.forth index e839e10..dbf93a2 100644 --- a/clients/forth/sync/src/un.forth +++ b/clients/forth/sync/src/un.forth @@ -355,9 +355,30 @@ s" TIMESTAMP=$(date +%s)" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID:\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" curl -s -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mService destroyed: " r@ write-file throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw + s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw + s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw + s" echo -n 'Enter OTP: ' >&2" r@ write-line throw + s" read OTP" r@ write-line throw + s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\")" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" fi" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw + s" echo -e '\\x1b[32mService destroyed: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw + s" else" r@ write-line throw + s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw + s" echo \"$BODY\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -1120,9 +1141,30 @@ s" TIMESTAMP=$(date +%s)" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:DELETE:/images/$IMAGE_ID:\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" curl -s -X DELETE https://api.unsandbox.com/images/$IMAGE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mImage deleted: " r@ write-file throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/images/$IMAGE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw + s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw + s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw + s" echo -n 'Enter OTP: ' >&2" r@ write-line throw + s" read OTP" r@ write-line throw + s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:DELETE:/images/$IMAGE_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/images/$IMAGE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\")" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" fi" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw + s" echo -e '\\x1b[32mImage deleted: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw + s" else" r@ write-line throw + s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw + s" echo \"$BODY\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -1166,11 +1208,32 @@ get-secret-key r@ write-file throw s" '" r@ write-line throw s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/unlock:\"" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/unlock:{}\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/images/$IMAGE_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mImage unlocked: " r@ write-file throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/images/$IMAGE_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -d '{}')" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw + s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw + s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw + s" echo -n 'Enter OTP: ' >&2" r@ write-line throw + s" read OTP" r@ write-line throw + s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/unlock:{}\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/images/$IMAGE_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\" -d '{}')" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" fi" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw + s" echo -e '\\x1b[32mImage unlocked: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw + s" else" r@ write-line throw + s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw + s" echo \"$BODY\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; diff --git a/clients/fortran/sync/src/un.f90 b/clients/fortran/sync/src/un.f90 index 6f597ad..61f0e21 100644 --- a/clients/fortran/sync/src/un.f90 +++ b/clients/fortran/sync/src/un.f90 @@ -1402,15 +1402,33 @@ contains 'echo -e "\x1b[32mService unfreezing: ', trim(service_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'destroy' .and. len_trim(service_id) > 0) then - write(full_cmd, '(20A)') & + write(full_cmd, '(50A)') & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:DELETE:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X DELETE https://api.unsandbox.com/services/', & + 'RESP=$(curl -s -w "\n%{http_code}" -X DELETE https://api.unsandbox.com/services/', & trim(service_id), ' ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" >/dev/null && ', & - 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"' + '-H "X-Signature: $SIG"); ', & + 'HTTP_CODE=$(echo "$RESP" | tail -n1); ', & + 'BODY=$(echo "$RESP" | sed ''$d''); ', & + 'if [ "$HTTP_CODE" = "428" ]; then ', & + 'CHALLENGE_ID=$(echo "$BODY" | jq -r ".challenge_id // empty"); ', & + 'echo -e "\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m" >&2; ', & + 'echo -n "Enter OTP: " >&2; read OTP; ', & + 'if [ -z "$OTP" ]; then echo -e "\x1b[31mError: Operation cancelled\x1b[0m" >&2; exit 1; fi; ', & + 'TS2=$(date +%s); ', & + 'SIG2=$(echo -n "$TS2:DELETE:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X DELETE https://api.unsandbox.com/services/', trim(service_id), ' ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS2" ', & + '-H "X-Signature: $SIG2" ', & + '-H "X-Sudo-OTP: $OTP" ', & + '-H "X-Sudo-Challenge: $CHALLENGE_ID" >/dev/null && ', & + 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"; ', & + 'elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then ', & + 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"; ', & + 'else echo -e "\x1b[31mError: HTTP $HTTP_CODE\x1b[0m" >&2; echo "$BODY" >&2; exit 1; fi' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'resize' .and. len_trim(service_id) > 0) then if (resize_vcpu < 1 .or. resize_vcpu > 8) then @@ -1615,13 +1633,29 @@ contains '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" | jq .' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'delete' .and. len_trim(image_id) > 0) then - write(full_cmd, '(20A)') & + write(full_cmd, '(50A)') & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:DELETE:/images/', trim(image_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -w "\n%{http_code}" -X DELETE https://api.unsandbox.com/images/', trim(image_id), ' ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" -H "X-Signature: $SIG"); ', & + 'HTTP_CODE=$(echo "$RESP" | tail -n1); ', & + 'BODY=$(echo "$RESP" | sed ''$d''); ', & + 'if [ "$HTTP_CODE" = "428" ]; then ', & + 'CHALLENGE_ID=$(echo "$BODY" | jq -r ".challenge_id // empty"); ', & + 'echo -e "\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m" >&2; ', & + 'echo -n "Enter OTP: " >&2; read OTP; ', & + 'if [ -z "$OTP" ]; then echo -e "\x1b[31mError: Operation cancelled\x1b[0m" >&2; exit 1; fi; ', & + 'TS2=$(date +%s); ', & + 'SIG2=$(echo -n "$TS2:DELETE:/images/', trim(image_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & 'curl -s -X DELETE https://api.unsandbox.com/images/', trim(image_id), ' ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" >/dev/null; ', & - 'echo -e "\x1b[32mImage deleted: ', trim(image_id), '\x1b[0m"' + '-H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" ', & + '-H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID" >/dev/null && ', & + 'echo -e "\x1b[32mImage deleted: ', trim(image_id), '\x1b[0m"; ', & + 'elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then ', & + 'echo -e "\x1b[32mImage deleted: ', trim(image_id), '\x1b[0m"; ', & + 'else echo -e "\x1b[31mError: HTTP $HTTP_CODE\x1b[0m" >&2; echo "$BODY" >&2; exit 1; fi' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'lock' .and. len_trim(image_id) > 0) then write(full_cmd, '(20A)') & @@ -1633,13 +1667,31 @@ contains 'echo -e "\x1b[32mImage locked: ', trim(image_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'unlock' .and. len_trim(image_id) > 0) then - write(full_cmd, '(20A)') & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:POST:/images/', trim(image_id), '/unlock:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X POST https://api.unsandbox.com/images/', trim(image_id), '/unlock ', & + write(full_cmd, '(50A)') & + 'TS=$(date +%s); BODY="{}"; ', & + 'SIG=$(echo -n "$TS:POST:/images/', trim(image_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -w "\n%{http_code}" -X POST https://api.unsandbox.com/images/', trim(image_id), '/unlock ', & + '-H "Content-Type: application/json" ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" >/dev/null; ', & - 'echo -e "\x1b[32mImage unlocked: ', trim(image_id), '\x1b[0m"' + '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" -d "$BODY"); ', & + 'HTTP_CODE=$(echo "$RESP" | tail -n1); ', & + 'RESPBODY=$(echo "$RESP" | sed ''$d''); ', & + 'if [ "$HTTP_CODE" = "428" ]; then ', & + 'CHALLENGE_ID=$(echo "$RESPBODY" | jq -r ".challenge_id // empty"); ', & + 'echo -e "\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m" >&2; ', & + 'echo -n "Enter OTP: " >&2; read OTP; ', & + 'if [ -z "$OTP" ]; then echo -e "\x1b[31mError: Operation cancelled\x1b[0m" >&2; exit 1; fi; ', & + 'TS2=$(date +%s); ', & + 'SIG2=$(echo -n "$TS2:POST:/images/', trim(image_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/images/', trim(image_id), '/unlock ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" ', & + '-H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID" -d "$BODY" >/dev/null && ', & + 'echo -e "\x1b[32mImage unlocked: ', trim(image_id), '\x1b[0m"; ', & + 'elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then ', & + 'echo -e "\x1b[32mImage unlocked: ', trim(image_id), '\x1b[0m"; ', & + 'else echo -e "\x1b[31mError: HTTP $HTTP_CODE\x1b[0m" >&2; echo "$RESPBODY" >&2; exit 1; fi' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'publish' .and. len_trim(image_id) > 0) then if (len_trim(source_type) == 0) then diff --git a/clients/fsharp/sync/src/un.fs b/clients/fsharp/sync/src/un.fs index b1087d7..c196c60 100644 --- a/clients/fsharp/sync/src/un.fs +++ b/clients/fsharp/sync/src/un.fs @@ -272,7 +272,10 @@ let parseJson (json: string) = result |> Seq.map (fun (k, v) -> k, v) |> Map.ofSeq -let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = +// Custom exception for HTTP errors with status code +exception HttpException of int * string + +let apiRequestWithHeaders (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) (sudoOtp: string option) (sudoChallengeId: string option) = ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest @@ -298,6 +301,15 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op // Legacy API key authentication request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + // Add sudo OTP headers if provided + match sudoOtp with + | Some otp -> request.Headers.Add("X-Sudo-OTP", otp) + | None -> () + + match sudoChallengeId with + | Some cid -> request.Headers.Add("X-Sudo-Challenge", cid) + | None -> () + match data with | Some d -> let bytes = Encoding.UTF8.GetBytes(body) @@ -322,6 +334,13 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op else ex.Message + let statusCode = + if ex.Response <> null then + let httpResponse = ex.Response :?> HttpWebResponse + int httpResponse.StatusCode + else + 0 + // Check for clock drift error if errorMsg.Contains("timestamp") && (errorMsg.Contains("401") || errorMsg.Contains("expired") || errorMsg.Contains("invalid")) then eprintfn "%sError: Request timestamp expired (must be within 5 minutes of server time)%s" red reset @@ -332,6 +351,35 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op eprintfn " Windows: w32tm /resync%s" reset exit 1 + raise (HttpException(statusCode, errorMsg)) + +let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = + apiRequestWithHeaders endpoint method data publicKey secretKey None None + +// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +let handleSudoChallenge (responseBody: string) (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = + let challengeId = extractJsonValue responseBody "challenge_id" + + eprintfn "%sConfirmation required. Check your email for a one-time code.%s" yellow reset + eprintf "Enter OTP: " + + let otp = Console.ReadLine() + if String.IsNullOrEmpty(otp) then + failwith "Operation cancelled" + + let otp = otp.Trim() + + // Retry the request with sudo headers + apiRequestWithHeaders endpoint method data publicKey secretKey (Some otp) challengeId + +// Wrapper for destructive operations that may require 428 sudo OTP +let apiRequestWithSudo (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = + try + apiRequest endpoint method data publicKey secretKey + with + | HttpException(428, responseBody) -> + handleSudoChallenge responseBody endpoint method data publicKey secretKey + | HttpException(_, errorMsg) -> failwithf "HTTP error - %s" errorMsg let apiRequestPatch (endpoint: string) (data: (string * obj) list) (publicKey: string) (secretKey: string) = @@ -833,13 +881,13 @@ let cmdImage (args: Args) = let result = apiRequest (sprintf "/images/%s" args.ImageInfo.Value) "GET" None publicKey secretKey printfn "%s" (toJson (box result)) elif args.ImageDelete.IsSome then - let result = apiRequest (sprintf "/images/%s" args.ImageDelete.Value) "DELETE" None publicKey secretKey + let result = apiRequestWithSudo (sprintf "/images/%s" args.ImageDelete.Value) "DELETE" None publicKey secretKey printfn "%sImage deleted: %s%s" green args.ImageDelete.Value reset elif args.ImageLock.IsSome then let result = apiRequest (sprintf "/images/%s/lock" args.ImageLock.Value) "POST" None publicKey secretKey printfn "%sImage locked: %s%s" green args.ImageLock.Value reset elif args.ImageUnlock.IsSome then - let result = apiRequest (sprintf "/images/%s/unlock" args.ImageUnlock.Value) "POST" None publicKey secretKey + let result = apiRequestWithSudo (sprintf "/images/%s/unlock" args.ImageUnlock.Value) "POST" None publicKey secretKey printfn "%sImage unlocked: %s%s" green args.ImageUnlock.Value reset elif args.ImagePublish.IsSome then if args.ImageSourceType.IsNone then @@ -889,7 +937,7 @@ let cmdSnapshot (args: Args) = let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotInfo.Value) "GET" None publicKey secretKey printfn "%s" (toJson (box result)) elif args.SnapshotDelete.IsSome then - let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotDelete.Value) "DELETE" None publicKey secretKey + let result = apiRequestWithSudo (sprintf "/snapshots/%s" args.SnapshotDelete.Value) "DELETE" None publicKey secretKey printfn "%sSnapshot deleted: %s%s" green args.SnapshotDelete.Value reset elif args.SnapshotClone.IsSome then if args.SnapshotType.IsNone then @@ -954,7 +1002,7 @@ let cmdService (args: Args) = let result = apiRequest (sprintf "/services/%s/unfreeze" args.ServiceWake.Value) "POST" None publicKey secretKey printfn "%sService unfreezing: %s%s" green args.ServiceWake.Value reset elif args.ServiceDestroy.IsSome then - let result = apiRequest (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey + let result = apiRequestWithSudo (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey printfn "%sService destroyed: %s%s" green args.ServiceDestroy.Value reset elif args.ServiceResize.IsSome then if args.Vcpu <= 0 then diff --git a/clients/go/sync/src/un.go b/clients/go/sync/src/un.go index 4bf5cba..4b858a5 100644 --- a/clients/go/sync/src/un.go +++ b/clients/go/sync/src/un.go @@ -80,6 +80,7 @@ Languages Cache: package un import ( + "bufio" "bytes" "crypto/hmac" "crypto/sha256" @@ -343,6 +344,136 @@ func makeRequest(method, path string, creds *Credentials, data interface{}) (map return result, nil } +// SudoChallengeError represents a 428 response requiring OTP confirmation +type SudoChallengeError struct { + ChallengeID string + Message string + StatusCode int + Body []byte +} + +func (e *SudoChallengeError) Error() string { + return fmt.Sprintf("HTTP 428: sudo challenge required (challenge_id: %s)", e.ChallengeID) +} + +// makeRequestWithSudo makes an authenticated HTTP request with optional sudo headers +func makeRequestWithSudo(method, path string, creds *Credentials, data interface{}, sudoOTP, sudoChallengeID string) (map[string]interface{}, int, []byte, error) { + url := APIBase + path + timestamp := time.Now().Unix() + + var body []byte + var err error + if data != nil { + body, err = json.Marshal(data) + if err != nil { + return nil, 0, nil, err + } + } + + signature := signRequest(creds.SecretKey, timestamp, method, path, body) + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, 0, nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", creds.PublicKey)) + req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp)) + req.Header.Set("X-Signature", signature) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "un-go/2.0") + + // Add sudo headers if provided + if sudoOTP != "" { + req.Header.Set("X-Sudo-OTP", sudoOTP) + } + if sudoChallengeID != "" { + req.Header.Set("X-Sudo-Challenge", sudoChallengeID) + } + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, 0, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, resp.StatusCode, respBody, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, resp.StatusCode, respBody, fmt.Errorf("failed to parse response: %w", err) + } + + return result, resp.StatusCode, respBody, nil +} + +// handleSudoChallenge handles 428 sudo OTP challenge - prompts user for OTP and retries request +func handleSudoChallenge(method, path string, creds *Credentials, data interface{}, responseBody []byte) (map[string]interface{}, error) { + // Extract challenge_id from response + var resp map[string]interface{} + if err := json.Unmarshal(responseBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse 428 response: %w", err) + } + + challengeID, _ := resp["challenge_id"].(string) + + // Prompt user for OTP + fmt.Fprintf(os.Stderr, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m\n") + fmt.Fprintf(os.Stderr, "Enter OTP: ") + + reader := bufio.NewReader(os.Stdin) + otp, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read OTP: %w", err) + } + otp = strings.TrimSpace(otp) + + if otp == "" { + return nil, fmt.Errorf("operation cancelled") + } + + // Retry the request with sudo headers + result, statusCode, retryBody, err := makeRequestWithSudo(method, path, creds, data, otp, challengeID) + if err != nil { + if statusCode >= 200 && statusCode < 300 { + return result, nil + } + // Extract error message from response if available + if retryBody != nil { + var errResp map[string]interface{} + if json.Unmarshal(retryBody, &errResp) == nil { + if errMsg, ok := errResp["error"].(string); ok { + return nil, fmt.Errorf("%s", errMsg) + } + } + } + return nil, err + } + + fmt.Fprintf(os.Stderr, "\033[32mOperation completed successfully\033[0m\n") + return result, nil +} + +// makeDestructiveRequest makes a request that may require sudo OTP confirmation (for 428 responses) +func makeDestructiveRequest(method, path string, creds *Credentials, data interface{}) (map[string]interface{}, error) { + result, statusCode, respBody, err := makeRequestWithSudo(method, path, creds, data, "", "") + if statusCode == 428 { + return handleSudoChallenge(method, path, creds, data, respBody) + } + if err != nil { + return nil, err + } + return result, nil +} + // getLanguagesCachePath returns path to languages cache file func getLanguagesCachePath() (string, error) { unsandboxDir, err := getUnsandboxDir() @@ -646,8 +777,9 @@ func RestoreSnapshot(creds *Credentials, snapshotID string) (map[string]interfac } // DeleteSnapshot deletes a snapshot (NEW) +// This operation may require sudo OTP confirmation (428 response handling) func DeleteSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { - return makeRequest("DELETE", fmt.Sprintf("/snapshots/%s", snapshotID), creds, nil) + return makeDestructiveRequest("DELETE", fmt.Sprintf("/snapshots/%s", snapshotID), creds, nil) } // ============================================================================ @@ -855,8 +987,9 @@ func UpdateService(creds *Credentials, serviceID string, opts *ServiceUpdateOpti } // DeleteService destroys a service. +// This operation may require sudo OTP confirmation (428 response handling) func DeleteService(creds *Credentials, serviceID string) (map[string]interface{}, error) { - return makeRequest("DELETE", fmt.Sprintf("/services/%s", serviceID), creds, nil) + return makeDestructiveRequest("DELETE", fmt.Sprintf("/services/%s", serviceID), creds, nil) } // FreezeService freezes a service (pauses execution, preserves state). @@ -875,8 +1008,9 @@ func LockService(creds *Credentials, serviceID string) (map[string]interface{}, } // UnlockService unlocks a previously locked service. +// This operation may require sudo OTP confirmation (428 response handling) func UnlockService(creds *Credentials, serviceID string) (map[string]interface{}, error) { - return makeRequest("POST", fmt.Sprintf("/services/%s/unlock", serviceID), creds, map[string]interface{}{}) + return makeDestructiveRequest("POST", fmt.Sprintf("/services/%s/unlock", serviceID), creds, map[string]interface{}{}) } // SetUnfreezeOnDemand enables or disables automatic unfreezing on HTTP request. @@ -972,8 +1106,9 @@ func LockSnapshot(creds *Credentials, snapshotID string) (map[string]interface{} } // UnlockSnapshot unlocks a previously locked snapshot. +// This operation may require sudo OTP confirmation (428 response handling) func UnlockSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { - return makeRequest("POST", fmt.Sprintf("/snapshots/%s/unlock", snapshotID), creds, map[string]interface{}{}) + return makeDestructiveRequest("POST", fmt.Sprintf("/snapshots/%s/unlock", snapshotID), creds, map[string]interface{}{}) } // CloneSnapshotOptions contains optional parameters for snapshot cloning. @@ -1181,8 +1316,9 @@ func GetImage(creds *Credentials, imageID string) (map[string]interface{}, error // DeleteImage deletes an LXD container image. // // Note: Locked images cannot be deleted. Use UnlockImage first if needed. +// This operation may require sudo OTP confirmation (428 response handling) func DeleteImage(creds *Credentials, imageID string) (map[string]interface{}, error) { - return makeRequest("DELETE", fmt.Sprintf("/images/%s", imageID), creds, nil) + return makeDestructiveRequest("DELETE", fmt.Sprintf("/images/%s", imageID), creds, nil) } // LockImage locks an LXD container image to prevent deletion. @@ -1191,8 +1327,9 @@ func LockImage(creds *Credentials, imageID string) (map[string]interface{}, erro } // UnlockImage unlocks a previously locked LXD container image. +// This operation may require sudo OTP confirmation (428 response handling) func UnlockImage(creds *Credentials, imageID string) (map[string]interface{}, error) { - return makeRequest("POST", fmt.Sprintf("/images/%s/unlock", imageID), creds, map[string]interface{}{}) + return makeDestructiveRequest("POST", fmt.Sprintf("/images/%s/unlock", imageID), creds, map[string]interface{}{}) } // SetImageVisibility sets the visibility of an LXD container image. diff --git a/clients/groovy/sync/src/un.groovy b/clients/groovy/sync/src/un.groovy index 952981e..65a1fdb 100644 --- a/clients/groovy/sync/src/un.groovy +++ b/clients/groovy/sync/src/un.groovy @@ -361,6 +361,177 @@ def apiRequestPatch(endpoint, data, publicKey, secretKey) { return apiRequest(endpoint, 'PATCH', data, publicKey, secretKey) } +/** + * Exception for 428 Sudo Challenge requiring OTP confirmation. + */ +class SudoChallengeError extends UnsandboxError { + String challengeId + String responseBody + + SudoChallengeError(String challengeId, String responseBody) { + super("Sudo challenge required") + this.challengeId = challengeId + this.responseBody = responseBody + } +} + +/** + * Make API request for destructive operations with 428 handling. + * Uses curl with -w to capture HTTP status code. + */ +def apiRequestDestructive(String endpoint, String method, data, String publicKey, String secretKey) { + def tempFile = File.createTempFile('un_request_', '.json') + def statusFile = File.createTempFile('un_status_', '.txt') + try { + def body = "" + if (data) { + body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() + tempFile.text = body + } + + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, method, endpoint, body) + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', "Content-Type: application/json", + '-H', "Authorization: Bearer ${publicKey}", + '-H', "X-Timestamp: ${timestamp}", + '-H', "X-Signature: ${signature}", + '-w', '\\n%{http_code}', + '-o', statusFile.absolutePath] + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def statusOutput = proc.text.trim() + proc.waitFor() + + def responseBody = statusFile.exists() ? statusFile.text : "" + def httpCode = 0 + try { + httpCode = statusOutput.toInteger() + } catch (Exception e) { + // Failed to parse status code + } + + if (httpCode == 428) { + // Extract challenge_id from response + def challengeId = null + try { + def parsed = new JsonSlurper().parseText(responseBody) + challengeId = parsed?.challenge_id + } catch (Exception e) { + // Ignore parse errors + } + throw new SudoChallengeError(challengeId, responseBody) + } + + if (httpCode < 200 || httpCode >= 300) { + throw new APIError("HTTP ${httpCode} - ${responseBody}", httpCode, responseBody) + } + + try { + return new JsonSlurper().parseText(responseBody) + } catch (Exception e) { + return [raw: responseBody] + } + } finally { + tempFile.delete() + statusFile.delete() + } +} + +/** + * Make API request with sudo OTP headers. + */ +def apiRequestWithSudo(String endpoint, String method, data, String publicKey, String secretKey, String otp, String challengeId) { + def tempFile = File.createTempFile('un_request_', '.json') + def statusFile = File.createTempFile('un_status_', '.txt') + try { + def body = "" + if (data) { + body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() + tempFile.text = body + } + + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, method, endpoint, body) + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', "Content-Type: application/json", + '-H', "Authorization: Bearer ${publicKey}", + '-H', "X-Timestamp: ${timestamp}", + '-H', "X-Signature: ${signature}", + '-H', "X-Sudo-OTP: ${otp}", + '-w', '\\n%{http_code}', + '-o', statusFile.absolutePath] + + if (challengeId) { + curlCmd += ['-H', "X-Sudo-Challenge: ${challengeId}"] + } + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def statusOutput = proc.text.trim() + proc.waitFor() + + def responseBody = statusFile.exists() ? statusFile.text : "" + def httpCode = 0 + try { + httpCode = statusOutput.toInteger() + } catch (Exception e) { + // Failed to parse status code + } + + if (httpCode < 200 || httpCode >= 300) { + throw new APIError("HTTP ${httpCode} - ${responseBody}", httpCode, responseBody) + } + + try { + return new JsonSlurper().parseText(responseBody) + } catch (Exception e) { + return [raw: responseBody] + } + } finally { + tempFile.delete() + statusFile.delete() + } +} + +/** + * Handle sudo challenge by prompting for OTP and retrying. + */ +def handleSudoChallenge(String challengeId, String method, String endpoint, data, String publicKey, String secretKey) { + System.err.println("${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}") + System.err.print("Enter OTP: ") + System.err.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def otp = reader.readLine()?.trim() + + if (!otp) { + throw new RuntimeException("Operation cancelled - no OTP provided") + } + + return apiRequestWithSudo(endpoint, method, data, publicKey, secretKey, otp, challengeId) +} + +/** + * Execute a destructive operation with 428 sudo challenge handling. + */ +def executeDestructive(String endpoint, String method, data, String publicKey, String secretKey) { + try { + return apiRequestDestructive(endpoint, method, data, publicKey, secretKey) + } catch (SudoChallengeError e) { + return handleSudoChallenge(e.challengeId, method, endpoint, data, publicKey, secretKey) + } +} + def apiRequestText(endpoint, method, body, publicKey, secretKey) { def tempFile = File.createTempFile('un_env_', '.txt') try { @@ -1286,7 +1457,7 @@ def cmdSnapshot(args) { } if (args.snapshotDelete) { - apiRequest("/snapshots/${args.snapshotDelete}", 'DELETE', null, publicKey, secretKey) + executeDestructive("/snapshots/${args.snapshotDelete}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Snapshot deleted: ${args.snapshotDelete}${RESET}") return } @@ -1326,7 +1497,7 @@ def cmdImage(args) { } if (args.imageDelete) { - apiRequest("/images/${args.imageDelete}", 'DELETE', null, publicKey, secretKey) + executeDestructive("/images/${args.imageDelete}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Image deleted: ${args.imageDelete}${RESET}") return } @@ -1338,7 +1509,7 @@ def cmdImage(args) { } if (args.imageUnlock) { - apiRequest("/images/${args.imageUnlock}/unlock", 'POST', null, publicKey, secretKey) + executeDestructive("/images/${args.imageUnlock}/unlock", 'POST', null, publicKey, secretKey) println("${GREEN}Image unlocked: ${args.imageUnlock}${RESET}") return } @@ -1545,7 +1716,7 @@ def cmdService(args) { } if (args.serviceDestroy) { - apiRequest("/services/${args.serviceDestroy}", 'DELETE', null, publicKey, secretKey) + executeDestructive("/services/${args.serviceDestroy}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Service destroyed: ${args.serviceDestroy}${RESET}") return } diff --git a/clients/haskell/sync/src/un.hs b/clients/haskell/sync/src/un.hs index a02d130..79b417e 100644 --- a/clients/haskell/sync/src/un.hs +++ b/clients/haskell/sync/src/un.hs @@ -59,7 +59,7 @@ import System.Environment (getArgs, getEnv, lookupEnv) import System.Exit (exitWith, ExitCode(..), exitFailure) import System.FilePath (takeExtension, takeFileName) import System.Process (readProcessWithExitCode) -import System.IO (hPutStrLn, stderr) +import System.IO (hPutStrLn, hPutStr, hFlush, stderr, stdout) import System.Directory (createDirectoryIfMissing, setPermissions, getPermissions, setOwnerExecutable) import Data.List (isPrefixOf, intercalate) import Data.Char (isDigit, ord) @@ -548,8 +548,13 @@ serviceCommand opts = do (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/unfreeze") "{}" putStrLn $ green ++ "Service unfreezing: " ++ sid ++ reset ServiceDestroy sid -> do - (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/services/" ++ sid) - putStrLn $ green ++ "Service destroyed: " ++ sid ++ reset + result <- curlDeleteWithSudo apiKey ("https://api.unsandbox.com/services/" ++ sid) + case result of + SudoSuccess _ -> putStrLn $ green ++ "Service destroyed: " ++ sid ++ reset + SudoCancelled -> exitFailure + SudoError msg -> do + hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset + exitFailure ServiceResize sid -> do case svcVcpu opts of Nothing -> do @@ -790,6 +795,99 @@ curlPut apiKey url body = do checkClockDriftError stdout return (exitCode, stdout, stderr) +-- Result type for sudo challenge operations +data SudoResult = SudoSuccess String | SudoError String | SudoCancelled + +-- Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +handleSudoChallenge :: String -> String -> String -> String -> IO SudoResult +handleSudoChallenge response method endpoint body = do + let challengeId = extractJsonString response "challenge_id" + + hPutStrLn stderr $ yellow ++ "Confirmation required. Check your email for a one-time code." ++ reset + hPutStr stderr "Enter OTP: " + hFlush stderr + + otpRaw <- getLine + let otp = filter (/= '\n') $ filter (/= '\r') otpRaw + + if null otp + then do + hPutStrLn stderr $ red ++ "Error: Operation cancelled" ++ reset + return SudoCancelled + else do + -- Retry the request with sudo headers + (publicKey, secretKey) <- getApiKeys + authHeaders <- buildAuthHeaders publicKey secretKey method endpoint body + + -- Build sudo headers + let sudoHeaders = ["-H", "X-Sudo-OTP: " ++ otp] ++ + case challengeId of + Just cid -> ["-H", "X-Sudo-Challenge: " ++ cid] + Nothing -> [] + + let baseArgs = case method of + "DELETE" -> ["-s", "-X", "DELETE", apiBase ++ endpoint] + "POST" -> ["-s", "-X", "POST", apiBase ++ endpoint, "-H", "Content-Type: application/json", "-d", body] + _ -> ["-s", apiBase ++ endpoint] + + (exitCode, retryStdout, _) <- readProcessWithExitCode "curl" + (baseArgs ++ authHeaders ++ sudoHeaders) "" + + if exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') retryStdout) + then return $ SudoSuccess retryStdout + else return $ SudoError retryStdout + +-- Curl DELETE with 428 handling +curlDeleteWithSudo :: String -> String -> IO SudoResult +curlDeleteWithSudo apiKey url = do + (publicKey, secretKey) <- getApiKeys + let path = drop (length "https://api.unsandbox.com") url + authHeaders <- buildAuthHeaders publicKey secretKey "DELETE" path "" + + (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" + ([ "-s", "-X", "DELETE", "-w", "\n%{http_code}", url ] ++ authHeaders) "" + + -- Split response and status code + let allLines = lines stdout + let (bodyLines, statusLines) = splitAt (length allLines - 1) allLines + let body = intercalate "\n" bodyLines + let httpCode = case statusLines of + [s] -> read (filter (`elem` "0123456789") s) :: Int + _ -> 200 + + checkClockDriftError body + + if httpCode == 428 + then handleSudoChallenge body "DELETE" path "" + else return $ SudoSuccess body + +-- Curl POST with 428 handling +curlPostWithSudo :: String -> String -> String -> IO SudoResult +curlPostWithSudo apiKey url body = do + (publicKey, secretKey) <- getApiKeys + let path = drop (length "https://api.unsandbox.com") url + authHeaders <- buildAuthHeaders publicKey secretKey "POST" path body + + (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" + ([ "-s", "-X", "POST", "-w", "\n%{http_code}" + , url + , "-H", "Content-Type: application/json" + ] ++ authHeaders ++ ["-d", body]) "" + + -- Split response and status code + let allLines = lines stdout + let (bodyLines, statusLines) = splitAt (length allLines - 1) allLines + let bodyStr = intercalate "\n" bodyLines + let httpCode = case statusLines of + [s] -> read (filter (`elem` "0123456789") s) :: Int + _ -> 200 + + checkClockDriftError bodyStr + + if httpCode == 428 + then handleSudoChallenge bodyStr "POST" path body + else return $ SudoSuccess bodyStr + -- Vault helper functions maxEnvContentSize :: Int maxEnvContentSize = 65536 @@ -930,8 +1028,13 @@ snapshotCommand opts = do (_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/snapshots/" ++ sid) putStrLn stdout SnapshotDelete sid -> do - (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/snapshots/" ++ sid) - putStrLn $ green ++ "Snapshot deleted: " ++ sid ++ reset + result <- curlDeleteWithSudo apiKey ("https://api.unsandbox.com/snapshots/" ++ sid) + case result of + SudoSuccess _ -> putStrLn $ green ++ "Snapshot deleted: " ++ sid ++ reset + SudoCancelled -> exitFailure + SudoError msg -> do + hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset + exitFailure SnapshotClone sid -> do case snapCloneType opts of Nothing -> do @@ -958,14 +1061,24 @@ imageCommand opts = do (_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/images/" ++ iid) putStrLn stdout ImageDelete iid -> do - (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/images/" ++ iid) - putStrLn $ green ++ "Image deleted: " ++ iid ++ reset + result <- curlDeleteWithSudo apiKey ("https://api.unsandbox.com/images/" ++ iid) + case result of + SudoSuccess _ -> putStrLn $ green ++ "Image deleted: " ++ iid ++ reset + SudoCancelled -> exitFailure + SudoError msg -> do + hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset + exitFailure ImageLock iid -> do (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/images/" ++ iid ++ "/lock") "{}" putStrLn $ green ++ "Image locked: " ++ iid ++ reset ImageUnlock iid -> do - (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/images/" ++ iid ++ "/unlock") "{}" - putStrLn $ green ++ "Image unlocked: " ++ iid ++ reset + result <- curlPostWithSudo apiKey ("https://api.unsandbox.com/images/" ++ iid ++ "/unlock") "{}" + case result of + SudoSuccess _ -> putStrLn $ green ++ "Image unlocked: " ++ iid ++ reset + SudoCancelled -> exitFailure + SudoError msg -> do + hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset + exitFailure ImagePublish sourceId -> do case imgSourceType opts of Nothing -> do diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index f8ffedc..956158a 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -107,6 +107,29 @@ public class Un { } } + /** + * Exception thrown when a 428 sudo challenge is received. + * This indicates a destructive operation requires OTP confirmation. + */ + public static class SudoChallengeException extends RuntimeException { + private final String challengeId; + private final String responseBody; + + public SudoChallengeException(String challengeId, String responseBody) { + super("Sudo challenge required"); + this.challengeId = challengeId; + this.responseBody = responseBody; + } + + public String getChallengeId() { + return challengeId; + } + + public String getResponseBody() { + return responseBody; + } + } + // ======================================================================== // Credential Resolution // ======================================================================== @@ -280,6 +303,21 @@ public class Un { } } + if (responseCode == 428) { + // Extract challenge_id from response + String challengeId = null; + try { + Map errorJson = parseJson(responseBody); + Object cid = errorJson.get("challenge_id"); + if (cid != null) { + challengeId = cid.toString(); + } + } catch (Exception e) { + // Ignore parse errors + } + throw new SudoChallengeException(challengeId, responseBody); + } + if (responseCode < 200 || responseCode >= 300) { throw new ApiException( "API request failed with status " + responseCode, @@ -291,6 +329,144 @@ public class Un { return parseJson(responseBody); } + // ======================================================================== + // Sudo Challenge Handling + // ======================================================================== + + /** + * Make an HTTP request with sudo headers for OTP verification. + */ + private static Map makeRequestWithSudo( + String method, + String path, + String publicKey, + String secretKey, + Map data, + String otp, + String challengeId + ) throws IOException { + String url = API_BASE + path; + long timestamp = System.currentTimeMillis() / 1000; + String body = (data != null) ? mapToJson(data) : ""; + + String signature = signRequest(secretKey, timestamp, method, path, data != null ? body : null); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(DEFAULT_TIMEOUT_MS); + conn.setReadTimeout(DEFAULT_TIMEOUT_MS); + + conn.setRequestProperty("Authorization", "Bearer " + publicKey); + conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); + conn.setRequestProperty("X-Signature", signature); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("X-Sudo-OTP", otp); + if (challengeId != null) { + conn.setRequestProperty("X-Sudo-Challenge", challengeId); + } + + if ("POST".equals(method) && data != null) { + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } else if ("DELETE".equals(method)) { + conn.setRequestMethod("DELETE"); + } + + int responseCode = conn.getResponseCode(); + String responseBody; + + InputStream inputStream = (responseCode >= 200 && responseCode < 300) + ? conn.getInputStream() + : conn.getErrorStream(); + + if (inputStream == null) { + responseBody = ""; + } else { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + responseBody = sb.toString(); + } + } + + if (responseCode < 200 || responseCode >= 300) { + throw new ApiException( + "API request failed with status " + responseCode, + responseCode, + responseBody + ); + } + + return parseJson(responseBody); + } + + /** + * Prompt user for OTP and retry a destructive operation. + * Called when a 428 Sudo Challenge is received. + * + * @param challengeId The challenge ID from the 428 response + * @param method HTTP method (DELETE or POST) + * @param path API endpoint path + * @param publicKey API public key + * @param secretKey API secret key + * @param data Request body data (can be null) + * @return Response map on success + * @throws IOException on network errors + */ + private static Map handleSudoChallenge( + String challengeId, + String method, + String path, + String publicKey, + String secretKey, + Map data + ) throws IOException { + System.err.println("\033[33mConfirmation required. Check your email for a one-time code.\033[0m"); + System.err.print("Enter OTP: "); + System.err.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + String otp = reader.readLine(); + + if (otp == null || otp.trim().isEmpty()) { + throw new RuntimeException("Operation cancelled - no OTP provided"); + } + + otp = otp.trim(); + return makeRequestWithSudo(method, path, publicKey, secretKey, data, otp, challengeId); + } + + /** + * Execute a destructive operation with 428 sudo challenge handling. + * If the API returns 428, prompts for OTP and retries. + * + * @param method HTTP method + * @param path API endpoint path + * @param publicKey API public key + * @param secretKey API secret key + * @param data Request body data (can be null) + * @return Response map on success + * @throws IOException on network errors + */ + private static Map makeDestructiveRequest( + String method, + String path, + String publicKey, + String secretKey, + Map data + ) throws IOException { + try { + return makeRequest(method, path, publicKey, secretKey, data); + } catch (SudoChallengeException e) { + return handleSudoChallenge(e.getChallengeId(), method, path, publicKey, secretKey, data); + } + } + // ======================================================================== // Simple JSON Serialization/Deserialization // ======================================================================== @@ -1048,7 +1224,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("DELETE", "/snapshots/" + snapshotId, creds[0], creds[1], null); + return makeDestructiveRequest("DELETE", "/snapshots/" + snapshotId, creds[0], creds[1], null); } // ======================================================================== @@ -1456,7 +1632,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("DELETE", "/services/" + serviceId, creds[0], creds[1], null); + return makeDestructiveRequest("DELETE", "/services/" + serviceId, creds[0], creds[1], null); } /** @@ -1536,7 +1712,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("POST", "/services/" + serviceId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + return makeDestructiveRequest("POST", "/services/" + serviceId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); } /** @@ -1795,7 +1971,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("POST", "/snapshots/" + snapshotId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + return makeDestructiveRequest("POST", "/snapshots/" + snapshotId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); } /** @@ -1934,7 +2110,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("DELETE", "/images/" + imageId, creds[0], creds[1], null); + return makeDestructiveRequest("DELETE", "/images/" + imageId, creds[0], creds[1], null); } /** @@ -1974,7 +2150,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("POST", "/images/" + imageId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + return makeDestructiveRequest("POST", "/images/" + imageId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); } /** diff --git a/clients/javascript/sync/src/un.js b/clients/javascript/sync/src/un.js index 24d2ce4..81051e6 100644 --- a/clients/javascript/sync/src/un.js +++ b/clients/javascript/sync/src/un.js @@ -533,6 +533,111 @@ async function makeRequest(method, urlPath, publicKey, secretKey, data) { return response.json(); } +/** + * Make an authenticated HTTP request with sudo OTP challenge handling. + * + * If the server returns 428 (Precondition Required), prompts for OTP + * and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers. + * + * Used for destructive operations: service destroy/unlock, snapshot delete/unlock, + * image delete/unlock. + */ +async function makeRequestWithSudo(method, urlPath, publicKey, secretKey, data) { + const url = `${API_BASE}${urlPath}`; + const timestamp = Math.floor(Date.now() / 1000); + const body = data ? JSON.stringify(data) : ''; + + const signature = await signRequest(secretKey, timestamp, method, urlPath, body || null); + + const headers = { + 'Authorization': `Bearer ${publicKey}`, + 'X-Timestamp': timestamp.toString(), + 'X-Signature': signature, + 'Content-Type': 'application/json', + 'User-Agent': 'un-js/2.0', + }; + + const options = { + method, + headers, + signal: AbortSignal.timeout(120000), + }; + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) { + options.body = body; + } + + let response = await fetch(url, options); + + // Handle 428 sudo OTP challenge + if (response.status === 428) { + let challengeId = ''; + try { + const challengeData = await response.json(); + challengeId = challengeData.challenge_id || ''; + } catch (e) { + // Ignore JSON parse errors + } + + console.error('\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m'); + + // Prompt for OTP (Node.js only) + if (!IS_NODE) { + throw new Error('Sudo OTP challenge not supported in browser environment'); + } + + const readline = require('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr + }); + + const otp = await new Promise((resolve) => { + rl.question('Enter OTP: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); + + if (!otp) { + throw new Error('Operation cancelled'); + } + + // Retry with sudo headers + const retryTimestamp = Math.floor(Date.now() / 1000); + const retrySignature = await signRequest(secretKey, retryTimestamp, method, urlPath, body || null); + + const retryHeaders = { + 'Authorization': `Bearer ${publicKey}`, + 'X-Timestamp': retryTimestamp.toString(), + 'X-Signature': retrySignature, + 'Content-Type': 'application/json', + 'User-Agent': 'un-js/2.0', + 'X-Sudo-OTP': otp, + 'X-Sudo-Challenge': challengeId, + }; + + const retryOptions = { + method, + headers: retryHeaders, + signal: AbortSignal.timeout(120000), + }; + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) { + retryOptions.body = body; + } + + response = await fetch(url, retryOptions); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return response.json(); +} + /** * Get path to languages cache file. [Node.js only] */ @@ -855,7 +960,7 @@ async function restoreSnapshot(snapshotId, publicKey, secretKey) { */ async function deleteSnapshot(snapshotId, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); - return makeRequest('DELETE', `/snapshots/${snapshotId}`, publicKey, secretKey); + return makeRequestWithSudo('DELETE', `/snapshots/${snapshotId}`, publicKey, secretKey); } // ============================================================================ @@ -1090,7 +1195,7 @@ async function updateService(serviceId, opts = {}, publicKey, secretKey) { */ async function deleteService(serviceId, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); - return makeRequest('DELETE', `/services/${serviceId}`, publicKey, secretKey); + return makeRequestWithSudo('DELETE', `/services/${serviceId}`, publicKey, secretKey); } /** @@ -1142,7 +1247,7 @@ async function lockService(serviceId, publicKey, secretKey) { */ async function unlockService(serviceId, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); - return makeRequest('POST', `/services/${serviceId}/unlock`, publicKey, secretKey, {}); + return makeRequestWithSudo('POST', `/services/${serviceId}/unlock`, publicKey, secretKey, {}); } /** @@ -1332,7 +1437,7 @@ async function lockSnapshot(snapshotId, publicKey, secretKey) { */ async function unlockSnapshot(snapshotId, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); - return makeRequest('POST', `/snapshots/${snapshotId}/unlock`, publicKey, secretKey, {}); + return makeRequestWithSudo('POST', `/snapshots/${snapshotId}/unlock`, publicKey, secretKey, {}); } /** @@ -1434,7 +1539,7 @@ async function getImage(imageId, publicKey, secretKey) { */ async function deleteImage(imageId, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); - return makeRequest('DELETE', `/images/${imageId}`, publicKey, secretKey); + return makeRequestWithSudo('DELETE', `/images/${imageId}`, publicKey, secretKey); } /** @@ -1464,7 +1569,7 @@ async function lockImage(imageId, publicKey, secretKey) { */ async function unlockImage(imageId, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); - return makeRequest('POST', `/images/${imageId}/unlock`, publicKey, secretKey, {}); + return makeRequestWithSudo('POST', `/images/${imageId}/unlock`, publicKey, secretKey, {}); } /** diff --git a/clients/julia/sync/src/un.jl b/clients/julia/sync/src/un.jl index 005132c..35c2564 100755 --- a/clients/julia/sync/src/un.jl +++ b/clients/julia/sync/src/un.jl @@ -112,7 +112,7 @@ function compute_signature(secret_key::String, timestamp::Int64, method::String, return hmac_sha256_hex(secret_key, message) end -function api_request(endpoint::String, public_key::String, secret_key::String; method="GET", data=nothing) +function api_request(endpoint::String, public_key::String, secret_key::String; method="GET", data=nothing, sudo_otp=nothing, sudo_challenge=nothing) url = API_BASE * endpoint # Prepare body @@ -129,6 +129,14 @@ function api_request(endpoint::String, public_key::String, secret_key::String; m "Content-Type" => "application/json" ] + # Add sudo headers if provided + if sudo_otp !== nothing + push!(headers, "X-Sudo-OTP" => sudo_otp) + end + if sudo_challenge !== nothing + push!(headers, "X-Sudo-Challenge" => sudo_challenge) + end + try if method == "GET" response = HTTP.get(url, headers, readtimeout=300) @@ -161,6 +169,83 @@ function api_request(endpoint::String, public_key::String, secret_key::String; m end end +# Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +function handle_sudo_challenge(endpoint::String, public_key::String, secret_key::String, method::String, data, response_body::String) + # Extract challenge_id from response + parsed = JSON.parse(response_body) + challenge_id = get(parsed, "challenge_id", nothing) + + println(stderr, "$(YELLOW)Confirmation required. Check your email for a one-time code.$(RESET)") + print(stderr, "Enter OTP: ") + otp = readline() + + if isempty(strip(otp)) + println(stderr, "$(RED)Error: Operation cancelled$(RESET)") + exit(1) + end + + # Retry the request with sudo headers + return api_request(endpoint, public_key, secret_key, method=method, data=data, sudo_otp=strip(otp), sudo_challenge=challenge_id) +end + +# API request that handles 428 sudo challenges for destructive operations +function api_request_with_sudo(endpoint::String, public_key::String, secret_key::String; method="DELETE", data=nothing) + url = API_BASE * endpoint + + # Prepare body + body = data !== nothing ? JSON.json(data) : "" + + # Generate timestamp and signature + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, method, endpoint, body) + + headers = [ + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + try + if method == "GET" + response = HTTP.get(url, headers, readtimeout=300, status_exception=false) + elseif method == "POST" + response = HTTP.post(url, headers, body, readtimeout=300, status_exception=false) + elseif method == "DELETE" + response = HTTP.delete(url, headers, readtimeout=300, status_exception=false) + else + error("Unsupported method: $method") + end + + response_body = String(response.body) + + # Handle 428 - sudo OTP required + if response.status == 428 + return handle_sudo_challenge(endpoint, public_key, secret_key, method, data, response_body) + end + + # Handle other errors + if response.status >= 400 + if response.status == 401 && occursin("timestamp", lowercase(response_body)) + println(stderr, "$(RED)Error: Request timestamp expired (must be within 5 minutes of server time)$(RESET)") + println(stderr, "$(YELLOW)Your computer's clock may have drifted.$(RESET)") + println(stderr, "Check your system time and sync with NTP if needed:") + println(stderr, " Linux: sudo ntpdate -s time.nist.gov") + println(stderr, " macOS: sudo sntp -sS time.apple.com") + println(stderr, " Windows: w32tm /resync") + else + println(stderr, "$(RED)Error: HTTP $(response.status) - $(response_body)$(RESET)") + end + exit(1) + end + + return JSON.parse(response_body) + catch e + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + exit(1) + end +end + function api_request_patch(endpoint::String, public_key::String, secret_key::String; data=nothing) url = API_BASE * endpoint @@ -545,7 +630,7 @@ function cmd_service(args) end if args["destroy"] !== nothing - api_request("/services/$(args["destroy"])", public_key, secret_key, method="DELETE") + api_request_with_sudo("/services/$(args["destroy"])", public_key, secret_key, method="DELETE") println("$(GREEN)Service destroyed: $(args["destroy"])$(RESET)") return end @@ -1136,7 +1221,7 @@ function cmd_image(args) end if args["delete"] !== nothing - api_request("/images/$(args["delete"])", public_key, secret_key, method="DELETE") + api_request_with_sudo("/images/$(args["delete"])", public_key, secret_key, method="DELETE") println("$(GREEN)Image deleted: $(args["delete"])$(RESET)") return end @@ -1148,7 +1233,7 @@ function cmd_image(args) end if args["unlock"] !== nothing - api_request("/images/$(args["unlock"])/unlock", public_key, secret_key, method="POST") + api_request_with_sudo("/images/$(args["unlock"])/unlock", public_key, secret_key, method="POST", data=Dict()) println("$(GREEN)Image unlocked: $(args["unlock"])$(RESET)") return end diff --git a/clients/kotlin/sync/src/un.kt b/clients/kotlin/sync/src/un.kt index 8d435b0..5486d22 100644 --- a/clients/kotlin/sync/src/un.kt +++ b/clients/kotlin/sync/src/un.kt @@ -350,7 +350,7 @@ fun cmdService(args: Args) { } if (args.serviceDestroy != null) { - apiRequest("/services/${args.serviceDestroy}", "DELETE", null, publicKey, secretKey) + apiRequestDestructive("/services/${args.serviceDestroy}", "DELETE", null, publicKey, secretKey) println("${GREEN}Service destroyed: ${args.serviceDestroy}${RESET}") return } @@ -578,7 +578,7 @@ fun cmdImage(args: Args) { } if (args.imageDelete != null) { - apiRequest("/images/${args.imageDelete}", "DELETE", null, publicKey, secretKey) + apiRequestDestructive("/images/${args.imageDelete}", "DELETE", null, publicKey, secretKey) println("${GREEN}Image deleted: ${args.imageDelete}${RESET}") return } @@ -590,7 +590,7 @@ fun cmdImage(args: Args) { } if (args.imageUnlock != null) { - apiRequest("/images/${args.imageUnlock}/unlock", "POST", null, publicKey, secretKey) + apiRequestDestructive("/images/${args.imageUnlock}/unlock", "POST", null, publicKey, secretKey) println("${GREEN}Image unlocked: ${args.imageUnlock}${RESET}") return } @@ -776,6 +776,9 @@ fun detectLanguage(filename: String): String { return EXT_MAP[".$ext"] ?: throw RuntimeException("Unsupported file extension: .$ext") } +// Exception for 428 Sudo Challenge +class SudoChallengeException(val challengeId: String?, val responseBody: String) : RuntimeException("Sudo challenge required") + fun apiRequest(endpoint: String, method: String, data: Map?, publicKey: String?, secretKey: String): Map { val timestamp = System.currentTimeMillis() / 1000 val body = if (data != null) toJson(data) else "" @@ -800,6 +803,17 @@ fun apiRequest(endpoint: String, method: String, data: Map?, public if (connection.responseCode !in 200..299) { val error = connection.errorStream?.bufferedReader()?.readText() ?: "" + if (connection.responseCode == 428) { + // Extract challenge_id from response + var challengeId: String? = null + try { + val errorJson = parseJson(error) + challengeId = errorJson["challenge_id"]?.toString() + } catch (e: Exception) { + // Ignore parse errors + } + throw SudoChallengeException(challengeId, error) + } if (connection.responseCode == 401 && error.lowercase().contains("timestamp")) { System.err.println("${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}") System.err.println("${YELLOW}Your computer's clock may have drifted.${RESET}") @@ -816,6 +830,64 @@ fun apiRequest(endpoint: String, method: String, data: Map?, public return parseJson(response) } +fun apiRequestWithSudo(endpoint: String, method: String, data: Map?, publicKey: String?, secretKey: String, otp: String, challengeId: String?): Map { + val timestamp = System.currentTimeMillis() / 1000 + val body = if (data != null) toJson(data) else "" + val signatureData = "$timestamp:$method:$endpoint:$body" + val signature = hmacSha256(secretKey, signatureData) + + val url = URL(API_BASE + endpoint) + val connection = url.openConnection() as HttpURLConnection + + connection.requestMethod = method + connection.setRequestProperty("Authorization", "Bearer ${publicKey ?: secretKey}") + connection.setRequestProperty("X-Timestamp", timestamp.toString()) + connection.setRequestProperty("X-Signature", signature) + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("X-Sudo-OTP", otp) + if (challengeId != null) { + connection.setRequestProperty("X-Sudo-Challenge", challengeId) + } + connection.connectTimeout = 30000 + connection.readTimeout = 300000 + + if (data != null) { + connection.doOutput = true + connection.outputStream.use { it.write(body.toByteArray()) } + } + + if (connection.responseCode !in 200..299) { + val error = connection.errorStream?.bufferedReader()?.readText() ?: "" + throw RuntimeException("HTTP ${connection.responseCode} - $error") + } + + val response = connection.inputStream.bufferedReader().readText() + return parseJson(response) +} + +fun handleSudoChallenge(challengeId: String?, method: String, endpoint: String, data: Map?, publicKey: String?, secretKey: String): Map { + System.err.println("${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}") + System.err.print("Enter OTP: ") + System.err.flush() + + val reader = java.io.BufferedReader(java.io.InputStreamReader(System.`in`)) + val otp = reader.readLine()?.trim() + + if (otp.isNullOrEmpty()) { + throw RuntimeException("Operation cancelled - no OTP provided") + } + + return apiRequestWithSudo(endpoint, method, data, publicKey, secretKey, otp, challengeId) +} + +fun apiRequestDestructive(endpoint: String, method: String, data: Map?, publicKey: String?, secretKey: String): Map { + return try { + apiRequest(endpoint, method, data, publicKey, secretKey) + } catch (e: SudoChallengeException) { + handleSudoChallenge(e.challengeId, method, endpoint, data, publicKey, secretKey) + } +} + fun apiRequestPatch(endpoint: String, data: Map, publicKey: String?, secretKey: String): Map { val timestamp = System.currentTimeMillis() / 1000 val body = toJson(data) diff --git a/clients/lisp/sync/src/un.lisp b/clients/lisp/sync/src/un.lisp index 255433f..c03744d 100644 --- a/clients/lisp/sync/src/un.lisp +++ b/clients/lisp/sync/src/un.lisp @@ -176,6 +176,101 @@ (check-clock-drift response) response))) +(defun run-curl-with-status (args) + "Run curl and return (response-body . http-code)" + (with-output-to-string (out) + (let ((process (uiop:launch-program + (append args (list "-w" "\\n%{http_code}")) + :output :stream :error-output nil))) + (loop for line = (read-line (uiop:process-info-output process) nil) + while line do (format out "~a~%" line)) + (uiop:wait-process process)))) + +(defun parse-response-with-status (response) + "Parse response to extract body and HTTP status code" + (let* ((lines (remove-if (lambda (s) (zerop (length s))) + (uiop:split-string response :separator '(#\Newline)))) + (last-line (car (last lines))) + (code (ignore-errors (parse-integer last-line)))) + (if code + (cons (format nil "~{~a~^~%~}" (butlast lines)) code) + (cons response 0)))) + +(defun handle-sudo-challenge (response-data public-key secret-key method endpoint body) + "Handle 428 sudo OTP challenge - prompts user for OTP and retries" + (let ((challenge-id (parse-json-field response-data "challenge_id"))) + (format *error-output* "~aConfirmation required. Check your email for a one-time code.~a~%" *yellow* *reset*) + (format *error-output* "Enter OTP: ") + (force-output *error-output*) + (let ((otp (string-trim '(#\Space #\Tab #\Newline #\Return) (read-line)))) + (when (zerop (length otp)) + (format *error-output* "Error: Operation cancelled~%") + (uiop:quit 1)) + ;; Retry the request with sudo headers + (let* ((auth-headers (build-auth-headers public-key secret-key method endpoint (or body ""))) + (sudo-headers (list "-H" (format nil "X-Sudo-OTP: ~a" otp) + "-H" (format nil "X-Sudo-Challenge: ~a" (or challenge-id "")))) + (method-args (cond + ((string= method "DELETE") (list "-X" "DELETE")) + ((string= method "POST") (list "-X" "POST")) + (t (list "-X" method)))) + (content-headers (if body (list "-H" "Content-Type: application/json") nil)) + (body-args (if body (list "-d" body) nil)) + (all-args (append (list "curl" "-s" "-w" "\\n%{http_code}") + method-args + (list (format nil "https://api.unsandbox.com~a" endpoint)) + auth-headers + sudo-headers + content-headers + body-args)) + (response (run-curl all-args)) + (parsed (parse-response-with-status response)) + (resp-body (car parsed)) + (http-code (cdr parsed))) + (if (and (>= http-code 200) (< http-code 300)) + (list t resp-body) + (progn + (format *error-output* "~aError: HTTP ~a~a~%" *red* http-code *reset*) + (format *error-output* "~a~%" resp-body) + (list nil resp-body))))))) + +(defun curl-delete-with-sudo (api-key endpoint) + "DELETE request that handles 428 sudo OTP challenge" + (destructuring-bind (public-key secret-key) (get-api-keys) + (let* ((auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "")) + (base-args (append (list "curl" "-s" "-w" "\\n%{http_code}" "-X" "DELETE" + (format nil "https://api.unsandbox.com~a" endpoint)) + auth-headers)) + (response (run-curl base-args)) + (parsed (parse-response-with-status response)) + (body (car parsed)) + (http-code (cdr parsed))) + (check-clock-drift body) + (if (= http-code 428) + (handle-sudo-challenge body public-key secret-key "DELETE" endpoint nil) + (list (and (>= http-code 200) (< http-code 300)) body))))) + +(defun curl-post-with-sudo (api-key endpoint json-data) + "POST request that handles 428 sudo OTP challenge" + (let ((tmp-file (write-temp-file json-data))) + (unwind-protect + (destructuring-bind (public-key secret-key) (get-api-keys) + (let* ((auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)) + (base-args (append (list "curl" "-s" "-w" "\\n%{http_code}" "-X" "POST" + (format nil "https://api.unsandbox.com~a" endpoint) + "-H" "Content-Type: application/json") + auth-headers + (list "-d" (format nil "@~a" tmp-file)))) + (response (run-curl base-args)) + (parsed (parse-response-with-status response)) + (body (car parsed)) + (http-code (cdr parsed))) + (check-clock-drift body) + (if (= http-code 428) + (handle-sudo-challenge body public-key secret-key "POST" endpoint json-data) + (list (and (>= http-code 200) (< http-code 300)) body)))) + (delete-file tmp-file)))) + (defun curl-post-portal (api-key endpoint json-data) (let ((tmp-file (write-temp-file json-data))) (unwind-protect @@ -330,8 +425,12 @@ (curl-post api-key (format nil "/services/~a/unfreeze" id) "{}") (format t "~aService unfreezing: ~a~a~%" *green* id *reset*)) ((string= action "destroy") - (curl-delete api-key (format nil "/services/~a" id)) - (format t "~aService destroyed: ~a~a~%" *green* id *reset*)) + (let ((result (curl-delete-with-sudo api-key (format nil "/services/~a" id)))) + (if (first result) + (format t "~aService destroyed: ~a~a~%" *green* id *reset*) + (progn + (format *error-output* "~aError destroying service~a~%" *red* *reset*) + (uiop:quit 1))))) ((string= action "resize") (if (or (null service-type) (string= service-type "")) (progn @@ -506,14 +605,22 @@ ((string= action "info") (format t "~a~%" (curl-get api-key (format nil "/images/~a" id)))) ((string= action "delete") - (curl-delete api-key (format nil "/images/~a" id)) - (format t "~aImage deleted: ~a~a~%" *green* id *reset*)) + (let ((result (curl-delete-with-sudo api-key (format nil "/images/~a" id)))) + (if (first result) + (format t "~aImage deleted: ~a~a~%" *green* id *reset*) + (progn + (format *error-output* "~aError deleting image~a~%" *red* *reset*) + (uiop:quit 1))))) ((string= action "lock") (curl-post api-key (format nil "/images/~a/lock" id) "{}") (format t "~aImage locked: ~a~a~%" *green* id *reset*)) ((string= action "unlock") - (curl-post api-key (format nil "/images/~a/unlock" id) "{}") - (format t "~aImage unlocked: ~a~a~%" *green* id *reset*)) + (let ((result (curl-post-with-sudo api-key (format nil "/images/~a/unlock" id) "{}"))) + (if (first result) + (format t "~aImage unlocked: ~a~a~%" *green* id *reset*) + (progn + (format *error-output* "~aError unlocking image~a~%" *red* *reset*) + (uiop:quit 1))))) ((string= action "publish") (if (or (null source-type) (string= source-type "")) (progn diff --git a/clients/lua/sync/src/un.lua b/clients/lua/sync/src/un.lua index 0844354..23d715b 100644 --- a/clients/lua/sync/src/un.lua +++ b/clients/lua/sync/src/un.lua @@ -69,7 +69,80 @@ function Un.sign_request(secret, timestamp, method, endpoint, body) end -- API request -function Un.api_request(method, endpoint, body, opts) +function Un.api_request(method, endpoint, body, opts, extra_headers) + opts = opts or {} + extra_headers = extra_headers or {} + local pk, sk = Un.get_credentials(opts) + + local timestamp = tostring(os.time()) + local url = Un.API_BASE .. endpoint + local body_str = body and json.encode(body) or "{}" + local signature = Un.sign_request(sk, timestamp, method, endpoint, body_str) + + local headers = { + ["Authorization"] = "Bearer " .. pk, + ["X-Timestamp"] = timestamp, + ["X-Signature"] = signature, + ["Content-Type"] = "application/json" + } + + -- Add extra headers (for sudo OTP) + for k, v in pairs(extra_headers) do + headers[k] = v + end + + local resp_body = {} + local resp, status = https.request({ + url = url, + method = method, + headers = headers, + source = body_str and ltn12.source.string(body_str), + sink = ltn12.sink.table(resp_body) + }) + + if status ~= 200 then error("API error (" .. status .. ")") end + return json.decode(table.concat(resp_body)), status +end + +-- Handle 428 Sudo OTP challenge - prompt user for OTP and retry +function Un.handle_sudo_challenge(response_body, method, endpoint, body, opts) + -- Extract challenge_id from response + local response_data = {} + if response_body and response_body ~= "" then + pcall(function() response_data = json.decode(response_body) end) + end + local challenge_id = response_data.challenge_id or "" + + io.stderr:write("\027[33mConfirmation required. Check your email for a one-time code.\027[0m\n") + io.stderr:write("Enter OTP: ") + io.stderr:flush() + + local otp = io.read("*line") + if not otp or otp == "" then + io.stderr:write("\027[31mError: Operation cancelled\027[0m\n") + return false + end + + -- Retry with sudo headers + local extra_headers = {["X-Sudo-OTP"] = otp} + if challenge_id ~= "" then + extra_headers["X-Sudo-Challenge"] = challenge_id + end + + local ok, result = pcall(function() + return Un.api_request(method, endpoint, body, opts, extra_headers) + end) + + if ok then + print("\027[32mOperation completed successfully\027[0m") + return true + else + return false + end +end + +-- API request with 428 sudo handling for destructive operations +function Un.api_request_with_sudo(method, endpoint, body, opts) opts = opts or {} local pk, sk = Un.get_credentials(opts) @@ -94,6 +167,11 @@ function Un.api_request(method, endpoint, body, opts) sink = ltn12.sink.table(resp_body) }) + -- Handle 428 Precondition Required (sudo OTP needed) + if status == 428 then + return Un.handle_sudo_challenge(table.concat(resp_body), method, endpoint, body, opts) + end + if status ~= 200 then error("API error (" .. status .. ")") end return json.decode(table.concat(resp_body)) end @@ -202,7 +280,7 @@ end function Un.image_delete(image_id, opts) opts = opts or {} - return Un.api_request("DELETE", "/images/" .. image_id, nil, opts) + return Un.api_request_with_sudo("DELETE", "/images/" .. image_id, nil, opts) end function Un.image_lock(image_id, opts) @@ -212,7 +290,7 @@ end function Un.image_unlock(image_id, opts) opts = opts or {} - return Un.api_request("POST", "/images/" .. image_id .. "/unlock", {}, opts) + return Un.api_request_with_sudo("POST", "/images/" .. image_id .. "/unlock", {}, opts) end function Un.image_publish(source_id, source_type, name, opts) diff --git a/clients/nim/sync/src/un.nim b/clients/nim/sync/src/un.nim index 003eb05..27c2686 100644 --- a/clients/nim/sync/src/un.nim +++ b/clients/nim/sync/src/un.nim @@ -273,6 +273,106 @@ proc extractJsonField(response, field: string): string = return response[start..= 200 and status < 300: + echo GREEN & "Operation completed successfully" & RESET + return true + else: + stderr.writeLine(RED & "Error: HTTP " & $status & RESET) + return false + except: + stderr.writeLine(RED & "Error: Failed to retry operation" & RESET) + return false + +proc execCurlDeleteWithSudo(endpoint, publicKey, secretKey: string): int = + let authHeaders = buildAuthHeaders("DELETE", endpoint, "", publicKey, secretKey) + let cmd = fmt"""curl -s -w '\n%{{http_code}}' -X DELETE '{API_BASE}{endpoint}' {authHeaders}""" + let output = execProcess(cmd) + + # Parse response body and status code + let lines = output.strip().split('\n') + if lines.len < 1: + return 500 + + let statusLine = lines[^1] + let responseBody = if lines.len > 1: lines[0..^2].join("\n") else: "" + + try: + let status = parseInt(statusLine) + if status == 428: + if handleSudoChallenge(responseBody, "DELETE", endpoint, "", publicKey, secretKey): + return 200 + else: + return 428 + return status + except: + return 500 + +proc execCurlPostWithSudo(endpoint, body, publicKey, secretKey: string): int = + let authHeaders = buildAuthHeaders("POST", endpoint, body, publicKey, secretKey) + let cmd = fmt"""curl -s -w '\n%{{http_code}}' -X POST '{API_BASE}{endpoint}' -H 'Content-Type: application/json' {authHeaders} -d '{body}'""" + let output = execProcess(cmd) + + # Parse response body and status code + let lines = output.strip().split('\n') + if lines.len < 1: + return 500 + + let statusLine = lines[^1] + let responseBody = if lines.len > 1: lines[0..^2].join("\n") else: "" + + try: + let status = parseInt(statusLine) + if status == 428: + if handleSudoChallenge(responseBody, "POST", endpoint, body, publicKey, secretKey): + return 200 + else: + return 428 + return status + except: + return 500 + proc cmdServiceEnv(action, target: string, envs: seq[string], envFile, publicKey, secretKey: string) = case action of "status": @@ -443,10 +543,12 @@ proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list if destroy != "": let path = fmt"/services/{destroy}" - let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey) - let cmd = fmt"""curl -s -X DELETE '{API_BASE}/services/{destroy}' {authHeaders}""" - discard execCurl(cmd) - echo GREEN & "Service destroyed: " & destroy & RESET + let status = execCurlDeleteWithSudo(path, publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Service destroyed: " & destroy & RESET + elif status != 428: # 428 already handled in execCurlDeleteWithSudo + stderr.writeLine(RED & "Error: Failed to destroy service" & RESET) + quit(1) return if resize != "": @@ -737,10 +839,12 @@ proc cmdImage(list: bool, infoId, deleteId, lockId, unlockId, publishId, sourceT if deleteId != "": let path = fmt"/images/{deleteId}" - let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey) - let cmd = fmt"""curl -s -X DELETE '{API_BASE}/images/{deleteId}' {authHeaders}""" - discard execCurl(cmd) - echo GREEN & "Image deleted: " & deleteId & RESET + let status = execCurlDeleteWithSudo(path, publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Image deleted: " & deleteId & RESET + elif status != 428: # 428 already handled + stderr.writeLine(RED & "Error: Failed to delete image" & RESET) + quit(1) return if lockId != "": @@ -753,10 +857,12 @@ proc cmdImage(list: bool, infoId, deleteId, lockId, unlockId, publishId, sourceT if unlockId != "": let path = fmt"/images/{unlockId}/unlock" - let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) - let cmd = fmt"""curl -s -X POST '{API_BASE}/images/{unlockId}/unlock' {authHeaders}""" - discard execCurl(cmd) - echo GREEN & "Image unlocked: " & unlockId & RESET + let status = execCurlPostWithSudo(path, "{}", publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Image unlocked: " & unlockId & RESET + elif status != 428: # 428 already handled + stderr.writeLine(RED & "Error: Failed to unlock image" & RESET) + quit(1) return if publishId != "": diff --git a/clients/objective-c/sync/src/un.m b/clients/objective-c/sync/src/un.m index ec6592f..64ecb48 100644 --- a/clients/objective-c/sync/src/un.m +++ b/clients/objective-c/sync/src/un.m @@ -912,6 +912,132 @@ NSDictionary* UNLanguages(void) { // CLI Helper Functions // ============================================================================ +// ============================================================================ +// Sudo OTP Challenge Handling +// ============================================================================ + +/** + * Handle 428 sudo OTP challenge - prompts user for OTP and retries the request. + * + * @param responseData The JSON response containing challenge_id + * @param meth HTTP method (DELETE, POST, etc.) + * @param endpoint API endpoint path + * @param body Request body (or nil for DELETE) + * @param publicKey API public key + * @param secretKey API secret key + * @return YES on success, NO on failure + */ +BOOL handleSudoChallenge(NSDictionary* responseData, NSString* meth, NSString* endpoint, NSString* body, NSString* publicKey, NSString* secretKey) { + NSString* challengeId = responseData[@"challenge_id"]; + + fprintf(stderr, "%sConfirmation required. Check your email for a one-time code.%s\n", + [YELLOW UTF8String], [RESET UTF8String]); + fprintf(stderr, "Enter OTP: "); + + char otpBuffer[64]; + if (!fgets(otpBuffer, sizeof(otpBuffer), stdin)) { + fprintf(stderr, "%sError: Failed to read OTP%s\n", [RED UTF8String], [RESET UTF8String]); + return NO; + } + + // Strip newline + NSString* otp = [[NSString stringWithUTF8String:otpBuffer] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + if ([otp length] == 0) { + fprintf(stderr, "%sError: Operation cancelled%s\n", [RED UTF8String], [RESET UTF8String]); + return NO; + } + + // Build retry request with sudo headers + NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; + NSURL* url = [NSURL URLWithString:urlString]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + [request setHTTPMethod:meth]; + [request setTimeoutInterval:UN_DEFAULT_TIMEOUT]; + + NSString* bodyString = body ?: @""; + if (body && [body length] > 0) { + [request setHTTPBody:[body dataUsingEncoding:NSUTF8StringEncoding]]; + } + + long timestamp = (long)[[NSDate date] timeIntervalSince1970]; + NSString* signature = UNComputeSignature(secretKey, timestamp, meth, endpoint, bodyString); + + [request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [request setValue:otp forHTTPHeaderField:@"X-Sudo-OTP"]; + if (challengeId) { + [request setValue:challengeId forHTTPHeaderField:@"X-Sudo-Challenge"]; + } + + NSHTTPURLResponse* response = nil; + NSError* error = nil; + NSData* responseData2 = [NSURLConnection sendSynchronousRequest:request + returningResponse:&response + error:&error]; + + if (error || [response statusCode] < 200 || [response statusCode] >= 300) { + fprintf(stderr, "%sError: HTTP %ld%s\n", + [RED UTF8String], (long)[response statusCode], [RESET UTF8String]); + if (responseData2) { + NSString* errMsg = [[NSString alloc] initWithData:responseData2 encoding:NSUTF8StringEncoding]; + fprintf(stderr, "%s\n", [errMsg UTF8String]); + } + return NO; + } + + fprintf(stderr, "%sOperation completed successfully%s\n", [GREEN UTF8String], [RESET UTF8String]); + return YES; +} + +/** + * Make an API request that returns status code and response (for 428 handling). + */ +NSDictionary* apiRequestWithStatusCLI(NSString* endpoint, NSString* method, NSDictionary* data, NSString* publicKey, NSString* secretKey, NSInteger* outStatusCode) { + NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; + NSURL* url = [NSURL URLWithString:urlString]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + [request setHTTPMethod:method]; + [request setTimeoutInterval:UN_DEFAULT_TIMEOUT]; + + NSString* bodyString = @""; + if (data) { + NSError* error = nil; + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:data options:0 error:&error]; + if (error) { + *outStatusCode = 500; + return @{@"error": [error localizedDescription]}; + } + bodyString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + [request setHTTPBody:jsonData]; + } + + long timestamp = (long)[[NSDate date] timeIntervalSince1970]; + NSString* signature = UNComputeSignature(secretKey, timestamp, method, endpoint, bodyString); + + [request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + + NSHTTPURLResponse* response = nil; + NSError* error = nil; + NSData* responseData = [NSURLConnection sendSynchronousRequest:request + returningResponse:&response + error:&error]; + + *outStatusCode = [response statusCode]; + + if (error) { + return @{@"error": [error localizedDescription]}; + } + + NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error]; + return result ?: @{}; +} + NSDictionary* apiRequestCLI(NSString* endpoint, NSString* method, NSDictionary* data, NSString* publicKey, NSString* secretKey) { NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; NSURL* url = [NSURL URLWithString:urlString]; @@ -1435,8 +1561,21 @@ void cmdService(NSArray* args) { if (destroyId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@", destroyId]; - apiRequestCLI(endpoint, @"DELETE", nil, publicKey, secretKey); - printf("%sService destroyed: %s%s\n", [GREEN UTF8String], [destroyId UTF8String], [RESET UTF8String]); + NSInteger statusCode = 0; + NSDictionary* response = apiRequestWithStatusCLI(endpoint, @"DELETE", nil, publicKey, secretKey, &statusCode); + + if (statusCode == 428) { + if (handleSudoChallenge(response, @"DELETE", endpoint, nil, publicKey, secretKey)) { + printf("%sService destroyed: %s%s\n", [GREEN UTF8String], [destroyId UTF8String], [RESET UTF8String]); + } else { + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + printf("%sService destroyed: %s%s\n", [GREEN UTF8String], [destroyId UTF8String], [RESET UTF8String]); + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } return; } @@ -1585,8 +1724,21 @@ void cmdImage(NSArray* args) { if (deleteId) { NSString* endpoint = [NSString stringWithFormat:@"/images/%@", deleteId]; - apiRequestCLI(endpoint, @"DELETE", nil, publicKey, secretKey); - printf("%sImage deleted: %s%s\n", [GREEN UTF8String], [deleteId UTF8String], [RESET UTF8String]); + NSInteger statusCode = 0; + NSDictionary* response = apiRequestWithStatusCLI(endpoint, @"DELETE", nil, publicKey, secretKey, &statusCode); + + if (statusCode == 428) { + if (handleSudoChallenge(response, @"DELETE", endpoint, nil, publicKey, secretKey)) { + printf("%sImage deleted: %s%s\n", [GREEN UTF8String], [deleteId UTF8String], [RESET UTF8String]); + } else { + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + printf("%sImage deleted: %s%s\n", [GREEN UTF8String], [deleteId UTF8String], [RESET UTF8String]); + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } return; } @@ -1599,8 +1751,21 @@ void cmdImage(NSArray* args) { if (unlockId) { NSString* endpoint = [NSString stringWithFormat:@"/images/%@/unlock", unlockId]; - apiRequestCLI(endpoint, @"POST", nil, publicKey, secretKey); - printf("%sImage unlocked: %s%s\n", [GREEN UTF8String], [unlockId UTF8String], [RESET UTF8String]); + NSInteger statusCode = 0; + NSDictionary* response = apiRequestWithStatusCLI(endpoint, @"POST", @{}, publicKey, secretKey, &statusCode); + + if (statusCode == 428) { + if (handleSudoChallenge(response, @"POST", endpoint, @"{}", publicKey, secretKey)) { + printf("%sImage unlocked: %s%s\n", [GREEN UTF8String], [unlockId UTF8String], [RESET UTF8String]); + } else { + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + printf("%sImage unlocked: %s%s\n", [GREEN UTF8String], [unlockId UTF8String], [RESET UTF8String]); + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } return; } diff --git a/clients/ocaml/sync/src/un.ml b/clients/ocaml/sync/src/un.ml index e6de159..e316b33 100755 --- a/clients/ocaml/sync/src/un.ml +++ b/clients/ocaml/sync/src/un.ml @@ -466,6 +466,150 @@ let api_delete ?public_key ?secret_key endpoint = check_clock_drift output; output +(** Result type for sudo challenge operations *) +type sudo_result = SudoSuccess of string | SudoError of string | SudoCancelled + +(** Handle 428 sudo OTP challenge - prompts user for OTP and retries the request *) +let handle_sudo_challenge response method_ endpoint body = + let challenge_id = extract_json_value response "challenge_id" in + + Printf.fprintf stderr "%sConfirmation required. Check your email for a one-time code.%s\n" yellow reset; + Printf.fprintf stderr "Enter OTP: "; + flush stderr; + + let otp_raw = try input_line stdin with End_of_file -> "" in + let otp = String.trim otp_raw in + + if otp = "" then begin + Printf.fprintf stderr "%sError: Operation cancelled%s\n" red reset; + SudoCancelled + end else begin + (* Retry the request with sudo headers *) + let (pk, sk) = get_credentials () in + let auth_headers = build_auth_headers pk sk method_ endpoint body in + + let sudo_headers = Printf.sprintf " -H 'X-Sudo-OTP: %s'" otp in + let challenge_header = match challenge_id with + | Some cid -> Printf.sprintf " -H 'X-Sudo-Challenge: %s'" cid + | None -> "" + in + + let cmd = match method_ with + | "DELETE" -> + Printf.sprintf "curl -s -X DELETE %s%s%s%s%s" api_base endpoint auth_headers sudo_headers challenge_header + | "POST" -> + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc body; + close_out oc; + let result = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s%s%s -d @%s" + api_base endpoint auth_headers sudo_headers challenge_header tmp_file in + result + | _ -> + Printf.sprintf "curl -s %s%s%s%s%s" api_base endpoint auth_headers sudo_headers challenge_header + in + + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let retry_output = read_all "" in + let _ = Unix.close_process_in ic in + + (* Clean up temp file for POST *) + (if method_ = "POST" then + try Sys.remove (Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999)) with _ -> ()); + + let contains_error s = + try let _ = Str.search_forward (Str.regexp_string "\"error\"") s 0 in true + with Not_found -> false + in + + if not (contains_error retry_output) then + SudoSuccess retry_output + else + SudoError retry_output + end + +(** Make authenticated DELETE request with 428 handling *) +let api_delete_with_sudo ?public_key ?secret_key endpoint = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "DELETE" endpoint "" in + let cmd = Printf.sprintf "curl -s -w '\\n%%{http_code}' -X DELETE %s%s%s" api_base endpoint auth_headers in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + + (* Split response and status code *) + let lines = String.split_on_char '\n' output in + let lines_filtered = List.filter (fun s -> String.trim s <> "") lines in + let (body_lines, status_lines) = + let n = List.length lines_filtered in + if n > 0 then + (List.filteri (fun i _ -> i < n - 1) lines_filtered, + [List.nth lines_filtered (n - 1)]) + else ([], []) + in + let body = String.concat "\n" body_lines in + let http_code = match status_lines with + | [s] -> (try int_of_string (String.trim s) with _ -> 200) + | _ -> 200 + in + + check_clock_drift body; + + if http_code = 428 then + handle_sudo_challenge body "DELETE" endpoint "" + else + SudoSuccess body + +(** Make authenticated POST request with 428 handling *) +let api_post_with_sudo ?public_key ?secret_key endpoint json = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "POST" endpoint 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; + close_out oc; + let cmd = Printf.sprintf "curl -s -w '\\n%%{http_code}' -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + + (* Split response and status code *) + let lines = String.split_on_char '\n' output in + let lines_filtered = List.filter (fun s -> String.trim s <> "") lines in + let (body_lines, status_lines) = + let n = List.length lines_filtered in + if n > 0 then + (List.filteri (fun i _ -> i < n - 1) lines_filtered, + [List.nth lines_filtered (n - 1)]) + else ([], []) + in + let body = String.concat "\n" body_lines in + let http_code = match status_lines with + | [s] -> (try int_of_string (String.trim s) with _ -> 200) + | _ -> 200 + in + + check_clock_drift body; + + if http_code = 428 then + handle_sudo_challenge body "POST" endpoint json + else + SudoSuccess body + (** Make authenticated POST request to portal *) let portal_post ?public_key ?secret_key endpoint json = let (pk, sk) = get_credentials ?public_key ?secret_key () in @@ -1247,8 +1391,12 @@ let service_command action name ports bootstrap bootstrap_file service_type netw | "destroy" -> (match name with | Some sid -> - let _ = curl_delete api_key ("/services/" ^ sid) in - Printf.printf "%sService destroyed: %s%s\n" green sid reset + (match api_delete_with_sudo ("/services/" ^ sid) with + | SudoSuccess _ -> Printf.printf "%sService destroyed: %s%s\n" green sid reset + | SudoCancelled -> exit 1 + | SudoError msg -> + Printf.fprintf stderr "%sError: %s%s\n" red msg reset; + exit 1) | None -> Printf.fprintf stderr "Error: --destroy requires service ID\n"; exit 1) @@ -1429,16 +1577,24 @@ let image_command args = Printf.printf "%s\n" response end else if delete_id <> "" then begin - let _ = curl_delete api_key (Printf.sprintf "/images/%s" delete_id) in - Printf.printf "%sImage deleted: %s%s\n" green delete_id reset + (match api_delete_with_sudo (Printf.sprintf "/images/%s" delete_id) with + | SudoSuccess _ -> Printf.printf "%sImage deleted: %s%s\n" green delete_id reset + | SudoCancelled -> exit 1 + | SudoError msg -> + Printf.fprintf stderr "%sError: %s%s\n" red msg reset; + exit 1) end else if lock_id <> "" then begin let _ = curl_post api_key (Printf.sprintf "/images/%s/lock" lock_id) "{}" in Printf.printf "%sImage locked: %s%s\n" green lock_id reset end else if unlock_id <> "" then begin - let _ = curl_post api_key (Printf.sprintf "/images/%s/unlock" unlock_id) "{}" in - Printf.printf "%sImage unlocked: %s%s\n" green unlock_id reset + (match api_post_with_sudo (Printf.sprintf "/images/%s/unlock" unlock_id) "{}" with + | SudoSuccess _ -> Printf.printf "%sImage unlocked: %s%s\n" green unlock_id reset + | SudoCancelled -> exit 1 + | SudoError msg -> + Printf.fprintf stderr "%sError: %s%s\n" red msg reset; + exit 1) end else if publish_id <> "" then begin if source_type = "" then begin diff --git a/clients/perl/sync/src/un.pl b/clients/perl/sync/src/un.pl index 19ae7d7..57ae458 100644 --- a/clients/perl/sync/src/un.pl +++ b/clients/perl/sync/src/un.pl @@ -323,6 +323,96 @@ sub detect_language { } sub api_request { + my ($endpoint, $method, $data, $public_key, $secret_key, $extra_headers) = @_; + $method //= 'GET'; + $extra_headers //= {}; + + my $url = "$API_BASE$endpoint"; + my $ua = LWP::UserAgent->new(timeout => 300); + my $request = HTTP::Request->new($method => $url); + $request->header('Authorization' => "Bearer $public_key"); + $request->header('Content-Type' => 'application/json'); + + my $body = ''; + if ($data) { + $body = encode_json($data); + $request->content($body); + } + + # Add HMAC signature if secret_key is present + if ($secret_key) { + my $timestamp = time(); + my $sig_input = "${timestamp}:${method}:${endpoint}:${body}"; + my $signature = hmac_sha256_hex($sig_input, $secret_key); + $request->header('X-Timestamp' => $timestamp); + $request->header('X-Signature' => $signature); + } + + # Add extra headers (for sudo OTP) + for my $key (keys %$extra_headers) { + $request->header($key => $extra_headers->{$key}); + } + + my $response = $ua->request($request); + + unless ($response->is_success) { + if ($response->code == 401 && $response->content =~ /timestamp/i) { + print STDERR "${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}\n"; + print STDERR "${YELLOW}Your computer's clock may have drifted.${RESET}\n"; + print STDERR "${YELLOW}Check your system time and sync with NTP if needed:${RESET}\n"; + print STDERR " Linux: sudo ntpdate -s time.nist.gov\n"; + print STDERR " macOS: sudo sntp -sS time.apple.com\n"; + print STDERR " Windows: w32tm /resync\n"; + } else { + print STDERR "${RED}Error: HTTP ", $response->code, " - ", $response->content, "${RESET}\n"; + } + exit 1; + } + + return decode_json($response->content); +} + +# Handle 428 Sudo OTP challenge - prompt user for OTP and retry +sub handle_sudo_challenge { + my ($response_content, $endpoint, $method, $data, $public_key, $secret_key) = @_; + + # Extract challenge_id from response + my $response_data = eval { decode_json($response_content) } || {}; + my $challenge_id = $response_data->{challenge_id} || ''; + + print STDERR "${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}\n"; + print STDERR "Enter OTP: "; + + my $otp = ; + unless (defined $otp) { + print STDERR "${RED}Error: Failed to read OTP${RESET}\n"; + return 0; + } + chomp $otp; + $otp =~ s/\r//g; + + if ($otp eq '') { + print STDERR "${RED}Error: Operation cancelled${RESET}\n"; + return 0; + } + + # Retry with sudo headers + my $extra_headers = { 'X-Sudo-OTP' => $otp }; + $extra_headers->{'X-Sudo-Challenge'} = $challenge_id if $challenge_id; + + eval { + api_request($endpoint, $method, $data, $public_key, $secret_key, $extra_headers); + }; + if ($@) { + return 0; + } + + print "${GREEN}Operation completed successfully${RESET}\n"; + return 1; +} + +# API request that handles 428 sudo challenges for destructive operations +sub api_request_with_sudo { my ($endpoint, $method, $data, $public_key, $secret_key) = @_; $method //= 'GET'; @@ -349,17 +439,13 @@ sub api_request { my $response = $ua->request($request); + # Handle 428 Precondition Required (sudo OTP needed) + if ($response->code == 428) { + return handle_sudo_challenge($response->content, $endpoint, $method, $data, $public_key, $secret_key); + } + unless ($response->is_success) { - if ($response->code == 401 && $response->content =~ /timestamp/i) { - print STDERR "${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}\n"; - print STDERR "${YELLOW}Your computer's clock may have drifted.${RESET}\n"; - print STDERR "${YELLOW}Check your system time and sync with NTP if needed:${RESET}\n"; - print STDERR " Linux: sudo ntpdate -s time.nist.gov\n"; - print STDERR " macOS: sudo sntp -sS time.apple.com\n"; - print STDERR " Windows: w32tm /resync\n"; - } else { - print STDERR "${RED}Error: HTTP ", $response->code, " - ", $response->content, "${RESET}\n"; - } + print STDERR "${RED}Error: HTTP ", $response->code, " - ", $response->content, "${RESET}\n"; exit 1; } @@ -716,7 +802,7 @@ sub cmd_service { } if ($options->{destroy}) { - api_request("/services/$options->{destroy}", 'DELETE', undef, $public_key, $secret_key); + api_request_with_sudo("/services/$options->{destroy}", 'DELETE', undef, $public_key, $secret_key); print "${GREEN}Service destroyed: $options->{destroy}${RESET}\n"; return; } @@ -974,7 +1060,7 @@ sub cmd_image { } if ($options->{delete}) { - api_request("/images/$options->{delete}", 'DELETE', undef, $public_key, $secret_key); + api_request_with_sudo("/images/$options->{delete}", 'DELETE', undef, $public_key, $secret_key); print "${GREEN}Image deleted: $options->{delete}${RESET}\n"; return; } @@ -986,7 +1072,7 @@ sub cmd_image { } if ($options->{unlock}) { - api_request("/images/$options->{unlock}/unlock", 'POST', undef, $public_key, $secret_key); + api_request_with_sudo("/images/$options->{unlock}/unlock", 'POST', undef, $public_key, $secret_key); print "${GREEN}Image unlocked: $options->{unlock}${RESET}\n"; return; } diff --git a/clients/php/sync/src/un.php b/clients/php/sync/src/un.php index 679ae62..c862830 100644 --- a/clients/php/sync/src/un.php +++ b/clients/php/sync/src/un.php @@ -440,7 +440,7 @@ class Unsandbox { */ public function deleteSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey); + return $this->makeRequestWithSudo('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey); } /** @@ -470,7 +470,7 @@ class Unsandbox { */ public function unlockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('POST', "/snapshots/{$snapshotId}/unlock", $publicKey, $secretKey, []); + return $this->makeRequestWithSudo('POST', "/snapshots/{$snapshotId}/unlock", $publicKey, $secretKey, []); } /** @@ -597,7 +597,7 @@ class Unsandbox { */ public function deleteImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('DELETE', "/images/{$imageId}", $publicKey, $secretKey); + return $this->makeRequestWithSudo('DELETE', "/images/{$imageId}", $publicKey, $secretKey); } /** @@ -627,7 +627,7 @@ class Unsandbox { */ public function unlockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('POST', "/images/{$imageId}/unlock", $publicKey, $secretKey, []); + return $this->makeRequestWithSudo('POST', "/images/{$imageId}/unlock", $publicKey, $secretKey, []); } /** @@ -1121,7 +1121,7 @@ class Unsandbox { */ public function deleteService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('DELETE', "/services/{$serviceId}", $publicKey, $secretKey); + return $this->makeRequestWithSudo('DELETE', "/services/{$serviceId}", $publicKey, $secretKey); } /** @@ -1181,7 +1181,7 @@ class Unsandbox { */ public function unlockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('POST', "/services/{$serviceId}/unlock", $publicKey, $secretKey, []); + return $this->makeRequestWithSudo('POST', "/services/{$serviceId}/unlock", $publicKey, $secretKey, []); } /** @@ -1633,6 +1633,158 @@ class Unsandbox { return $decoded; } + /** + * Make an authenticated HTTP request with sudo OTP challenge handling. + * + * If the server returns 428 (Precondition Required), prompts for OTP + * and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers. + * + * Used for destructive operations: service destroy/unlock, snapshot delete/unlock, + * image delete/unlock. + * + * @param string $method HTTP method (GET, POST, DELETE, etc.) + * @param string $path API endpoint path + * @param string $publicKey API public key + * @param string $secretKey API secret key + * @param array|null $data Request data (optional) + * @return array Decoded JSON response + * @throws ApiException On network errors or non-2xx response + */ + private function makeRequestWithSudo(string $method, string $path, string $publicKey, string $secretKey, ?array $data = null): array { + $url = self::API_BASE . $path; + $timestamp = time(); + $body = $data !== null ? json_encode($data) : ''; + + $signature = $this->signRequest($secretKey, $timestamp, $method, $path, $data !== null ? $body : null); + + $headers = [ + 'Authorization: Bearer ' . $publicKey, + 'X-Timestamp: ' . $timestamp, + 'X-Signature: ' . $signature, + 'Content-Type: application/json', + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 120); + + switch ($method) { + case 'POST': + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'PUT': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'PATCH': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'DELETE': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + break; + case 'GET': + default: + break; + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false) { + throw new ApiException("cURL error: {$error}"); + } + + // Handle 428 sudo OTP challenge + if ($httpCode === 428) { + $challengeId = ''; + $challengeData = json_decode($response, true); + if ($challengeData !== null && isset($challengeData['challenge_id'])) { + $challengeId = $challengeData['challenge_id']; + } + + fwrite(STDERR, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m\n"); + fwrite(STDERR, "Enter OTP: "); + + $otp = ''; + if (stream_isatty(STDIN)) { + $otp = trim(fgets(STDIN)); + } else { + throw new ApiException("Cannot read OTP in non-interactive mode"); + } + + if (empty($otp)) { + throw new ApiException("Operation cancelled"); + } + + // Retry with sudo headers + $retryTimestamp = time(); + $retrySignature = $this->signRequest($secretKey, $retryTimestamp, $method, $path, $data !== null ? $body : null); + + $retryHeaders = [ + 'Authorization: Bearer ' . $publicKey, + 'X-Timestamp: ' . $retryTimestamp, + 'X-Signature: ' . $retrySignature, + 'Content-Type: application/json', + 'X-Sudo-OTP: ' . $otp, + 'X-Sudo-Challenge: ' . $challengeId, + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $retryHeaders); + curl_setopt($ch, CURLOPT_TIMEOUT, 120); + + switch ($method) { + case 'POST': + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'PUT': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'PATCH': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + break; + case 'DELETE': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + break; + case 'GET': + default: + break; + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false) { + throw new ApiException("cURL error: {$error}"); + } + } + + $decoded = json_decode($response, true); + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + throw new ApiException("Invalid JSON response: " . json_last_error_msg()); + } + + if ($httpCode >= 400) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP {$httpCode}"; + throw new ApiException($errorMessage, $httpCode, $decoded); + } + + return $decoded; + } + /** * Make an authenticated HTTP request with raw body content (non-JSON). * diff --git a/clients/powershell/sync/src/un.ps1 b/clients/powershell/sync/src/un.ps1 index bb52849..b15315f 100644 --- a/clients/powershell/sync/src/un.ps1 +++ b/clients/powershell/sync/src/un.ps1 @@ -128,6 +128,111 @@ function Invoke-Api { } } +# Internal API request function that returns status code info for sudo handling +function Invoke-ApiInternal { + param($Endpoint, $Method = "GET", $Body = $null, $SudoOtp = $null, $SudoChallengeId = $null) + + $publicKey, $secretKey = Get-ApiKeys + $headers = @{ + "Authorization" = "Bearer $publicKey" + "Content-Type" = "application/json" + } + + # Add HMAC signature if secret key exists + if ($secretKey) { + $timestamp = [int][double]::Parse((Get-Date -UFormat %s)) + $bodyContent = if ($Body) { $Body } else { "" } + $sigInput = "${timestamp}:${Method}:${Endpoint}:${bodyContent}" + + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($secretKey) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sigInput)) + $signature = [System.BitConverter]::ToString($hash).Replace("-", "").ToLower() + + $headers["X-Timestamp"] = $timestamp.ToString() + $headers["X-Signature"] = $signature + } + + # Add sudo OTP headers if provided + if ($SudoOtp) { + $headers["X-Sudo-OTP"] = $SudoOtp + } + if ($SudoChallengeId) { + $headers["X-Sudo-Challenge"] = $SudoChallengeId + } + + $uri = "$API_BASE$Endpoint" + + try { + if ($Body) { + $response = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -Body $Body + } else { + $response = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers + } + return @{ Success = $true; Response = $response; StatusCode = 200 } + } catch { + $statusCode = 0 + $responseBody = "" + + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + $stream = $_.Exception.Response.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream) + $responseBody = $reader.ReadToEnd() + } + + return @{ Success = $false; StatusCode = $statusCode; ResponseBody = $responseBody; Error = $_.Exception.Message } + } +} + +# Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +function Invoke-SudoChallenge { + param($ResponseBody, $Endpoint, $Method, $Body) + + # Extract challenge_id from response + $challengeId = $null + try { + $parsed = $ResponseBody | ConvertFrom-Json + $challengeId = $parsed.challenge_id + } catch {} + + Write-Host "`e[33mConfirmation required. Check your email for a one-time code.`e[0m" -ForegroundColor Yellow + $otp = Read-Host "Enter OTP" + + if (-not $otp) { + Write-Error "Operation cancelled" + exit 1 + } + + $otp = $otp.Trim() + + # Retry the request with sudo headers + return Invoke-ApiInternal -Endpoint $Endpoint -Method $Method -Body $Body -SudoOtp $otp -SudoChallengeId $challengeId +} + +# Wrapper for destructive operations that may require 428 sudo OTP +function Invoke-ApiWithSudo { + param($Endpoint, $Method = "GET", $Body = $null) + + $result = Invoke-ApiInternal -Endpoint $Endpoint -Method $Method -Body $Body + + if (-not $result.Success -and $result.StatusCode -eq 428) { + $retryResult = Invoke-SudoChallenge -ResponseBody $result.ResponseBody -Endpoint $Endpoint -Method $Method -Body $Body + if (-not $retryResult.Success) { + Write-Error "Error: $($retryResult.Error)" + exit 1 + } + return $retryResult.Response + } + + if (-not $result.Success) { + Write-Error "Error: $($result.Error)" + exit 1 + } + + return $result.Response +} + function Invoke-ApiText { param($Endpoint, $Method, $Body, $BaseUrl = $null) @@ -583,7 +688,7 @@ function Invoke-Image { } if ($deleteId) { - Invoke-Api -Endpoint "/images/$deleteId" -Method "DELETE" + Invoke-ApiWithSudo -Endpoint "/images/$deleteId" -Method "DELETE" Write-Host "`e[32mImage deleted: $deleteId`e[0m" return } @@ -595,7 +700,7 @@ function Invoke-Image { } if ($unlockId) { - Invoke-Api -Endpoint "/images/$unlockId/unlock" -Method "POST" -Body "{}" + Invoke-ApiWithSudo -Endpoint "/images/$unlockId/unlock" -Method "POST" -Body "{}" Write-Host "`e[32mImage unlocked: $unlockId`e[0m" return } @@ -731,7 +836,7 @@ function Invoke-Service { if ($Args -contains "--destroy") { $idx = [array]::IndexOf($Args, "--destroy") $serviceId = $Args[$idx + 1] - Invoke-Api -Endpoint "/services/$serviceId" -Method "DELETE" + Invoke-ApiWithSudo -Endpoint "/services/$serviceId" -Method "DELETE" Write-Host "`e[32mService destroyed: $serviceId`e[0m" return } diff --git a/clients/prolog/sync/src/un.pro b/clients/prolog/sync/src/un.pro index f9db27b..56538a7 100644 --- a/clients/prolog/sync/src/un.pro +++ b/clients/prolog/sync/src/un.pro @@ -232,8 +232,8 @@ service_destroy(ServiceId) :- get_public_key(PublicKey), get_secret_key(SecretKey), format(atom(Cmd), - 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/services/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X DELETE https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mService destroyed: ~w\\x1b[0m"', - [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/services/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -w \'\'\\n%{http_code}\'\' -X DELETE https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE"); HTTP_CODE=$(echo "$RESP" | tail -n1); BODY=$(echo "$RESP" | sed \'\'$d\'\'); if [ "$HTTP_CODE" = "428" ]; then CHALLENGE_ID=$(echo "$BODY" | jq -r \'\'.challenge_id // empty\'\'); echo -e "\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m" >&2; echo -n "Enter OTP: " >&2; read OTP; if [ -z "$OTP" ]; then echo -e "\\x1b[31mError: Operation cancelled\\x1b[0m" >&2; exit 1; fi; TS2=$(date +%s); MSG2="$TS2:DELETE:/services/~w:"; SIG2=$(echo -n "$MSG2" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP2=$(curl -s -w \'\'\\n%{http_code}\'\' -X DELETE https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" -H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID"); HTTP2=$(echo "$RESP2" | tail -n1); if [ "$HTTP2" = "200" ] || [ "$HTTP2" = "204" ]; then echo -e "\\x1b[32mService destroyed: ~w\\x1b[0m"; else echo "$RESP2" | sed \'\'$d\'\' | jq . 2>/dev/null || echo "$RESP2" | sed \'\'$d\'\'; exit 1; fi; elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then echo -e "\\x1b[32mService destroyed: ~w\\x1b[0m"; else echo "$BODY" | jq . 2>/dev/null || echo "$BODY"; exit 1; fi', + [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId, SecretKey, ServiceId, PublicKey, ServiceId, ServiceId]), shell(Cmd, 0). % Service resize @@ -450,8 +450,8 @@ image_delete(ImageId) :- get_public_key(PublicKey), get_secret_key(SecretKey), format(atom(Cmd), - 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/images/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X DELETE https://api.unsandbox.com/images/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mImage deleted: ~w\\x1b[0m"', - [ImageId, SecretKey, ImageId, PublicKey, ImageId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/images/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -w \'\'\\n%{http_code}\'\' -X DELETE https://api.unsandbox.com/images/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE"); HTTP_CODE=$(echo "$RESP" | tail -n1); BODY=$(echo "$RESP" | sed \'\'$d\'\'); if [ "$HTTP_CODE" = "428" ]; then CHALLENGE_ID=$(echo "$BODY" | jq -r \'\'.challenge_id // empty\'\'); echo -e "\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m" >&2; echo -n "Enter OTP: " >&2; read OTP; if [ -z "$OTP" ]; then echo -e "\\x1b[31mError: Operation cancelled\\x1b[0m" >&2; exit 1; fi; TS2=$(date +%s); MSG2="$TS2:DELETE:/images/~w:"; SIG2=$(echo -n "$MSG2" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP2=$(curl -s -w \'\'\\n%{http_code}\'\' -X DELETE https://api.unsandbox.com/images/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" -H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID"); HTTP2=$(echo "$RESP2" | tail -n1); if [ "$HTTP2" = "200" ] || [ "$HTTP2" = "204" ]; then echo -e "\\x1b[32mImage deleted: ~w\\x1b[0m"; else echo "$RESP2" | sed \'\'$d\'\' | jq . 2>/dev/null || echo "$RESP2" | sed \'\'$d\'\'; exit 1; fi; elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then echo -e "\\x1b[32mImage deleted: ~w\\x1b[0m"; else echo "$BODY" | jq . 2>/dev/null || echo "$BODY"; exit 1; fi', + [ImageId, SecretKey, ImageId, PublicKey, ImageId, SecretKey, ImageId, PublicKey, ImageId, ImageId]), shell(Cmd, 0). % Image lock @@ -468,8 +468,8 @@ image_unlock(ImageId) :- get_public_key(PublicKey), get_secret_key(SecretKey), format(atom(Cmd), - 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/images/~w/unlock:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/images/~w/unlock -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mImage unlocked: ~w\\x1b[0m"', - [ImageId, SecretKey, ImageId, PublicKey, ImageId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/images/~w/unlock:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -w \'\'\\n%{http_code}\'\' -X POST https://api.unsandbox.com/images/~w/unlock -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE"); HTTP_CODE=$(echo "$RESP" | tail -n1); BODY=$(echo "$RESP" | sed \'\'$d\'\'); if [ "$HTTP_CODE" = "428" ]; then CHALLENGE_ID=$(echo "$BODY" | jq -r \'\'.challenge_id // empty\'\'); echo -e "\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m" >&2; echo -n "Enter OTP: " >&2; read OTP; if [ -z "$OTP" ]; then echo -e "\\x1b[31mError: Operation cancelled\\x1b[0m" >&2; exit 1; fi; TS2=$(date +%s); MSG2="$TS2:POST:/images/~w/unlock:"; SIG2=$(echo -n "$MSG2" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP2=$(curl -s -w \'\'\\n%{http_code}\'\' -X POST https://api.unsandbox.com/images/~w/unlock -H "Authorization: Bearer ~w" -H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" -H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID"); HTTP2=$(echo "$RESP2" | tail -n1); if [ "$HTTP2" = "200" ] || [ "$HTTP2" = "204" ]; then echo -e "\\x1b[32mImage unlocked: ~w\\x1b[0m"; else echo "$RESP2" | sed \'\'$d\'\' | jq . 2>/dev/null || echo "$RESP2" | sed \'\'$d\'\'; exit 1; fi; elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then echo -e "\\x1b[32mImage unlocked: ~w\\x1b[0m"; else echo "$BODY" | jq . 2>/dev/null || echo "$BODY"; exit 1; fi', + [ImageId, SecretKey, ImageId, PublicKey, ImageId, SecretKey, ImageId, PublicKey, ImageId, ImageId]), shell(Cmd, 0). % Image publish diff --git a/clients/python/sync/src/un.py b/clients/python/sync/src/un.py index 6e93b1f..74e2fd4 100644 --- a/clients/python/sync/src/un.py +++ b/clients/python/sync/src/un.py @@ -271,6 +271,91 @@ def _make_request( return response.json() +def _make_request_with_sudo( + method: str, + path: str, + public_key: str, + secret_key: str, + data: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Make an authenticated HTTP request with sudo OTP challenge handling. + + If the server returns 428 (Precondition Required), prompts for OTP + and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers. + + Used for destructive operations: service destroy/unlock, snapshot delete/unlock, + image delete/unlock. + """ + import sys + + url = f"{API_BASE}{path}" + timestamp = int(time.time()) + body = json.dumps(data) if data else "" + + signature = _sign_request(secret_key, timestamp, method, path, body if data else None) + + headers = { + "Authorization": f"Bearer {public_key}", + "X-Timestamp": str(timestamp), + "X-Signature": signature, + "Content-Type": "application/json", + } + + if method == "GET": + response = requests.get(url, headers=headers, timeout=120) + elif method == "POST": + response = requests.post(url, headers=headers, json=data, timeout=120) + elif method == "PATCH": + response = requests.patch(url, headers=headers, json=data, timeout=120) + elif method == "DELETE": + response = requests.delete(url, headers=headers, timeout=120) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + # Handle 428 sudo OTP challenge + if response.status_code == 428: + try: + challenge_data = response.json() + challenge_id = challenge_data.get("challenge_id", "") + except (ValueError, KeyError): + challenge_id = "" + + print("\033[33mConfirmation required. Check your email for a one-time code.\033[0m", file=sys.stderr) + try: + otp = input("Enter OTP: ").strip() + except (EOFError, KeyboardInterrupt): + raise ValueError("Operation cancelled") + + if not otp: + raise ValueError("Operation cancelled") + + # Retry with sudo headers + retry_timestamp = int(time.time()) + retry_signature = _sign_request(secret_key, retry_timestamp, method, path, body if data else None) + + retry_headers = { + "Authorization": f"Bearer {public_key}", + "X-Timestamp": str(retry_timestamp), + "X-Signature": retry_signature, + "Content-Type": "application/json", + "X-Sudo-OTP": otp, + "X-Sudo-Challenge": challenge_id, + } + + if method == "GET": + response = requests.get(url, headers=retry_headers, timeout=120) + elif method == "POST": + response = requests.post(url, headers=retry_headers, json=data, timeout=120) + elif method == "PATCH": + response = requests.patch(url, headers=retry_headers, json=data, timeout=120) + elif method == "DELETE": + response = requests.delete(url, headers=retry_headers, timeout=120) + + response.raise_for_status() + return response.json() + + def _get_languages_cache_path() -> Path: """Get path to languages cache file.""" return _get_unsandbox_dir() / "languages.json" @@ -758,7 +843,7 @@ def delete_snapshot( CredentialsError: Missing credentials """ public_key, secret_key = _resolve_credentials(public_key, secret_key) - return _make_request("DELETE", f"/snapshots/{snapshot_id}", public_key, secret_key) + return _make_request_with_sudo("DELETE", f"/snapshots/{snapshot_id}", public_key, secret_key) # ============================================================================= @@ -1202,7 +1287,7 @@ def delete_service( CredentialsError: Missing credentials """ public_key, secret_key = _resolve_credentials(public_key, secret_key) - return _make_request("DELETE", f"/services/{service_id}", public_key, secret_key) + return _make_request_with_sudo("DELETE", f"/services/{service_id}", public_key, secret_key) def freeze_service( @@ -1302,7 +1387,7 @@ def unlock_service( CredentialsError: Missing credentials """ public_key, secret_key = _resolve_credentials(public_key, secret_key) - return _make_request("POST", f"/services/{service_id}/unlock", public_key, secret_key, {}) + return _make_request_with_sudo("POST", f"/services/{service_id}/unlock", public_key, secret_key, {}) def update_service_domains( @@ -1682,7 +1767,7 @@ def unlock_snapshot( CredentialsError: Missing credentials """ public_key, secret_key = _resolve_credentials(public_key, secret_key) - return _make_request("POST", f"/snapshots/{snapshot_id}/unlock", public_key, secret_key, {}) + return _make_request_with_sudo("POST", f"/snapshots/{snapshot_id}/unlock", public_key, secret_key, {}) def clone_snapshot( @@ -1849,7 +1934,7 @@ def delete_image( Response dict with deletion confirmation """ public_key, secret_key = _resolve_credentials(public_key, secret_key) - return _make_request("DELETE", f"/images/{image_id}", public_key, secret_key) + return _make_request_with_sudo("DELETE", f"/images/{image_id}", public_key, secret_key) def lock_image( @@ -1889,7 +1974,7 @@ def unlock_image( Response dict with unlock confirmation """ public_key, secret_key = _resolve_credentials(public_key, secret_key) - return _make_request("POST", f"/images/{image_id}/unlock", public_key, secret_key, {}) + return _make_request_with_sudo("POST", f"/images/{image_id}/unlock", public_key, secret_key, {}) def set_image_visibility( diff --git a/clients/r/sync/src/un.r b/clients/r/sync/src/un.r index 0e7fee8..ba4f6b6 100644 --- a/clients/r/sync/src/un.r +++ b/clients/r/sync/src/un.r @@ -246,7 +246,70 @@ build_auth_headers <- function(method, endpoint, body, public_key, secret_key) { } } -api_request <- function(endpoint, public_key, secret_key, method = "GET", data = NULL) { +api_request <- function(endpoint, public_key, secret_key, method = "GET", data = NULL, + sudo_otp = NULL, sudo_challenge = NULL) { + url <- paste0(API_BASE, endpoint) + + body_content <- "" + if (!is.null(data)) { + body_content <- toJSON(data, auto_unbox = TRUE) + } + + headers <- build_auth_headers(method, endpoint, body_content, public_key, secret_key) + + # Add sudo headers if provided + if (!is.null(sudo_otp)) { + headers <- c(headers, add_headers(`X-Sudo-OTP` = sudo_otp)) + } + if (!is.null(sudo_challenge)) { + headers <- c(headers, add_headers(`X-Sudo-Challenge` = sudo_challenge)) + } + + tryCatch({ + if (method == "GET") { + response <- GET(url, headers, timeout(300)) + } else if (method == "POST") { + response <- POST(url, headers, body = body_content, encode = "raw", timeout(300)) + } else if (method == "DELETE") { + response <- DELETE(url, headers, timeout(300)) + } else if (method == "PATCH") { + response <- PATCH(url, headers, body = body_content, encode = "raw", timeout(300)) + } else { + stop(paste("Unsupported method:", method)) + } + + response_text <- content(response, "text", encoding = "UTF-8") + check_clock_drift(response_text) + result <- fromJSON(response_text) + return(result) + }, error = function(e) { + cat(sprintf("%sError: Request failed: %s%s\n", RED, e$message, RESET), file = stderr()) + quit(status = 1) + }) +} + +# Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +handle_sudo_challenge <- function(endpoint, public_key, secret_key, method, data, response_body) { + # Extract challenge_id from response + parsed <- fromJSON(response_body) + challenge_id <- parsed$challenge_id + + cat(sprintf("%sConfirmation required. Check your email for a one-time code.%s\n", YELLOW, RESET), file = stderr()) + cat("Enter OTP: ", file = stderr()) + otp <- trimws(readLines(stdin(), n = 1)) + + if (nchar(otp) == 0) { + cat(sprintf("%sError: Operation cancelled%s\n", RED, RESET), file = stderr()) + quit(status = 1) + } + + # Retry the request with sudo headers + return(api_request(endpoint, public_key, secret_key, method = method, data = data, + sudo_otp = otp, sudo_challenge = challenge_id)) +} + +# API request that handles 428 sudo challenges for destructive operations +api_request_with_sudo <- function(endpoint, public_key, secret_key, method = "DELETE", data = NULL) { url <- paste0(API_BASE, endpoint) body_content <- "" @@ -270,7 +333,20 @@ api_request <- function(endpoint, public_key, secret_key, method = "GET", data = } response_text <- content(response, "text", encoding = "UTF-8") - check_clock_drift(response_text) + status <- status_code(response) + + # Handle 428 - sudo OTP required + if (status == 428) { + return(handle_sudo_challenge(endpoint, public_key, secret_key, method, data, response_text)) + } + + # Handle other errors + if (status >= 400) { + check_clock_drift(response_text) + cat(sprintf("%sError: HTTP %d - %s%s\n", RED, status, response_text, RESET), file = stderr()) + quit(status = 1) + } + result <- fromJSON(response_text) return(result) }, error = function(e) { @@ -1238,7 +1314,7 @@ cmd_snapshot <- function(args) { } if (!is.null(args$delete)) { - result <- api_request(paste0("/snapshots/", args$delete), public_key, secret_key, method = "DELETE") + result <- api_request_with_sudo(paste0("/snapshots/", args$delete), public_key, secret_key, method = "DELETE") cat(sprintf("%sSnapshot deleted successfully%s\n", GREEN, RESET)) return() } @@ -1315,7 +1391,7 @@ cmd_image <- function(args) { } if (!is.null(args$image_delete)) { - result <- api_request(paste0("/images/", args$image_delete), public_key, secret_key, method = "DELETE") + result <- api_request_with_sudo(paste0("/images/", args$image_delete), public_key, secret_key, method = "DELETE") cat(sprintf("%sImage deleted successfully%s\n", GREEN, RESET)) return() } @@ -1327,7 +1403,7 @@ cmd_image <- function(args) { } if (!is.null(args$image_unlock)) { - result <- api_request(paste0("/images/", args$image_unlock, "/unlock"), public_key, secret_key, method = "POST", data = list()) + result <- api_request_with_sudo(paste0("/images/", args$image_unlock, "/unlock"), public_key, secret_key, method = "POST", data = list()) cat(sprintf("%sImage unlocked successfully%s\n", GREEN, RESET)) return() } @@ -1444,7 +1520,7 @@ cmd_service <- function(args) { } if (!is.null(args$destroy)) { - result <- api_request(paste0("/services/", args$destroy), public_key, secret_key, method = "DELETE") + result <- api_request_with_sudo(paste0("/services/", args$destroy), public_key, secret_key, method = "DELETE") cat(sprintf("%sService destroyed: %s%s\n", GREEN, args$destroy, RESET)) return() } diff --git a/clients/raku/sync/src/un.raku b/clients/raku/sync/src/un.raku index 0395289..aa2173c 100644 --- a/clients/raku/sync/src/un.raku +++ b/clients/raku/sync/src/un.raku @@ -250,6 +250,127 @@ sub api-request( return from-json($resp-body); } +#| Make authenticated API request returning both status code and body +sub api-request-with-status( + Str $endpoint, + Str $method = 'GET', + %data?, + Str :$body-text, + Str :$content-type = 'application/json', + Str :$public-key, + Str :$secret-key, + Int :$timeout = $DEFAULT_TIMEOUT, + Str :$sudo-otp, + Str :$sudo-challenge +) returns List is export { + my ($pk, $sk) = get-credentials(:$public-key, :$secret-key); + + my $url = $API_BASE ~ $endpoint; + my @args = 'curl', '-s', '-w', '%{http_code}', '--max-time', $timeout.Str; + my $body = ''; + + if $method eq 'GET' { + @args.append: '-X', 'GET'; + } elsif $method eq 'DELETE' { + @args.append: '-X', 'DELETE'; + } elsif $method eq 'POST' || $method eq 'PUT' || $method eq 'PATCH' { + @args.append: '-X', $method; + @args.append: '-H', "Content-Type: $content-type"; + if $body-text.defined { + $body = $body-text; + @args.append: '-d', $body; + } elsif %data { + $body = to-json(%data); + @args.append: '-d', $body; + } + } + + @args.append: '-H', "Authorization: Bearer $pk"; + + # Add HMAC signature + my $timestamp = now.Int; + my $signature = sign-request($sk, $timestamp, $method, $endpoint, $body); + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + + # Add sudo OTP headers if provided + if $sudo-otp.defined && $sudo-challenge.defined { + @args.append: '-H', "X-Sudo-OTP: $sudo-otp"; + @args.append: '-H', "X-Sudo-Challenge: $sudo-challenge"; + } + + @args.append: $url; + + my $proc = run |@args, :out, :err; + my $output = $proc.out.slurp; + my $err = $proc.err.slurp; + + if $proc.exitcode != 0 { + die APIError.new("API request failed: $err", :status-code(0), :response($err)); + } + + # Extract status code from end of output + my $status-code = 0; + my $resp-body = $output; + if $output.chars >= 3 { + my $code-str = $output.substr($output.chars - 3); + $status-code = $code-str.Int // 0; + $resp-body = $output.substr(0, $output.chars - 3); + } + + return ($status-code, $resp-body); +} + +#| Handle HTTP 428 sudo OTP challenge +#| Returns True if retry succeeded, False otherwise +sub handle-sudo-challenge( + Str $response-body, + Str $endpoint, + Str $method, + Str :$body-text, + %data?, + Str :$public-key, + Str :$secret-key, + Int :$timeout = $DEFAULT_TIMEOUT +) returns Bool is export { + # Extract challenge_id from response + my $challenge-id = ''; + try { + my %resp = from-json($response-body); + $challenge-id = %resp // ''; + } + + unless $challenge-id { + note "{$RED}Error: Could not extract challenge_id from response{$RESET}"; + return False; + } + + # Prompt user for OTP + note "{$YELLOW}Confirmation required. Check your email for a one-time code.{$RESET}"; + $*ERR.print("Enter OTP: "); + my $otp = $*IN.get.trim; + + unless $otp { + note "{$RED}Error: No OTP provided{$RESET}"; + return False; + } + + # Retry request with sudo headers + my ($status, $body) = api-request-with-status( + $endpoint, $method, %data, + :$body-text, + :$public-key, :$secret-key, :$timeout, + :sudo-otp($otp), :sudo-challenge($challenge-id) + ); + + if $status >= 200 && $status < 300 { + return True; + } else { + note "{$RED}Error: OTP verification failed{$RESET}"; + return False; + } +} + # ============================================================================ # Core Execution Functions # ============================================================================ @@ -1056,8 +1177,20 @@ sub cmd-service(@args) { } if $destroy-id { - api-request("/services/$destroy-id", 'DELETE', :$public-key, :$secret-key); - say "{$GREEN}Service destroyed: $destroy-id{$RESET}"; + my ($status, $body) = api-request-with-status("/services/$destroy-id", 'DELETE', :$public-key, :$secret-key); + if $status == 428 { + if handle-sudo-challenge($body, "/services/$destroy-id", 'DELETE', :$public-key, :$secret-key) { + say "{$GREEN}Service destroyed: $destroy-id{$RESET}"; + } else { + note "{$RED}Error: Failed to destroy service (OTP verification failed){$RESET}"; + exit 1; + } + } elsif $status >= 200 && $status < 300 { + say "{$GREEN}Service destroyed: $destroy-id{$RESET}"; + } else { + note "{$RED}Error: Failed to destroy service (HTTP $status){$RESET}"; + exit 1; + } return; } @@ -1325,8 +1458,20 @@ sub cmd-image(@args) { } if $delete-id { - api-request("/images/$delete-id", 'DELETE', :$public-key, :$secret-key); - say "{$GREEN}Image deleted successfully{$RESET}"; + my ($status, $body) = api-request-with-status("/images/$delete-id", 'DELETE', :$public-key, :$secret-key); + if $status == 428 { + if handle-sudo-challenge($body, "/images/$delete-id", 'DELETE', :$public-key, :$secret-key) { + say "{$GREEN}Image deleted successfully{$RESET}"; + } else { + note "{$RED}Error: Failed to delete image (OTP verification failed){$RESET}"; + exit 1; + } + } elsif $status >= 200 && $status < 300 { + say "{$GREEN}Image deleted successfully{$RESET}"; + } else { + note "{$RED}Error: Failed to delete image (HTTP $status){$RESET}"; + exit 1; + } return; } @@ -1337,8 +1482,20 @@ sub cmd-image(@args) { } if $unlock-id { - api-request("/images/$unlock-id/unlock", 'POST', :$public-key, :$secret-key); - say "{$GREEN}Image unlocked successfully{$RESET}"; + my ($status, $body) = api-request-with-status("/images/$unlock-id/unlock", 'POST', :$public-key, :$secret-key); + if $status == 428 { + if handle-sudo-challenge($body, "/images/$unlock-id/unlock", 'POST', :$public-key, :$secret-key) { + say "{$GREEN}Image unlocked successfully{$RESET}"; + } else { + note "{$RED}Error: Failed to unlock image (OTP verification failed){$RESET}"; + exit 1; + } + } elsif $status >= 200 && $status < 300 { + say "{$GREEN}Image unlocked successfully{$RESET}"; + } else { + note "{$RED}Error: Failed to unlock image (HTTP $status){$RESET}"; + exit 1; + } return; } diff --git a/clients/ruby/sync/src/un.rb b/clients/ruby/sync/src/un.rb index c7a83dc..86df1bc 100644 --- a/clients/ruby/sync/src/un.rb +++ b/clients/ruby/sync/src/un.rb @@ -373,7 +373,7 @@ module Un # Un.delete_snapshot(snapshot_id) def delete_snapshot(snapshot_id, public_key: nil, secret_key: nil) pk, sk = resolve_credentials(public_key, secret_key) - make_request('DELETE', "/snapshots/#{snapshot_id}", pk, sk) + make_request_with_sudo('DELETE', "/snapshots/#{snapshot_id}", pk, sk) end # Lock a snapshot to prevent deletion @@ -405,7 +405,7 @@ module Un # Un.unlock_snapshot(snapshot_id) def unlock_snapshot(snapshot_id, public_key: nil, secret_key: nil) pk, sk = resolve_credentials(public_key, secret_key) - make_request('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {}) + make_request_with_sudo('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {}) end # Clone a snapshot to create a new session or service @@ -523,7 +523,7 @@ module Un # Un.delete_image(image_id) def delete_image(image_id, public_key: nil, secret_key: nil) pk, sk = resolve_credentials(public_key, secret_key) - make_request('DELETE', "/images/#{image_id}", pk, sk) + make_request_with_sudo('DELETE', "/images/#{image_id}", pk, sk) end # Lock an image to prevent deletion @@ -555,7 +555,7 @@ module Un # Un.unlock_image(image_id) def unlock_image(image_id, public_key: nil, secret_key: nil) pk, sk = resolve_credentials(public_key, secret_key) - make_request('POST', "/images/#{image_id}/unlock", pk, sk, {}) + make_request_with_sudo('POST', "/images/#{image_id}/unlock", pk, sk, {}) end # Set image visibility (private, public, or shared) @@ -969,7 +969,7 @@ module Un # Un.delete_service(service_id) def delete_service(service_id, public_key: nil, secret_key: nil) pk, sk = resolve_credentials(public_key, secret_key) - make_request('DELETE', "/services/#{service_id}", pk, sk) + make_request_with_sudo('DELETE', "/services/#{service_id}", pk, sk) end # Freeze a service (stop container, reduce resource usage) @@ -1033,7 +1033,7 @@ module Un # Un.unlock_service(service_id) def unlock_service(service_id, public_key: nil, secret_key: nil) pk, sk = resolve_credentials(public_key, secret_key) - make_request('POST', "/services/#{service_id}/unlock", pk, sk, {}) + make_request_with_sudo('POST', "/services/#{service_id}/unlock", pk, sk, {}) end # Set unfreeze-on-demand for a service @@ -1480,6 +1480,123 @@ module Un raise end + # Make an authenticated HTTP request with sudo OTP challenge handling. + # + # If the server returns 428 (Precondition Required), prompts for OTP + # and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers. + # + # Used for destructive operations: service destroy/unlock, snapshot delete/unlock, + # image delete/unlock. + # + # @param method [String] HTTP method (GET, POST, DELETE) + # @param path [String] API path + # @param public_key [String] API public key + # @param secret_key [String] API secret key + # @param data [Hash, nil] Request body data + # @return [Hash] Parsed JSON response + # @raise [APIError] If request fails + def make_request_with_sudo(method, path, public_key, secret_key, data = nil) + uri = URI.parse("#{API_BASE}#{path}") + timestamp = Time.now.to_i + body = data ? JSON.generate(data) : '' + + signature = sign_request(secret_key, timestamp, method, path, data ? body : nil) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = REQUEST_TIMEOUT + http.read_timeout = REQUEST_TIMEOUT + + headers = { + 'Authorization' => "Bearer #{public_key}", + 'X-Timestamp' => timestamp.to_s, + 'X-Signature' => signature, + 'Content-Type' => 'application/json' + } + + response = case method + when 'GET' + http.get(uri.request_uri, headers) + when 'POST' + http.post(uri.request_uri, body, headers) + when 'PATCH' + http.patch(uri.request_uri, body, headers) + when 'PUT' + http.put(uri.request_uri, body, headers) + when 'DELETE' + req = Net::HTTP::Delete.new(uri.request_uri, headers) + req.body = body if data + http.request(req) + else + raise APIError, "Unsupported HTTP method: #{method}" + end + + # Handle 428 sudo OTP challenge + if response.code.to_i == 428 + challenge_id = '' + begin + challenge_data = JSON.parse(response.body) + challenge_id = challenge_data['challenge_id'] || '' + rescue JSON::ParserError + # Ignore JSON parse errors + end + + $stderr.puts "\e[33mConfirmation required. Check your email for a one-time code.\e[0m" + $stderr.print 'Enter OTP: ' + otp = $stdin.gets&.strip + + raise APIError, 'Operation cancelled' if otp.nil? || otp.empty? + + # Retry with sudo headers + retry_timestamp = Time.now.to_i + retry_signature = sign_request(secret_key, retry_timestamp, method, path, data ? body : nil) + + retry_headers = { + 'Authorization' => "Bearer #{public_key}", + 'X-Timestamp' => retry_timestamp.to_s, + 'X-Signature' => retry_signature, + 'Content-Type' => 'application/json', + 'X-Sudo-OTP' => otp, + 'X-Sudo-Challenge' => challenge_id + } + + response = case method + when 'GET' + http.get(uri.request_uri, retry_headers) + when 'POST' + http.post(uri.request_uri, body, retry_headers) + when 'PATCH' + http.patch(uri.request_uri, body, retry_headers) + when 'PUT' + http.put(uri.request_uri, body, retry_headers) + when 'DELETE' + req = Net::HTTP::Delete.new(uri.request_uri, retry_headers) + req.body = body if data + http.request(req) + else + raise APIError, "Unsupported HTTP method: #{method}" + end + end + + unless response.is_a?(Net::HTTPSuccess) + raise APIError.new( + "API request failed: #{response.code} #{response.message}", + status_code: response.code.to_i, + response_body: response.body + ) + end + + JSON.parse(response.body) + rescue JSON::ParserError => e + raise APIError, "Invalid JSON response: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise APIError, "Request timeout: #{e.message}" + rescue StandardError => e + raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError) + + raise + end + # Make an authenticated HTTP request with text/plain content type # # @param method [String] HTTP method (PUT) diff --git a/clients/rust/sync/src/lib.rs b/clients/rust/sync/src/lib.rs index 4d567ea..99b276e 100644 --- a/clients/rust/sync/src/lib.rs +++ b/clients/rust/sync/src/lib.rs @@ -65,7 +65,7 @@ use sha2::Sha256; use std::collections::HashMap; use std::env; use std::fs; -use std::io::{BufRead, BufReader}; +use std::io::{self, BufRead, BufReader, Write}; use std::path::PathBuf; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -838,6 +838,168 @@ fn make_request Deserialize<'de>>( Ok(result) } +/// Make an authenticated HTTP request that may require sudo OTP confirmation +fn make_destructive_request Deserialize<'de>>( + method: &str, + path: &str, + creds: &Credentials, + body: Option<&impl Serialize>, +) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + + let url = format!("{}{}", API_BASE, path); + let timestamp = get_timestamp(); + + let body_str = match body { + Some(b) => serde_json::to_string(b)?, + None => String::new(), + }; + + let signature = sign_request(&creds.secret_key, timestamp, method, path, &body_str); + + let mut request = match method { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PATCH" => client.patch(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + _ => client.get(&url), + }; + + request = request + .header("Authorization", format!("Bearer {}", creds.public_key)) + .header("X-Timestamp", timestamp.to_string()) + .header("X-Signature", signature) + .header("Content-Type", "application/json") + .header("User-Agent", "un-rust-sync/2.0"); + + if !body_str.is_empty() { + request = request.body(body_str.clone()); + } + + let response = request.send()?; + let status = response.status().as_u16(); + let response_text = response.text()?; + + // Handle 428 sudo challenge + if status == 428 { + return handle_sudo_challenge(method, path, creds, body, &response_text); + } + + if status < 200 || status >= 300 { + return Err(UnsandboxError::ApiError { + status, + message: response_text, + }); + } + + let result: T = serde_json::from_str(&response_text)?; + Ok(result) +} + +/// Handle 428 sudo OTP challenge - prompts user for OTP and retries request +fn handle_sudo_challenge Deserialize<'de>>( + method: &str, + path: &str, + creds: &Credentials, + body: Option<&impl Serialize>, + response_text: &str, +) -> Result { + // Extract challenge_id from response + #[derive(Deserialize)] + struct ChallengeResponse { + challenge_id: Option, + } + + let challenge: ChallengeResponse = serde_json::from_str(response_text) + .unwrap_or(ChallengeResponse { challenge_id: None }); + + let challenge_id = challenge.challenge_id.unwrap_or_default(); + + // Prompt user for OTP + eprintln!("\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m"); + eprint!("Enter OTP: "); + io::stderr().flush().ok(); + + let mut otp = String::new(); + io::stdin().read_line(&mut otp).map_err(|e| UnsandboxError::IoError(e))?; + let otp = otp.trim(); + + if otp.is_empty() { + return Err(UnsandboxError::ApiError { + status: 428, + message: "Operation cancelled".to_string(), + }); + } + + // Retry the request with sudo headers + let client = Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + + let url = format!("{}{}", API_BASE, path); + let timestamp = get_timestamp(); + + let body_str = match body { + Some(b) => serde_json::to_string(b)?, + None => String::new(), + }; + + let signature = sign_request(&creds.secret_key, timestamp, method, path, &body_str); + + let mut request = match method { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PATCH" => client.patch(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + _ => client.get(&url), + }; + + request = request + .header("Authorization", format!("Bearer {}", creds.public_key)) + .header("X-Timestamp", timestamp.to_string()) + .header("X-Signature", signature) + .header("Content-Type", "application/json") + .header("User-Agent", "un-rust-sync/2.0") + .header("X-Sudo-OTP", otp) + .header("X-Sudo-Challenge", &challenge_id); + + if !body_str.is_empty() { + request = request.body(body_str); + } + + let response = request.send()?; + let status = response.status().as_u16(); + let response_text = response.text()?; + + if status < 200 || status >= 300 { + // Try to extract error message + #[derive(Deserialize)] + struct ErrorResponse { + error: Option, + } + if let Ok(err_resp) = serde_json::from_str::(&response_text) { + if let Some(err_msg) = err_resp.error { + return Err(UnsandboxError::ApiError { + status, + message: err_msg, + }); + } + } + return Err(UnsandboxError::ApiError { + status, + message: response_text, + }); + } + + eprintln!("\x1b[32mOperation completed successfully\x1b[0m"); + let result: T = serde_json::from_str(&response_text)?; + Ok(result) +} + // ============================================================================= // Languages Cache // ============================================================================= @@ -1156,12 +1318,14 @@ pub fn restore_snapshot(snapshot_id: &str, creds: &Credentials) -> Result Result<()> { let path = format!("/snapshots/{}", snapshot_id); - let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?; + let _: serde_json::Value = make_destructive_request("DELETE", &path, creds, None::<&()>)?; Ok(()) } @@ -1181,6 +1345,8 @@ pub fn lock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result /// Unlock a snapshot to allow deletion. /// +/// This operation may require sudo OTP confirmation (428 response handling). +/// /// # Arguments /// * `snapshot_id` - Snapshot ID to unlock /// * `creds` - API credentials @@ -1190,7 +1356,7 @@ pub fn lock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result pub fn unlock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result { let path = format!("/snapshots/{}/unlock", snapshot_id); let body = serde_json::json!({}); - make_request("POST", &path, creds, Some(&body)) + make_destructive_request("POST", &path, creds, Some(&body)) } /// Clone a snapshot to create a new snapshot with a different name. @@ -1306,13 +1472,14 @@ pub fn get_image(image_id: &str, creds: &Credentials) -> Result { /// Delete an LXD container image. /// /// The image must be unlocked to be deleted. +/// This operation may require sudo OTP confirmation (428 response handling). /// /// # Arguments /// * `image_id` - Image ID to delete /// * `creds` - API credentials pub fn delete_image(image_id: &str, creds: &Credentials) -> Result<()> { let path = format!("/images/{}", image_id); - let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?; + let _: serde_json::Value = make_destructive_request("DELETE", &path, creds, None::<&()>)?; Ok(()) } @@ -1332,6 +1499,8 @@ pub fn lock_image(image_id: &str, creds: &Credentials) -> Result { /// Unlock an LXD container image to allow modification or deletion. /// +/// This operation may require sudo OTP confirmation (428 response handling). +/// /// # Arguments /// * `image_id` - Image ID to unlock /// * `creds` - API credentials @@ -1341,7 +1510,7 @@ pub fn lock_image(image_id: &str, creds: &Credentials) -> Result { pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result { let path = format!("/images/{}/unlock", image_id); let body = serde_json::json!({}); - make_request("POST", &path, creds, Some(&body)) + make_destructive_request("POST", &path, creds, Some(&body)) } /// Set the visibility of an LXD container image. @@ -1868,12 +2037,14 @@ pub fn update_service( /// Delete (destroy) a service. /// +/// This operation may require sudo OTP confirmation (428 response handling). +/// /// # Arguments /// * `service_id` - Service ID to delete /// * `creds` - API credentials pub fn delete_service(service_id: &str, creds: &Credentials) -> Result<()> { let path = format!("/services/{}", service_id); - let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?; + let _: serde_json::Value = make_destructive_request("DELETE", &path, creds, None::<&()>)?; Ok(()) } @@ -1921,6 +2092,8 @@ pub fn lock_service(service_id: &str, creds: &Credentials) -> Result { /// Unlock a service to allow modifications. /// +/// This operation may require sudo OTP confirmation (428 response handling). +/// /// # Arguments /// * `service_id` - Service ID to unlock /// * `creds` - API credentials @@ -1930,7 +2103,7 @@ pub fn lock_service(service_id: &str, creds: &Credentials) -> Result { pub fn unlock_service(service_id: &str, creds: &Credentials) -> Result { let path = format!("/services/{}/unlock", service_id); let body = serde_json::json!({}); - make_request("POST", &path, creds, Some(&body)) + make_destructive_request("POST", &path, creds, Some(&body)) } /// Set the unfreeze_on_demand flag for a service. diff --git a/clients/scheme/sync/src/un.scm b/clients/scheme/sync/src/un.scm index 62fbdec..acdce2f 100644 --- a/clients/scheme/sync/src/un.scm +++ b/clients/scheme/sync/src/un.scm @@ -214,6 +214,121 @@ (exit 1)) output)) +(define (parse-http-response-with-code response) + "Parse response to extract body and HTTP status code from curl -w output" + (let* ((lines (string-split response #\newline)) + (last-line (if (null? lines) "" (car (last-pair lines)))) + (code (string->number (string-trim-both last-line)))) + (if code + (cons (string-join (reverse (cdr (reverse lines))) "\n") code) + (cons response 0)))) + +(define (handle-sudo-challenge response-data public-key secret-key method endpoint body) + "Handle 428 sudo OTP challenge - prompts user for OTP and retries" + (let ((challenge-id (json-extract-string response-data "challenge_id"))) + (format (current-error-port) "~aConfirmation required. Check your email for a one-time code.~a\n" yellow reset) + (display "Enter OTP: " (current-error-port)) + (force-output (current-error-port)) + (let ((otp (string-trim-both (read-line)))) + (when (string=? otp "") + (display "Error: Operation cancelled\n" (current-error-port)) + (exit 1)) + ;; Retry the request with sudo headers + (let* ((auth-headers (build-auth-headers public-key secret-key method endpoint (or body ""))) + (sudo-otp-header (format #f "-H 'X-Sudo-OTP: ~a'" otp)) + (sudo-challenge-header (format #f "-H 'X-Sudo-Challenge: ~a'" (or challenge-id ""))) + (method-flag (cond + ((equal? method "DELETE") "-X DELETE") + ((equal? method "POST") "-X POST") + (else (format #f "-X ~a" method)))) + (content-header (if body "-H 'Content-Type: application/json'" "")) + (body-part (if body (format #f "-d '~a'" body) "")) + (cmd (string-append "curl -s -w '\\n%{http_code}' " method-flag + " https://api.unsandbox.com" endpoint + " " (string-join auth-headers " ") + " " sudo-otp-header + " " sudo-challenge-header + " " content-header + " " body-part)) + (port (open-input-pipe cmd)) + (output (let loop ((lines '())) + (let ((line (read-line port))) + (if (eof-object? line) + (string-join (reverse lines) "\n") + (loop (cons line lines))))))) + (close-pipe port) + (let* ((parsed (parse-http-response-with-code output)) + (resp-body (car parsed)) + (http-code (cdr parsed))) + (if (and (>= http-code 200) (< http-code 300)) + (cons #t resp-body) + (begin + (format (current-error-port) "~aError: HTTP ~a~a\n" red http-code reset) + (format (current-error-port) "~a\n" resp-body) + (cons #f resp-body)))))))) + +(define (curl-delete-with-sudo api-key endpoint) + "DELETE request that handles 428 sudo OTP challenge" + (let* ((keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "")) + (cmd (string-append "curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com" endpoint + " " (string-join auth-headers " "))) + (port (open-input-pipe cmd)) + (output (let loop ((lines '())) + (let ((line (read-line port))) + (if (eof-object? line) + (string-join (reverse lines) "\n") + (loop (cons line lines))))))) + (close-pipe port) + (let* ((parsed (parse-http-response-with-code output)) + (body (car parsed)) + (http-code (cdr parsed))) + ;; Check for clock drift + (when (and (string-contains body "timestamp") + (or (string-contains body "401") + (string-contains body "expired") + (string-contains body "invalid"))) + (format (current-error-port) "~aError: Request timestamp expired~a\n" red reset) + (exit 1)) + (if (= http-code 428) + (handle-sudo-challenge body public-key secret-key "DELETE" endpoint #f) + (cons (and (>= http-code 200) (< http-code 300)) body))))) + +(define (curl-post-with-sudo api-key endpoint json-data) + "POST request that handles 428 sudo OTP challenge" + (let* ((tmp-file (write-temp-file json-data)) + (keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)) + (cmd (string-append "curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com" endpoint + " -H 'Content-Type: application/json' " + (string-join auth-headers " ") + " -d @" tmp-file)) + (port (open-input-pipe cmd)) + (output (let loop ((lines '())) + (let ((line (read-line port))) + (if (eof-object? line) + (string-join (reverse lines) "\n") + (loop (cons line lines))))))) + (close-pipe port) + (delete-file tmp-file) + (let* ((parsed (parse-http-response-with-code output)) + (body (car parsed)) + (http-code (cdr parsed))) + ;; Check for clock drift + (when (and (string-contains body "timestamp") + (or (string-contains body "401") + (string-contains body "expired") + (string-contains body "invalid"))) + (format (current-error-port) "~aError: Request timestamp expired~a\n" red reset) + (exit 1)) + (if (= http-code 428) + (handle-sudo-challenge body public-key secret-key "POST" endpoint json-data) + (cons (and (>= http-code 200) (< http-code 300)) body))))) + (define (curl-patch api-key endpoint json-data) (let* ((tmp-file (write-temp-file json-data)) (keys (get-api-keys)) @@ -562,8 +677,12 @@ (curl-post api-key (format #f "/services/~a/unfreeze" id) "{}") (format #t "~aService unfreezing: ~a~a\n" green id reset)) ((equal? action "destroy") - (curl-delete api-key (format #f "/services/~a" id)) - (format #t "~aService destroyed: ~a~a\n" green id reset)) + (let ((result (curl-delete-with-sudo api-key (format #f "/services/~a" id)))) + (if (car result) + (format #t "~aService destroyed: ~a~a\n" green id reset) + (begin + (format (current-error-port) "~aError destroying service~a\n" red reset) + (exit 1))))) ((equal? action "resize") (if (and vcpu (>= vcpu 1) (<= vcpu 8)) (let* ((json (format #f "{\"vcpu\":~a}" vcpu)) @@ -661,14 +780,22 @@ (display (curl-get api-key (format #f "/images/~a" id))) (newline)) ((equal? action "delete") - (curl-delete api-key (format #f "/images/~a" id)) - (format #t "~aImage deleted successfully~a\n" green reset)) + (let ((result (curl-delete-with-sudo api-key (format #f "/images/~a" id)))) + (if (car result) + (format #t "~aImage deleted successfully~a\n" green reset) + (begin + (format (current-error-port) "~aError deleting image~a\n" red reset) + (exit 1))))) ((equal? action "lock") (curl-post api-key (format #f "/images/~a/lock" id) "{}") (format #t "~aImage locked successfully~a\n" green reset)) ((equal? action "unlock") - (curl-post api-key (format #f "/images/~a/unlock" id) "{}") - (format #t "~aImage unlocked successfully~a\n" green reset)) + (let ((result (curl-post-with-sudo api-key (format #f "/images/~a/unlock" id) "{}"))) + (if (car result) + (format #t "~aImage unlocked successfully~a\n" green reset) + (begin + (format (current-error-port) "~aError unlocking image~a\n" red reset) + (exit 1))))) ((equal? action "publish") (if (not source-type) (begin diff --git a/clients/swift/sync/src/un.swift b/clients/swift/sync/src/un.swift index 7d4af39..cea980a 100644 --- a/clients/swift/sync/src/un.swift +++ b/clients/swift/sync/src/un.swift @@ -216,6 +216,162 @@ func signRequest(secretKey: String, timestamp: Int, method: String, path: String return hmac.map { String(format: "%02x", $0) }.joined() } +// MARK: - Sudo OTP Challenge Handling + +/// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +func handleSudoChallenge(responseData: [String: Any], publicKey: String, secretKey: String, method: String, path: String, body: String?) throws -> [String: Any] { + let challengeId = responseData["challenge_id"] as? String + + fputs("\u{001B}[33mConfirmation required. Check your email for a one-time code.\u{001B}[0m\n", stderr) + fputs("Enter OTP: ", stderr) + + guard let otp = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), !otp.isEmpty else { + fputs("\u{001B}[31mError: Operation cancelled\u{001B}[0m\n", stderr) + throw UnsandboxError.invalidArgument("Operation cancelled - no OTP provided") + } + + let url = URL(string: "\(API_BASE)\(path)")! + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 120 + + let timestamp = Int(Date().timeIntervalSince1970) + let bodyStr = body ?? "" + let signature = signRequest(secretKey: secretKey, timestamp: timestamp, method: method, path: path, body: method != "GET" && method != "DELETE" ? bodyStr : nil) + + request.setValue("Bearer \(publicKey)", forHTTPHeaderField: "Authorization") + request.setValue("\(timestamp)", forHTTPHeaderField: "X-Timestamp") + request.setValue(signature, forHTTPHeaderField: "X-Signature") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(otp, forHTTPHeaderField: "X-Sudo-OTP") + if let challengeId = challengeId { + request.setValue(challengeId, forHTTPHeaderField: "X-Sudo-Challenge") + } + + if let body = body, !body.isEmpty { + request.httpBody = body.data(using: .utf8) + } + + var result: [String: Any]? + var requestError: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + requestError = UnsandboxError.networkError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + requestError = UnsandboxError.invalidResponse("No HTTP response") + return + } + + guard let data = data else { + requestError = UnsandboxError.invalidResponse("No data received") + return + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + fputs("\u{001B}[32mOperation completed successfully\u{001B}[0m\n", stderr) + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } else { + result = [:] + } + } catch { + result = [:] + } + } else { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + requestError = UnsandboxError.apiError(httpResponse.statusCode, body) + } + } + + task.resume() + semaphore.wait() + + if let error = requestError { + throw error + } + + return result ?? [:] +} + +/// Make an authenticated HTTP request that may require sudo OTP, returns (statusCode, response) +func makeRequestWithSudo(method: String, path: String, publicKey: String, secretKey: String, data: [String: Any]? = nil) throws -> (Int, [String: Any]) { + let url = URL(string: "\(API_BASE)\(path)")! + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 120 + + let timestamp = Int(Date().timeIntervalSince1970) + var bodyStr: String? = nil + + if let data = data { + let jsonData = try JSONSerialization.data(withJSONObject: data) + bodyStr = String(data: jsonData, encoding: .utf8) + request.httpBody = jsonData + } + + let signature = signRequest(secretKey: secretKey, timestamp: timestamp, method: method, path: path, body: method != "GET" && method != "DELETE" ? bodyStr : nil) + + request.setValue("Bearer \(publicKey)", forHTTPHeaderField: "Authorization") + request.setValue("\(timestamp)", forHTTPHeaderField: "X-Timestamp") + request.setValue(signature, forHTTPHeaderField: "X-Signature") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var statusCode: Int = 0 + var result: [String: Any]? + var requestError: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + requestError = UnsandboxError.networkError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + requestError = UnsandboxError.invalidResponse("No HTTP response") + return + } + + statusCode = httpResponse.statusCode + + guard let data = data else { + result = [:] + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } else { + result = [:] + } + } catch { + result = [:] + } + } + + task.resume() + semaphore.wait() + + if let error = requestError { + throw error + } + + return (statusCode, result ?? [:]) +} + // MARK: - HTTP Client /// Make an authenticated HTTP request to the API @@ -688,10 +844,20 @@ func updateService(_ serviceId: String, vcpu: Int? = nil, publicKey: String? = n return try makeRequest(method: "PATCH", path: "/services/\(serviceId)", publicKey: pk, secretKey: sk, data: data) } -/// Delete/destroy a service +/// Delete/destroy a service (handles 428 sudo OTP challenge) func deleteService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) - return try makeRequest(method: "DELETE", path: "/services/\(serviceId)", publicKey: pk, secretKey: sk) + let path = "/services/\(serviceId)" + let (statusCode, response) = try makeRequestWithSudo(method: "DELETE", path: path, publicKey: pk, secretKey: sk) + + if statusCode == 428 { + return try handleSudoChallenge(responseData: response, publicKey: pk, secretKey: sk, method: "DELETE", path: path, body: nil) + } else if statusCode >= 400 { + let errorMsg = response["error"] as? String ?? "Unknown error" + throw UnsandboxError.apiError(statusCode, errorMsg) + } + + return response } /// Freeze a service (pause execution, preserve state) @@ -712,10 +878,20 @@ func lockService(_ serviceId: String, publicKey: String? = nil, secretKey: Strin return try makeRequest(method: "POST", path: "/services/\(serviceId)/lock", publicKey: pk, secretKey: sk, data: [:]) } -/// Unlock a service to allow deletion +/// Unlock a service to allow deletion (handles 428 sudo OTP challenge) func unlockService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) - return try makeRequest(method: "POST", path: "/services/\(serviceId)/unlock", publicKey: pk, secretKey: sk, data: [:]) + let path = "/services/\(serviceId)/unlock" + let (statusCode, response) = try makeRequestWithSudo(method: "POST", path: path, publicKey: pk, secretKey: sk, data: [:]) + + if statusCode == 428 { + return try handleSudoChallenge(responseData: response, publicKey: pk, secretKey: sk, method: "POST", path: path, body: "{}") + } else if statusCode >= 400 { + let errorMsg = response["error"] as? String ?? "Unknown error" + throw UnsandboxError.apiError(statusCode, errorMsg) + } + + return response } /// Enable or disable automatic unfreezing on incoming requests @@ -816,10 +992,20 @@ func restoreSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/restore", publicKey: pk, secretKey: sk, data: [:]) } -/// Delete a snapshot +/// Delete a snapshot (handles 428 sudo OTP challenge) func deleteSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) - return try makeRequest(method: "DELETE", path: "/snapshots/\(snapshotId)", publicKey: pk, secretKey: sk) + let path = "/snapshots/\(snapshotId)" + let (statusCode, response) = try makeRequestWithSudo(method: "DELETE", path: path, publicKey: pk, secretKey: sk) + + if statusCode == 428 { + return try handleSudoChallenge(responseData: response, publicKey: pk, secretKey: sk, method: "DELETE", path: path, body: nil) + } else if statusCode >= 400 { + let errorMsg = response["error"] as? String ?? "Unknown error" + throw UnsandboxError.apiError(statusCode, errorMsg) + } + + return response } /// Lock a snapshot to prevent accidental deletion @@ -828,10 +1014,20 @@ func lockSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: Str return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/lock", publicKey: pk, secretKey: sk, data: [:]) } -/// Unlock a snapshot to allow deletion +/// Unlock a snapshot to allow deletion (handles 428 sudo OTP challenge) func unlockSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) - return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/unlock", publicKey: pk, secretKey: sk, data: [:]) + let path = "/snapshots/\(snapshotId)/unlock" + let (statusCode, response) = try makeRequestWithSudo(method: "POST", path: path, publicKey: pk, secretKey: sk, data: [:]) + + if statusCode == 428 { + return try handleSudoChallenge(responseData: response, publicKey: pk, secretKey: sk, method: "POST", path: path, body: "{}") + } else if statusCode >= 400 { + let errorMsg = response["error"] as? String ?? "Unknown error" + throw UnsandboxError.apiError(statusCode, errorMsg) + } + + return response } /// Clone a snapshot to create a new session or service @@ -896,10 +1092,20 @@ func getImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = return try makeRequest(method: "GET", path: "/images/\(imageId)", publicKey: pk, secretKey: sk) } -/// Delete an image +/// Delete an image (handles 428 sudo OTP challenge) func deleteImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) - return try makeRequest(method: "DELETE", path: "/images/\(imageId)", publicKey: pk, secretKey: sk) + let path = "/images/\(imageId)" + let (statusCode, response) = try makeRequestWithSudo(method: "DELETE", path: path, publicKey: pk, secretKey: sk) + + if statusCode == 428 { + return try handleSudoChallenge(responseData: response, publicKey: pk, secretKey: sk, method: "DELETE", path: path, body: nil) + } else if statusCode >= 400 { + let errorMsg = response["error"] as? String ?? "Unknown error" + throw UnsandboxError.apiError(statusCode, errorMsg) + } + + return response } /// Lock an image to prevent accidental deletion @@ -908,10 +1114,20 @@ func lockImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = return try makeRequest(method: "POST", path: "/images/\(imageId)/lock", publicKey: pk, secretKey: sk, data: [:]) } -/// Unlock an image to allow deletion +/// Unlock an image to allow deletion (handles 428 sudo OTP challenge) func unlockImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) - return try makeRequest(method: "POST", path: "/images/\(imageId)/unlock", publicKey: pk, secretKey: sk, data: [:]) + let path = "/images/\(imageId)/unlock" + let (statusCode, response) = try makeRequestWithSudo(method: "POST", path: path, publicKey: pk, secretKey: sk, data: [:]) + + if statusCode == 428 { + return try handleSudoChallenge(responseData: response, publicKey: pk, secretKey: sk, method: "POST", path: path, body: "{}") + } else if statusCode >= 400 { + let errorMsg = response["error"] as? String ?? "Unknown error" + throw UnsandboxError.apiError(statusCode, errorMsg) + } + + return response } /// Set image visibility diff --git a/clients/tcl/sync/src/un.tcl b/clients/tcl/sync/src/un.tcl index a1bea10..2e4837e 100755 --- a/clients/tcl/sync/src/un.tcl +++ b/clients/tcl/sync/src/un.tcl @@ -121,7 +121,98 @@ proc detect_language {filename} { exit 1 } -proc api_request {endpoint method data public_key secret_key} { +proc api_request {endpoint method data public_key secret_key {extra_headers {}}} { + set url "${::API_BASE}${endpoint}" + set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] + + set json_data "" + if {$method ne "GET" && $method ne "DELETE" && [llength $data] > 0} { + set json_data [::json::write object {*}$data] + } + + # Add HMAC signature if secret_key is present + if {$secret_key ne ""} { + set timestamp [clock seconds] + set sig_input "${timestamp}:${method}:${endpoint}:${json_data}" + set signature [::sha2::hmac -hex -key $secret_key $sig_input] + lappend headers X-Timestamp $timestamp + lappend headers X-Signature $signature + } + + # Add extra headers (for sudo OTP) + foreach {k v} $extra_headers { + lappend headers $k $v + } + + if {$method eq "GET"} { + set token [::http::geturl $url -headers $headers -timeout 300000] + } elseif {$method eq "DELETE"} { + set token [::http::geturl $url -method DELETE -headers $headers -timeout 300000] + } else { + set token [::http::geturl $url -method $method -headers $headers -query $json_data -timeout 300000] + } + + set status [::http::status $token] + set ncode [::http::ncode $token] + set body [::http::data $token] + ::http::cleanup $token + + if {$status ne "ok" || ($ncode != 200 && $ncode != 201)} { + if {$ncode == 401 && [string match -nocase "*timestamp*" $body]} { + puts stderr "${::RED}Error: Request timestamp expired (must be within 5 minutes of server time)${::RESET}" + puts stderr "${::YELLOW}Your computer's clock may have drifted.${::RESET}" + puts stderr "Check your system time and sync with NTP if needed:" + puts stderr " Linux: sudo ntpdate -s time.nist.gov" + puts stderr " macOS: sudo sntp -sS time.apple.com" + puts stderr " Windows: w32tm /resync" + } else { + puts stderr "${::RED}Error: HTTP $ncode${::RESET}" + puts stderr $body + } + exit 1 + } + + return [::json::json2dict $body] +} + +# Handle 428 Sudo OTP challenge - prompt user for OTP and retry +proc handle_sudo_challenge {response_body endpoint method data public_key secret_key} { + # Extract challenge_id from response + set challenge_id "" + if {[catch {set response_data [::json::json2dict $response_body]}] == 0} { + if {[dict exists $response_data challenge_id]} { + set challenge_id [dict get $response_data challenge_id] + } + } + + puts stderr "${::YELLOW}Confirmation required. Check your email for a one-time code.${::RESET}" + puts -nonewline stderr "Enter OTP: " + flush stderr + + gets stdin otp + set otp [string trim $otp] + + if {$otp eq ""} { + puts stderr "${::RED}Error: Operation cancelled${::RESET}" + return 0 + } + + # Retry with sudo headers + set extra_headers [list X-Sudo-OTP $otp] + if {$challenge_id ne ""} { + lappend extra_headers X-Sudo-Challenge $challenge_id + } + + if {[catch {api_request $endpoint $method $data $public_key $secret_key $extra_headers} result]} { + return 0 + } + + puts "${::GREEN}Operation completed successfully${::RESET}" + return 1 +} + +# API request with 428 sudo handling for destructive operations +proc api_request_with_sudo {endpoint method data public_key secret_key} { set url "${::API_BASE}${endpoint}" set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] @@ -152,18 +243,14 @@ proc api_request {endpoint method data public_key secret_key} { set body [::http::data $token] ::http::cleanup $token + # Handle 428 Precondition Required (sudo OTP needed) + if {$ncode == 428} { + return [handle_sudo_challenge $body $endpoint $method $data $public_key $secret_key] + } + if {$status ne "ok" || ($ncode != 200 && $ncode != 201)} { - if {$ncode == 401 && [string match -nocase "*timestamp*" $body]} { - puts stderr "${::RED}Error: Request timestamp expired (must be within 5 minutes of server time)${::RESET}" - puts stderr "${::YELLOW}Your computer's clock may have drifted.${::RESET}" - puts stderr "Check your system time and sync with NTP if needed:" - puts stderr " Linux: sudo ntpdate -s time.nist.gov" - puts stderr " macOS: sudo sntp -sS time.apple.com" - puts stderr " Windows: w32tm /resync" - } else { - puts stderr "${::RED}Error: HTTP $ncode${::RESET}" - puts stderr $body - } + puts stderr "${::RED}Error: HTTP $ncode${::RESET}" + puts stderr $body exit 1 } @@ -879,7 +966,7 @@ proc cmd_image {args} { } if {$delete_id ne ""} { - api_request "/images/$delete_id" "DELETE" {} $public_key $secret_key + api_request_with_sudo "/images/$delete_id" "DELETE" {} $public_key $secret_key puts "${::GREEN}Image deleted successfully${::RESET}" return } @@ -891,7 +978,7 @@ proc cmd_image {args} { } if {$unlock_id ne ""} { - api_request "/images/$unlock_id/unlock" "POST" {} $public_key $secret_key + api_request_with_sudo "/images/$unlock_id/unlock" "POST" {} $public_key $secret_key puts "${::GREEN}Image unlocked successfully${::RESET}" return } @@ -1156,7 +1243,7 @@ proc cmd_service {args} { } if {$destroy_id ne ""} { - api_request "/services/$destroy_id" "DELETE" {} $public_key $secret_key + api_request_with_sudo "/services/$destroy_id" "DELETE" {} $public_key $secret_key puts "${::GREEN}Service destroyed: $destroy_id${::RESET}" return } diff --git a/clients/typescript/sync/src/un.ts b/clients/typescript/sync/src/un.ts index 3b200f8..e089c86 100644 --- a/clients/typescript/sync/src/un.ts +++ b/clients/typescript/sync/src/un.ts @@ -252,6 +252,146 @@ function apiRequest(endpoint: string, method: string = "GET", data: any = null, }); } +/** + * Make an authenticated HTTP request with sudo OTP challenge handling. + * If the server returns 428, prompts for OTP and retries with sudo headers. + */ +function apiRequestWithSudo(endpoint: string, method: string = "GET", data: any = null, keys: ApiKeys): Promise { + return new Promise((resolve, reject) => { + const url = new URL(API_BASE + endpoint); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const body = data ? JSON.stringify(data) : ''; + const message = `${timestamp}:${method}:${url.pathname}${url.search}:${body}`; + const signature = crypto.createHmac('sha256', keys.secretKey).update(message).digest('hex'); + + const options: https.RequestOptions = { + hostname: url.hostname, + path: url.pathname + url.search, + method: method, + headers: { + 'Authorization': `Bearer ${keys.publicKey}`, + 'X-Timestamp': timestamp, + 'X-Signature': signature, + 'Content-Type': 'application/json' + }, + timeout: 300000 + }; + + const req = https.request(options, (res) => { + let responseBody = ''; + res.on('data', chunk => responseBody += chunk); + res.on('end', async () => { + // Handle 428 sudo OTP challenge + if (res.statusCode === 428) { + let challengeId = ''; + try { + const challengeData = JSON.parse(responseBody); + challengeId = challengeData.challenge_id || ''; + } catch (e) { + // Ignore JSON parse errors + } + + console.error(`${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}`); + + const readline = require('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr + }); + + rl.question('Enter OTP: ', (otp: string) => { + rl.close(); + otp = otp.trim(); + + if (!otp) { + console.error(`${RED}Error: Operation cancelled${RESET}`); + process.exit(1); + } + + // Retry with sudo headers + const retryTimestamp = Math.floor(Date.now() / 1000).toString(); + const retryMessage = `${retryTimestamp}:${method}:${url.pathname}${url.search}:${body}`; + const retrySignature = crypto.createHmac('sha256', keys.secretKey).update(retryMessage).digest('hex'); + + const retryOptions: https.RequestOptions = { + hostname: url.hostname, + path: url.pathname + url.search, + method: method, + headers: { + 'Authorization': `Bearer ${keys.publicKey}`, + 'X-Timestamp': retryTimestamp, + 'X-Signature': retrySignature, + 'Content-Type': 'application/json', + 'X-Sudo-OTP': otp, + 'X-Sudo-Challenge': challengeId + }, + timeout: 300000 + }; + + const retryReq = https.request(retryOptions, (retryRes) => { + let retryBody = ''; + retryRes.on('data', chunk => retryBody += chunk); + retryRes.on('end', () => { + if (retryRes.statusCode && retryRes.statusCode >= 200 && retryRes.statusCode < 300) { + try { + resolve(JSON.parse(retryBody)); + } catch (e) { + resolve(retryBody); + } + } else { + console.error(`${RED}Error: HTTP ${retryRes.statusCode} - ${retryBody}${RESET}`); + process.exit(1); + } + }); + }); + + retryReq.on('error', (e) => { + console.error(`${RED}Error: ${e.message}${RESET}`); + process.exit(1); + }); + + if (data) { + retryReq.write(body); + } + retryReq.end(); + }); + return; + } + + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(responseBody)); + } catch (e) { + resolve(responseBody); + } + } else { + if (res.statusCode === 401 && responseBody.toLowerCase().includes('timestamp')) { + console.error(`${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}`); + console.error(`${YELLOW}Your computer's clock may have drifted.${RESET}`); + console.error("Check your system time and sync with NTP if needed:"); + console.error(" Linux: sudo ntpdate -s time.nist.gov"); + console.error(" macOS: sudo sntp -sS time.apple.com"); + console.error(" Windows: w32tm /resync"); + } else { + console.error(`${RED}Error: HTTP ${res.statusCode} - ${responseBody}${RESET}`); + } + process.exit(1); + } + }); + }); + + req.on('error', (e) => { + console.error(`${RED}Error: ${e.message}${RESET}`); + process.exit(1); + }); + + if (data) { + req.write(body); + } + req.end(); + }); +} + function portalRequest(endpoint: string, method: string = "GET", data: any = null, keys: ApiKeys): Promise { return new Promise((resolve, reject) => { const url = new URL(PORTAL_BASE + endpoint); @@ -675,7 +815,7 @@ async function cmdService(args: Args): Promise { } if (args.destroy) { - await apiRequest(`/services/${args.destroy}`, "DELETE", null, keys); + await apiRequestWithSudo(`/services/${args.destroy}`, "DELETE", null, keys); console.log(`${GREEN}Service destroyed: ${args.destroy}${RESET}`); return; } @@ -912,7 +1052,7 @@ async function cmdImage(args: Args): Promise { } if (args.imageDelete) { - await apiRequest(`/images/${args.imageDelete}`, "DELETE", null, keys); + await apiRequestWithSudo(`/images/${args.imageDelete}`, "DELETE", null, keys); console.log(`${GREEN}Image deleted successfully${RESET}`); return; } @@ -924,7 +1064,7 @@ async function cmdImage(args: Args): Promise { } if (args.imageUnlock) { - await apiRequest(`/images/${args.imageUnlock}/unlock`, "POST", {}, keys); + await apiRequestWithSudo(`/images/${args.imageUnlock}/unlock`, "POST", {}, keys); console.log(`${GREEN}Image unlocked successfully${RESET}`); return; } diff --git a/clients/v/sync/src/un.v b/clients/v/sync/src/un.v index 9102c8c..2c7080e 100644 --- a/clients/v/sync/src/un.v +++ b/clients/v/sync/src/un.v @@ -158,6 +158,100 @@ fn extract_json_string(json string, key string) string { return '' } +fn handle_sudo_challenge(response_data string, meth string, endpoint string, body string, public_key string, secret_key string) bool { + challenge_id := extract_json_string(response_data, 'challenge_id') + + eprintln('${yellow}Confirmation required. Check your email for a one-time code.${reset}') + eprint('Enter OTP: ') + + // Read OTP from stdin + mut otp := '' + mut buf := []u8{len: 64} + n := C.read(0, buf.data, buf.len) + if n > 0 { + otp = buf[..n].bytestr().trim_space() + } + + if otp.len == 0 { + eprintln('${red}Error: Operation cancelled${reset}') + return false + } + + // Build sudo headers + mut otp_header := "-H 'X-Sudo-OTP: ${otp}'" + mut challenge_header := '' + if challenge_id != '' { + challenge_header = " -H 'X-Sudo-Challenge: ${challenge_id}'" + } + + // Retry with sudo headers + mut cmd := '' + if meth == 'DELETE' { + cmd = "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:DELETE:${endpoint}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -o /dev/null -w '%{http_code}' -X DELETE '${api_base}${endpoint}' -H 'Authorization: Bearer ${public_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" ${otp_header}${challenge_header}" + } else if meth == 'POST' { + cmd = "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:${endpoint}:${body}\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -o /dev/null -w '%{http_code}' -X POST '${api_base}${endpoint}' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${public_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" ${otp_header}${challenge_header} -d '${body}'" + } else { + return false + } + + result := os.execute(cmd) + status := result.output.trim_space().int() + if status >= 200 && status < 300 { + println('${green}Operation completed successfully${reset}') + return true + } + eprintln('${red}Error: HTTP ${status}${reset}') + return false +} + +fn exec_curl_delete_with_sudo(endpoint string, public_key string, secret_key string) int { + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:DELETE:${endpoint}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -w '\\n%{http_code}' -X DELETE '${api_base}${endpoint}' -H 'Authorization: Bearer ${public_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + result := os.execute(cmd) + output := result.output.trim_space() + + // Parse response body and status code + lines := output.split('\n') + if lines.len < 1 { + return 500 + } + + status_line := lines[lines.len - 1] + response_body := if lines.len > 1 { lines[..lines.len - 1].join('\n') } else { '' } + + status := status_line.int() + if status == 428 { + if handle_sudo_challenge(response_body, 'DELETE', endpoint, '', public_key, secret_key) { + return 200 + } + return 428 + } + return status +} + +fn exec_curl_post_with_sudo(endpoint string, body string, public_key string, secret_key string) int { + cmd := "BODY='${body}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:${endpoint}:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -w '\\n%{http_code}' -X POST '${api_base}${endpoint}' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${public_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + result := os.execute(cmd) + output := result.output.trim_space() + + // Parse response body and status code + lines := output.split('\n') + if lines.len < 1 { + return 500 + } + + status_line := lines[lines.len - 1] + response_body := if lines.len > 1 { lines[..lines.len - 1].join('\n') } else { '' } + + status := status_line.int() + if status == 428 { + if handle_sudo_challenge(response_body, 'POST', endpoint, body, public_key, secret_key) { + return 200 + } + return 428 + } + return status +} + fn read_env_file(filename string) string { content := os.read_file(filename) or { eprintln('${red}Error: Cannot read env file: ${filename}${reset}') @@ -454,9 +548,14 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, } if destroy != '' { - cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:DELETE:/services/${destroy}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X DELETE '${api_base}/services/${destroy}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" - exec_curl(cmd) - println('${green}Service destroyed: ${destroy}${reset}') + endpoint := '/services/${destroy}' + status := exec_curl_delete_with_sudo(endpoint, pub_key, secret_key) + if status >= 200 && status < 300 { + println('${green}Service destroyed: ${destroy}${reset}') + } else if status != 428 { + eprintln('${red}Error: Failed to destroy service${reset}') + exit(1) + } return } @@ -630,9 +729,14 @@ fn cmd_image(list bool, info string, delete string, lock string, unlock string, } if delete != '' { - cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:DELETE:/images/${delete}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X DELETE '${api_base}/images/${delete}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" - exec_curl(cmd) - println('${green}Image deleted: ${delete}${reset}') + endpoint := '/images/${delete}' + status := exec_curl_delete_with_sudo(endpoint, pub_key, secret_key) + if status >= 200 && status < 300 { + println('${green}Image deleted: ${delete}${reset}') + } else if status != 428 { + eprintln('${red}Error: Failed to delete image${reset}') + exit(1) + } return } @@ -644,9 +748,14 @@ fn cmd_image(list bool, info string, delete string, lock string, unlock string, } if unlock != '' { - cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/images/${unlock}/unlock:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/images/${unlock}/unlock' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" - exec_curl(cmd) - println('${green}Image unlocked: ${unlock}${reset}') + endpoint := '/images/${unlock}/unlock' + status := exec_curl_post_with_sudo(endpoint, '{}', pub_key, secret_key) + if status >= 200 && status < 300 { + println('${green}Image unlocked: ${unlock}${reset}') + } else if status != 428 { + eprintln('${red}Error: Failed to unlock image${reset}') + exit(1) + } return } diff --git a/clients/zig/sync/src/un.zig b/clients/zig/sync/src/un.zig index 1a38441..f366ad9 100644 --- a/clients/zig/sync/src/un.zig +++ b/clients/zig/sync/src/un.zig @@ -191,6 +191,113 @@ fn extractJsonField(json: []const u8, field: []const u8) ?[]const u8 { return null; } +const CurlResult = struct { + body: []const u8, + status: i32, +}; + +fn execCurlWithStatus(allocator: std.mem.Allocator, method: []const u8, endpoint: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8, extra_headers: []const u8) !CurlResult { + const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ API_BASE, endpoint }); + defer allocator.free(url); + + const auth_headers = try buildAuthCmd(allocator, method, endpoint, body, public_key, secret_key); + defer allocator.free(auth_headers); + + const response_file = "/tmp/unsandbox_curl_response.txt"; + const status_file = "/tmp/unsandbox_curl_status.txt"; + + // Build curl command with status code output + const cmd = if (body.len > 0) blk: { + const body_file = "/tmp/unsandbox_curl_body.txt"; + const file = try fs.cwd().createFile(body_file, .{}); + try file.writeAll(body); + file.close(); + + break :blk try std.fmt.allocPrint(allocator, "curl -s -X {s} '{s}' -H 'Content-Type: application/json' {s} {s} --data-binary @{s} -o {s} -w '%{{http_code}}' > {s}", .{ method, url, auth_headers, extra_headers, body_file, response_file, status_file }); + } else blk: { + break :blk try std.fmt.allocPrint(allocator, "curl -s -X {s} '{s}' {s} {s} -o {s} -w '%{{http_code}}' > {s}", .{ method, url, auth_headers, extra_headers, response_file, status_file }); + }; + defer allocator.free(cmd); + + _ = std.c.system(cmd.ptr); + + // Read response body + const response_body = fs.cwd().readFileAlloc(allocator, response_file, 1024 * 1024) catch try allocator.dupe(u8, ""); + fs.cwd().deleteFile(response_file) catch {}; + + // Read status code + const status_str = fs.cwd().readFileAlloc(allocator, status_file, 16) catch try allocator.dupe(u8, "0"); + defer allocator.free(status_str); + fs.cwd().deleteFile(status_file) catch {}; + + const trimmed_status = mem.trim(u8, status_str, &std.ascii.whitespace); + const status = std.fmt.parseInt(i32, trimmed_status, 10) catch 0; + + return CurlResult{ + .body = response_body, + .status = status, + }; +} + +fn handleSudoChallenge(allocator: std.mem.Allocator, method: []const u8, endpoint: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8, challenge_response: []const u8) !bool { + // Extract challenge_id from response + const challenge_id = extractJsonField(challenge_response, "challenge_id") orelse { + std.debug.print("{s}Error: No challenge_id in 428 response{s}\n", .{ RED, RESET }); + return false; + }; + + // Prompt for OTP + std.debug.print("{s}Confirmation required. Check your email for a one-time code.{s}\n", .{ YELLOW, RESET }); + std.debug.print("Enter OTP: ", .{}); + + // Read OTP from stdin + const stdin = std.io.getStdIn().reader(); + var otp_buf: [64]u8 = undefined; + const otp_line = stdin.readUntilDelimiterOrEof(&otp_buf, '\n') catch null; + if (otp_line == null or otp_line.?.len == 0) { + std.debug.print("{s}Error: No OTP provided{s}\n", .{ RED, RESET }); + return false; + } + const otp = mem.trim(u8, otp_line.?, &std.ascii.whitespace); + + // Build sudo headers + const sudo_headers = try std.fmt.allocPrint(allocator, "-H 'X-Sudo-OTP: {s}' -H 'X-Sudo-Challenge: {s}'", .{ otp, challenge_id }); + defer allocator.free(sudo_headers); + + // Retry with sudo headers + const result = try execCurlWithStatus(allocator, method, endpoint, body, public_key, secret_key, sudo_headers); + defer allocator.free(result.body); + + if (result.status >= 200 and result.status < 300) { + return true; + } else { + std.debug.print("{s}", .{result.body}); + return false; + } +} + +fn execDestructiveCurl(allocator: std.mem.Allocator, method: []const u8, endpoint: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8, success_msg: []const u8) !bool { + const result = try execCurlWithStatus(allocator, method, endpoint, body, public_key, secret_key, ""); + + if (result.status == 428) { + // Handle sudo challenge + const success = try handleSudoChallenge(allocator, method, endpoint, body, public_key, secret_key, result.body); + allocator.free(result.body); + if (success) { + std.debug.print("{s}{s}{s}\n", .{ GREEN, success_msg, RESET }); + } + return success; + } else if (result.status >= 200 and result.status < 300) { + allocator.free(result.body); + std.debug.print("{s}{s}{s}\n", .{ GREEN, success_msg, RESET }); + return true; + } else { + std.debug.print("{s}\n", .{result.body}); + allocator.free(result.body); + return false; + } +} + fn execCurlPut(allocator: std.mem.Allocator, endpoint: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8) !bool { const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ API_BASE, endpoint }); defer allocator.free(url); @@ -978,12 +1085,9 @@ pub fn main() !u8 { } else if (delete) |del_id| { const path = try std.fmt.allocPrint(allocator, "/images/{s}", .{del_id}); defer allocator.free(path); - const auth_headers = try buildAuthCmd(allocator, "DELETE", path, "", public_key, secret_key); - defer allocator.free(auth_headers); - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X DELETE '{s}/images/{s}' {s}", .{ API_BASE, del_id, auth_headers }); - defer allocator.free(cmd); - _ = std.c.system(cmd.ptr); - std.debug.print("\n{s}Image deleted: {s}{s}\n", .{ GREEN, del_id, RESET }); + const success_msg = try std.fmt.allocPrint(allocator, "Image deleted: {s}", .{del_id}); + defer allocator.free(success_msg); + _ = try execDestructiveCurl(allocator, "DELETE", path, "", public_key, secret_key, success_msg); } else if (lock) |lock_id| { const path = try std.fmt.allocPrint(allocator, "/images/{s}/lock", .{lock_id}); defer allocator.free(path); @@ -996,12 +1100,9 @@ pub fn main() !u8 { } else if (unlock) |unlock_id| { const path = try std.fmt.allocPrint(allocator, "/images/{s}/unlock", .{unlock_id}); defer allocator.free(path); - const auth_headers = try buildAuthCmd(allocator, "POST", path, "", public_key, secret_key); - defer allocator.free(auth_headers); - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/images/{s}/unlock' {s}", .{ API_BASE, unlock_id, auth_headers }); - defer allocator.free(cmd); - _ = std.c.system(cmd.ptr); - std.debug.print("\n{s}Image unlocked: {s}{s}\n", .{ GREEN, unlock_id, RESET }); + const success_msg = try std.fmt.allocPrint(allocator, "Image unlocked: {s}", .{unlock_id}); + defer allocator.free(success_msg); + _ = try execDestructiveCurl(allocator, "POST", path, "", public_key, secret_key, success_msg); } else if (publish) |pub_id| { if (source_type == null) { std.debug.print("{s}Error: --publish requires --source-type (service or snapshot){s}\n", .{ RED, RESET });