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:
parent
1b06666f11
commit
a5155aed4f
42 changed files with 5163 additions and 321 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<>());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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, {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 != "":
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue