498 lines
16 KiB
Lua
498 lines
16 KiB
Lua
#!/usr/bin/env lua
|
|
-- PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
|
--
|
|
-- This is free public domain software for the public good of a permacomputer hosted
|
|
-- at permacomputer.com - an always-on computer by the people, for the people.
|
|
--
|
|
-- Learn more: https://www.permacomputer.com
|
|
--
|
|
-- Copyright 2025 TimeHexOn & foxhop & russell@unturf
|
|
|
|
local json = require("json")
|
|
local http = require("socket.http")
|
|
local https = require("ssl.https")
|
|
local ltn12 = require("ltn12")
|
|
|
|
local Un = {}
|
|
Un.API_BASE = "https://api.unsandbox.com"
|
|
Un.VERSION = "4.2.29"
|
|
|
|
-- Credential loading
|
|
function Un.load_accounts_csv(path)
|
|
path = path or (os.getenv("HOME") .. "/.unsandbox/accounts.csv")
|
|
local file = io.open(path, "r")
|
|
if not file then return {} end
|
|
|
|
local accounts = {}
|
|
for line in file:lines() do
|
|
line = line:match("^%s*(.-)%s*$")
|
|
if line ~= "" then
|
|
local pk, sk = line:match("([^,]+),(.+)")
|
|
if pk and sk then
|
|
table.insert(accounts, {pk, sk})
|
|
end
|
|
end
|
|
end
|
|
file:close()
|
|
return accounts
|
|
end
|
|
|
|
function Un.get_credentials(opts)
|
|
opts = opts or {}
|
|
|
|
-- Tier 1: Arguments
|
|
if opts.public_key and opts.secret_key then
|
|
return opts.public_key, opts.secret_key
|
|
end
|
|
|
|
-- Tier 2: Environment
|
|
local pk = os.getenv("UNSANDBOX_PUBLIC_KEY")
|
|
local sk = os.getenv("UNSANDBOX_SECRET_KEY")
|
|
if pk and sk then return pk, sk end
|
|
|
|
-- Tier 3: Home directory
|
|
local accounts = Un.load_accounts_csv()
|
|
if #accounts > 0 then return accounts[1][1], accounts[1][2] end
|
|
|
|
-- Tier 4: Local directory
|
|
accounts = Un.load_accounts_csv("./accounts.csv")
|
|
if #accounts > 0 then return accounts[1][1], accounts[1][2] end
|
|
|
|
error("No credentials found")
|
|
end
|
|
|
|
-- HMAC signature
|
|
function Un.sign_request(secret, timestamp, method, endpoint, body)
|
|
local hmac = require("crypto").hmac
|
|
local message = timestamp .. ":" .. method .. ":" .. endpoint .. ":" .. body
|
|
return hmac.digest("sha256", message, secret, true):hex()
|
|
end
|
|
|
|
-- API request
|
|
function Un.api_request(method, endpoint, body, opts)
|
|
opts = opts 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"
|
|
}
|
|
|
|
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))
|
|
end
|
|
|
|
-- Languages with cache
|
|
function Un.languages(opts)
|
|
opts = opts or {}
|
|
local cache_ttl = opts.cache_ttl or 3600
|
|
local cache_path = os.getenv("HOME") .. "/.unsandbox/languages.json"
|
|
|
|
local file = io.open(cache_path, "r")
|
|
if file then
|
|
local mtime = os.time() - (lfs.attributes(cache_path, "modification") or 0)
|
|
if mtime < cache_ttl then
|
|
local content = file:read("*a")
|
|
file:close()
|
|
return json.decode(content)
|
|
end
|
|
file:close()
|
|
end
|
|
|
|
local result = Un.api_request("GET", "/languages", nil, opts)
|
|
local langs = result.languages or {}
|
|
|
|
os.execute("mkdir -p " .. os.getenv("HOME") .. "/.unsandbox")
|
|
file = io.open(cache_path, "w")
|
|
file:write(json.encode(langs))
|
|
file:close()
|
|
|
|
return langs
|
|
end
|
|
|
|
-- Execute functions
|
|
function Un.execute(language, code, opts)
|
|
opts = opts or {}
|
|
local body = {
|
|
language = language,
|
|
code = code,
|
|
network_mode = opts.network_mode or "zerotrust",
|
|
ttl = opts.ttl or 60
|
|
}
|
|
return Un.api_request("POST", "/execute", body, opts)
|
|
end
|
|
|
|
function Un.execute_async(language, code, opts)
|
|
opts = opts or {}
|
|
local body = {
|
|
language = language,
|
|
code = code,
|
|
network_mode = opts.network_mode or "zerotrust",
|
|
ttl = opts.ttl or 300
|
|
}
|
|
return Un.api_request("POST", "/execute/async", body, opts)
|
|
end
|
|
|
|
function Un.run(file, opts)
|
|
local f = io.open(file, "r")
|
|
local code = f:read("*a")
|
|
f:close()
|
|
return Un.execute(Un.detect_language(file), code, opts)
|
|
end
|
|
|
|
-- Job management
|
|
function Un.get_job(job_id, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("GET", "/jobs/" .. job_id, nil, opts)
|
|
end
|
|
|
|
function Un.wait(job_id, timeout, opts)
|
|
opts = opts or {}
|
|
timeout = timeout or 3600
|
|
local delays = {300, 450, 700, 900, 650, 1600, 2000}
|
|
|
|
local start = os.time()
|
|
for i = 0, 119 do
|
|
local job = Un.get_job(job_id, opts)
|
|
if job.status == "completed" then return job end
|
|
if job.status == "failed" then error("Job failed") end
|
|
|
|
if os.time() - start > timeout then error("Polling timeout") end
|
|
|
|
local delay = delays[(i % 7) + 1] or 2000
|
|
require("socket").sleep(delay / 1000)
|
|
end
|
|
|
|
error("Max polls exceeded")
|
|
end
|
|
|
|
-- Utilities
|
|
function Un.detect_language(filename)
|
|
local ext = filename:match("%.([^%.]+)$")
|
|
local map = {py="python", lua="lua", sh="bash", rb="ruby"}
|
|
return map[ext] or error("Unknown file type")
|
|
end
|
|
|
|
-- Image API functions
|
|
function Un.image_list(opts)
|
|
opts = opts or {}
|
|
return Un.api_request("GET", "/images", nil, opts)
|
|
end
|
|
|
|
function Un.image_get(image_id, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("GET", "/images/" .. image_id, nil, opts)
|
|
end
|
|
|
|
function Un.image_delete(image_id, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("DELETE", "/images/" .. image_id, nil, opts)
|
|
end
|
|
|
|
function Un.image_lock(image_id, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("POST", "/images/" .. image_id .. "/lock", {}, opts)
|
|
end
|
|
|
|
function Un.image_unlock(image_id, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("POST", "/images/" .. image_id .. "/unlock", {}, opts)
|
|
end
|
|
|
|
function Un.image_publish(source_id, source_type, name, opts)
|
|
opts = opts or {}
|
|
local body = {source_type = source_type, source_id = source_id}
|
|
if name then body.name = name end
|
|
return Un.api_request("POST", "/images/publish", body, opts)
|
|
end
|
|
|
|
function Un.image_visibility(image_id, visibility, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("POST", "/images/" .. image_id .. "/visibility", {visibility = visibility}, opts)
|
|
end
|
|
|
|
function Un.image_spawn(image_id, name, ports, opts)
|
|
opts = opts or {}
|
|
local body = {}
|
|
if name then body.name = name end
|
|
if ports then body.ports = ports end
|
|
return Un.api_request("POST", "/images/" .. image_id .. "/spawn", body, opts)
|
|
end
|
|
|
|
function Un.image_clone(image_id, name, opts)
|
|
opts = opts or {}
|
|
local body = {}
|
|
if name then body.name = name end
|
|
return Un.api_request("POST", "/images/" .. image_id .. "/clone", body, opts)
|
|
end
|
|
|
|
-- Service API functions
|
|
function Un.service_list(opts)
|
|
opts = opts or {}
|
|
return Un.api_request("GET", "/services", nil, opts)
|
|
end
|
|
|
|
function Un.service_get(service_id, opts)
|
|
opts = opts or {}
|
|
return Un.api_request("GET", "/services/" .. service_id, nil, opts)
|
|
end
|
|
|
|
function Un.service_set_unfreeze_on_demand(service_id, enabled, opts)
|
|
opts = opts or {}
|
|
return Un.api_request_patch("/services/" .. service_id, {unfreeze_on_demand = enabled}, opts)
|
|
end
|
|
|
|
function Un.api_request_patch(endpoint, body, opts)
|
|
opts = opts 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, "PATCH", endpoint, body_str)
|
|
|
|
local headers = {
|
|
["Authorization"] = "Bearer " .. pk,
|
|
["X-Timestamp"] = timestamp,
|
|
["X-Signature"] = signature,
|
|
["Content-Type"] = "application/json"
|
|
}
|
|
|
|
local resp_body = {}
|
|
local resp, status = https.request({
|
|
url = url,
|
|
method = "PATCH",
|
|
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))
|
|
end
|
|
|
|
-- CLI
|
|
if arg and arg[1] then
|
|
if arg[1] == "languages" then
|
|
-- Languages command
|
|
local json_output = arg[2] == "--json"
|
|
local langs = Un.languages()
|
|
|
|
if json_output then
|
|
print(json.encode(langs))
|
|
else
|
|
for _, lang in ipairs(langs) do
|
|
print(lang)
|
|
end
|
|
end
|
|
os.exit(0)
|
|
elseif arg[1] == "service" then
|
|
-- Service command
|
|
local i = 2
|
|
local action = nil
|
|
local service_id = nil
|
|
local unfreeze_on_demand_value = nil
|
|
|
|
while i <= #arg do
|
|
if arg[i] == "--list" or arg[i] == "-l" then
|
|
action = "list"
|
|
elseif arg[i] == "--info" then
|
|
action = "info"
|
|
i = i + 1
|
|
service_id = arg[i]
|
|
elseif arg[i] == "--unfreeze-on-demand" then
|
|
action = "unfreeze-on-demand"
|
|
i = i + 1
|
|
service_id = arg[i]
|
|
if i + 1 <= #arg and (arg[i + 1] == "true" or arg[i + 1] == "false") then
|
|
i = i + 1
|
|
unfreeze_on_demand_value = arg[i] == "true"
|
|
end
|
|
end
|
|
i = i + 1
|
|
end
|
|
|
|
if action == "list" then
|
|
local result = Un.service_list()
|
|
print(json.encode(result))
|
|
elseif action == "info" then
|
|
local result = Un.service_get(service_id)
|
|
print(json.encode(result))
|
|
elseif action == "unfreeze-on-demand" then
|
|
if unfreeze_on_demand_value == nil then
|
|
io.stderr:write("Error: --unfreeze-on-demand requires true or false\n")
|
|
os.exit(1)
|
|
end
|
|
Un.service_set_unfreeze_on_demand(service_id, unfreeze_on_demand_value)
|
|
print("Service unfreeze_on_demand set to " .. tostring(unfreeze_on_demand_value) .. ": " .. service_id)
|
|
else
|
|
io.stderr:write("Error: Use --list, --info ID, or --unfreeze-on-demand ID true|false\n")
|
|
os.exit(1)
|
|
end
|
|
os.exit(0)
|
|
elseif arg[1] == "image" then
|
|
-- Image command
|
|
local i = 2
|
|
local action = nil
|
|
local image_id = nil
|
|
local name = nil
|
|
local ports = nil
|
|
local source_type = nil
|
|
local visibility_mode = nil
|
|
|
|
while i <= #arg do
|
|
if arg[i] == "--list" or arg[i] == "-l" then
|
|
action = "list"
|
|
elseif arg[i] == "--info" then
|
|
action = "info"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--delete" then
|
|
action = "delete"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--lock" then
|
|
action = "lock"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--unlock" then
|
|
action = "unlock"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--publish" then
|
|
action = "publish"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--source-type" then
|
|
i = i + 1
|
|
source_type = arg[i]
|
|
elseif arg[i] == "--visibility" then
|
|
action = "visibility"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
if i + 1 <= #arg and not arg[i + 1]:match("^%-") then
|
|
i = i + 1
|
|
visibility_mode = arg[i]
|
|
end
|
|
elseif arg[i] == "--spawn" then
|
|
action = "spawn"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--clone" then
|
|
action = "clone"
|
|
i = i + 1
|
|
image_id = arg[i]
|
|
elseif arg[i] == "--name" then
|
|
i = i + 1
|
|
name = arg[i]
|
|
elseif arg[i] == "--ports" then
|
|
i = i + 1
|
|
ports = {}
|
|
for p in arg[i]:gmatch("[^,]+") do
|
|
table.insert(ports, tonumber(p))
|
|
end
|
|
end
|
|
i = i + 1
|
|
end
|
|
|
|
if action == "list" then
|
|
local result = Un.image_list()
|
|
print(json.encode(result))
|
|
elseif action == "info" then
|
|
local result = Un.image_get(image_id)
|
|
print(json.encode(result))
|
|
elseif action == "delete" then
|
|
Un.image_delete(image_id)
|
|
print("Image deleted: " .. image_id)
|
|
elseif action == "lock" then
|
|
Un.image_lock(image_id)
|
|
print("Image locked: " .. image_id)
|
|
elseif action == "unlock" then
|
|
Un.image_unlock(image_id)
|
|
print("Image unlocked: " .. image_id)
|
|
elseif action == "publish" then
|
|
if not source_type then
|
|
io.stderr:write("Error: --source-type required (service or snapshot)\n")
|
|
os.exit(1)
|
|
end
|
|
local result = Un.image_publish(image_id, source_type, name)
|
|
print("Image published")
|
|
print(json.encode(result))
|
|
elseif action == "visibility" then
|
|
if not visibility_mode then
|
|
io.stderr:write("Error: --visibility requires MODE (private, unlisted, or public)\n")
|
|
os.exit(1)
|
|
end
|
|
Un.image_visibility(image_id, visibility_mode)
|
|
print("Image visibility set to " .. visibility_mode .. ": " .. image_id)
|
|
elseif action == "spawn" then
|
|
local result = Un.image_spawn(image_id, name, ports)
|
|
print("Service spawned from image")
|
|
print(json.encode(result))
|
|
elseif action == "clone" then
|
|
local result = Un.image_clone(image_id, name)
|
|
print("Image cloned")
|
|
print(json.encode(result))
|
|
else
|
|
io.stderr:write("Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID\n")
|
|
os.exit(1)
|
|
end
|
|
os.exit(0)
|
|
elseif arg[1] == "--help" or arg[1] == "-h" then
|
|
print("Usage: lua un.lua [options] <source_file>")
|
|
print(" lua un.lua languages [--json]")
|
|
print(" lua un.lua service [options]")
|
|
print(" lua un.lua image [options]")
|
|
print("")
|
|
print("Commands:")
|
|
print(" languages [--json] List available programming languages")
|
|
print(" service [options] Manage services")
|
|
print(" image [options] Manage images")
|
|
print("")
|
|
print("Languages options:")
|
|
print(" --json Output as JSON array")
|
|
print("")
|
|
print("Service options:")
|
|
print(" --list List all services")
|
|
print(" --info ID Get service details")
|
|
print(" --unfreeze-on-demand ID true|false Enable/disable auto-unfreeze on HTTP request")
|
|
print("")
|
|
print("Image options:")
|
|
print(" --list List all images")
|
|
print(" --info ID Get image details")
|
|
print(" --delete ID Delete an image")
|
|
print(" --lock ID Lock image to prevent deletion")
|
|
print(" --unlock ID Unlock image")
|
|
print(" --publish ID Publish image (requires --source-type)")
|
|
print(" --source-type TYPE Source type: service or snapshot")
|
|
print(" --visibility ID MODE Set visibility: private, unlisted, public")
|
|
print(" --spawn ID Spawn service from image")
|
|
print(" --clone ID Clone an image")
|
|
print(" --name NAME Name for spawned service or cloned image")
|
|
print(" --ports PORTS Ports for spawned service")
|
|
os.exit(0)
|
|
else
|
|
local result = Un.run(arg[1])
|
|
if result.stdout then print(result.stdout) end
|
|
if result.stderr then io.stderr:write(result.stderr) end
|
|
os.exit(result.exit_code or 0)
|
|
end
|
|
end
|
|
|
|
return Un
|