feat: add HTTP 428 sudo OTP handling to all 42 SDK implementations

When destructive operations (service/snapshot/image destroy/unlock) receive
HTTP 428 Precondition Required, the SDK now:
1. Extracts challenge_id from the JSON response
2. Prompts: 'Confirmation required. Check your email for a one-time code.'
3. Reads OTP from stdin
4. Retries with X-Sudo-OTP and X-Sudo-Challenge headers

Languages updated: AWK, Bash, C++, C#, Clojure, COBOL, Crystal, D, Dart,
.NET, Elixir, Erlang, F#, Forth, Fortran, Go, Groovy, Haskell, Java,
JavaScript, Julia, Kotlin, Lisp, Lua, Nim, Objective-C, OCaml, Perl, PHP,
PowerShell, Prolog, Python, R, Raku, Ruby, Rust, Scheme, Swift, Tcl,
TypeScript, V, Zig
This commit is contained in:
russell@unturf.com 2026-02-05 16:23:19 -05:00
parent 1b06666f11
commit a5155aed4f
42 changed files with 5163 additions and 321 deletions

View file

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