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

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