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
This commit is contained in:
russell@unturf.com 2026-02-05 16:23:19 -05:00
parent 1b06666f11
commit a5155aed4f
42 changed files with 5163 additions and 321 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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)

View file

@ -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.

View file

@ -161,6 +161,163 @@ string exec_curl(const string& cmd) {
return result;
}
// Execute curl and get HTTP status code
pair<string, int> 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;
}

View file

@ -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

View file

@ -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<string, object> ApiRequest(string endpoint, string method, Dictionary<string, object> data, string publicKey, string secretKey)
static Dictionary<string, object> ApiRequest(string endpoint, string method, Dictionary<string, object> 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<string, object> HandleSudoChallenge(string responseBody, string endpoint, string method, Dictionary<string, object> 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<string, object> ApiRequestWithSudo(string endpoint, string method, Dictionary<string, object> 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);
}
}

View file

@ -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;
}

View file

@ -281,6 +281,114 @@ Future<Map<String, dynamic>> 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<bool> 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<String, dynamic>;
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<Map<String, dynamic>?> 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<void> 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<void> 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<void> 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;
}

View file

@ -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<string, object> ApiRequest(string endpoint, HttpMethod method, Dictionary<string, object>? 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<string, object> ApiRequest(string endpoint, HttpMethod method, Dictionary<string, object>? data, string publicKey, string secretKey, string? sudoOtp = null, string? sudoChallengeId = null)
{
var body = data != null ? JsonSerializer.Serialize(data, jsonOptions) : "";
@ -754,6 +766,12 @@ Dictionary<string, object> 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<string, object> 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<string, object>();
@ -783,6 +801,41 @@ Dictionary<string, object> ApiRequest(string endpoint, HttpMethod method, Dictio
}
}
// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request
Dictionary<string, object> HandleSudoChallenge(string responseBody, string endpoint, HttpMethod method, Dictionary<string, object>? 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<string, object> ApiRequestWithSudo(string endpoint, HttpMethod method, Dictionary<string, object>? 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; }

View file

@ -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())

View file

@ -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),

View file

@ -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
;

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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
}

View file

@ -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

View file

@ -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<String, Object> 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<String, Object> makeRequestWithSudo(
String method,
String path,
String publicKey,
String secretKey,
Map<String, Object> 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<String, Object> handleSudoChallenge(
String challengeId,
String method,
String path,
String publicKey,
String secretKey,
Map<String, Object> 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<String, Object> makeDestructiveRequest(
String method,
String path,
String publicKey,
String secretKey,
Map<String, Object> 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<>());
}
/**

View file

@ -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, {});
}
/**

View file

@ -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

View file

@ -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<String, Any>?, publicKey: String?, secretKey: String): Map<String, Any> {
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<String, Any>?, 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<String, Any>?, public
return parseJson(response)
}
fun apiRequestWithSudo(endpoint: String, method: String, data: Map<String, Any>?, publicKey: String?, secretKey: String, otp: String, challengeId: String?): Map<String, Any> {
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<String, Any>?, publicKey: String?, secretKey: String): Map<String, Any> {
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<String, Any>?, publicKey: String?, secretKey: String): Map<String, Any> {
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<String, Any>, publicKey: String?, secretKey: String): Map<String, Any> {
val timestamp = System.currentTimeMillis() / 1000
val body = toJson(data)

View file

@ -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

View file

@ -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)

View file

@ -273,6 +273,106 @@ proc extractJsonField(response, field: string): string =
return response[start..<endPos]
return ""
proc execCurlWithStatus(cmd: string): (int, string) =
# Execute curl and capture HTTP status code and response body
let result = execProcess(cmd)
return (0, result) # For non-status commands, we parse response
proc handleSudoChallenge(responseData, meth, endpoint, body, publicKey, secretKey: string): bool =
# Extract challenge_id from response
let challengeId = extractJsonField(responseData, "challenge_id")
stderr.writeLine(YELLOW & "Confirmation required. Check your email for a one-time code." & RESET)
stderr.write("Enter OTP: ")
var otp = ""
try:
otp = stdin.readLine().strip()
except:
stderr.writeLine(RED & "Error: Failed to read OTP" & RESET)
return false
if otp.len == 0:
stderr.writeLine(RED & "Error: Operation cancelled" & RESET)
return false
# Build retry command with sudo headers
var otpHeader = fmt"-H 'X-Sudo-OTP: {otp}'"
var challengeHeader = ""
if challengeId != "":
challengeHeader = fmt" -H 'X-Sudo-Challenge: {challengeId}'"
let authHeaders = buildAuthHeaders(meth, endpoint, body, publicKey, secretKey)
var cmd: string
if meth == "DELETE":
cmd = fmt"""curl -s -o /dev/null -w '%{{http_code}}' -X DELETE '{API_BASE}{endpoint}' {authHeaders} {otpHeader}{challengeHeader}"""
elif meth == "POST":
let contentType = if body != "": "-H 'Content-Type: application/json'" else: ""
cmd = fmt"""curl -s -o /dev/null -w '%{{http_code}}' -X POST '{API_BASE}{endpoint}' {contentType} {authHeaders} {otpHeader}{challengeHeader} -d '{body}'"""
else:
return false
let output = execProcess(cmd).strip()
try:
let status = parseInt(output)
if status >= 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 != "":

View file

@ -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;
}

View file

@ -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

View file

@ -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 = <STDIN>;
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;
}

View file

@ -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).
*

View file

@ -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
}

View file

@ -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

View file

@ -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(

View file

@ -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()
}

View file

@ -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<challenge_id> // '';
}
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;
}

View file

@ -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)

View file

@ -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<T: for<'de> Deserialize<'de>>(
Ok(result)
}
/// Make an authenticated HTTP request that may require sudo OTP confirmation
fn make_destructive_request<T: for<'de> Deserialize<'de>>(
method: &str,
path: &str,
creds: &Credentials,
body: Option<&impl Serialize>,
) -> Result<T> {
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<T: for<'de> Deserialize<'de>>(
method: &str,
path: &str,
creds: &Credentials,
body: Option<&impl Serialize>,
response_text: &str,
) -> Result<T> {
// Extract challenge_id from response
#[derive(Deserialize)]
struct ChallengeResponse {
challenge_id: Option<String>,
}
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<String>,
}
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&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<Restor
/// Delete a snapshot.
///
/// This operation may require sudo OTP confirmation (428 response handling).
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to delete
/// * `creds` - API credentials
pub fn delete_snapshot(snapshot_id: &str, creds: &Credentials) -> 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<Snapshot>
/// 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<Snapshot>
pub fn unlock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
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<LxdImage> {
/// 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<LxdImage> {
/// 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<LxdImage> {
pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
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<Service> {
/// 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<Service> {
pub fn unlock_service(service_id: &str, creds: &Credentials) -> Result<Service> {
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.

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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<any> {
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<any> {
return new Promise((resolve, reject) => {
const url = new URL(PORTAL_BASE + endpoint);
@ -675,7 +815,7 @@ async function cmdService(args: Args): Promise<void> {
}
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<void> {
}
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<void> {
}
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;
}

View file

@ -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
}

View file

@ -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 });