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

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