Compare commits

..

5 Commits

Author SHA1 Message Date
Regela 24eb878b29 Fix auth: add WWW-Authenticate header, middleware on all routes
1 day ago
Regela e6191124ed Resolve all medium tasks: auth, tests, cjson, module loading
1 day ago
Regela 5ccf92e4a2 Handle WebSocket backpressure: log lagged clients, increase buffer
1 day ago
Regela 0e29569770 Add module sandbox isolation and dynamic imgui window registry
1 day ago
Regela 50ad1e805c Remove all unwrap()/expect() from production Rust code
1 day ago

6
.gitignore vendored

@ -1 +1,7 @@
more_modules more_modules
env.fish
rust_core/target/
build/
*.so
*.log
rgl_data.db

@ -12,59 +12,13 @@ Prioritized backlog of issues, improvements, and feature ideas.
## High ## High
### Add proper .gitignore *No high issues currently.*
Current `.gitignore` only has `more_modules`. Missing:
- `rust_core/target/`
- `*.so`
- `rgl_data.db`
- `*.log`
- `.DS_Store`
### Create build script
No Makefile or build automation exists. Need a script for:
- Cross-compile Rust for aarch64-linux-android
- Deploy .so to device via ADB
- Optional: rebuild on file change
### Improve Rust error handling
50+ `unwrap()` calls across Rust codebase. Key areas:
- `bridge.rs`: mutex locks can panic on poisoning — use `unwrap_or_else()` with recovery
- `db.rs`: 30+ unwrap/unwrap_or_default — silent failures on DB errors
- `server.rs`: 15+ unwraps in HTTP handlers
Should introduce proper error types (thiserror crate) or at minimum `unwrap_or_else()` with logging.
### Silent JSON parse failures in db.rs
`execute_batch()` returns `"[]"` on JSON parse error without logging. Should log the error for debugging.
--- ---
## Medium ## Medium
### Fix BTC module global state *No medium issues currently.*
`more_modules/btc/init.lua` uses `btc_visible = false` as a global variable (for mimgui OnFrame). Should be moved to a proper module state mechanism to avoid global namespace pollution.
### Implement WebSocket backpressure
`bridge.rs` broadcast channel has capacity 256. Events are silently dropped when full. Should either increase capacity, add warning logging, or implement backpressure.
### Improve fallback JSON encoder
`rgl_framework.lua` fallback JSON decoder only handles flat `{"key":"value"}` — fails on nested objects, arrays, numbers, booleans. Since cjson is always expected to be present, consider making it a hard requirement instead of silently degrading.
### Add integration tests
Only `db.rs` has tests (6 batch operation tests). Missing:
- Bridge request/response cycle
- Event system overflow/blocking
- HTTP handler edge cases
- Module loading/unloading
- Win-1251 encoding conversion
### Module loading error handling
`load_all_modules()` uses `io.popen('ls ...')` which can fail if directory is deleted between listing and loading. Use `pcall(io.open)` instead.
### Add auth/CORS for web API
Currently any network client can call all APIs. Consider at minimum:
- Bind to localhost only (or configurable)
- Basic auth token
- CORS headers for web clients
--- ---
@ -76,9 +30,6 @@ No protection against API spam. Could add simple per-endpoint rate limits.
### Add request/response logging middleware ### Add request/response logging middleware
No HTTP access logging in Rust. Add optional access log for debugging API calls. No HTTP access logging in Rust. Add optional access log for debugging API calls.
### Optimize broadcast channel capacity
Current 256 capacity is arbitrary. Profile actual event rates and set appropriately.
### Add module versioning ### Add module versioning
No way to track which version of a module is loaded. Could add `M.version` field and display in admin panel. No way to track which version of a module is loaded. Could add `M.version` field and display in admin panel.

@ -1,55 +1,8 @@
-- Lua Console module — execute Lua code in the game's main thread -- Lua Console module — execute Lua code in the game's main thread
local M = {} local M = {}
function M.init(fw)
local static_dir = fw.modules_dir .. "/console/static"
fw.register_module("console", static_dir)
fw.on_api("console", "exec", function(body)
local ok, data = pcall(fw.json_decode, body)
if not ok or not data or not data.code then
return '{"ok":false,"error":"invalid body"}'
end
local code = data.code
if #code == 0 then
return '{"ok":false,"error":"empty code"}'
end
-- Try as expression first (return value), then as statement
local fn, err = loadstring("return " .. code)
if not fn then
fn, err = loadstring(code)
end
if not fn then
return fw.json_encode({ok = false, error = "Compile: " .. tostring(err)})
end
local results = {pcall(fn)}
local success = table.remove(results, 1)
if not success then
return fw.json_encode({ok = false, error = tostring(results[1])})
end
-- Format results
local parts = {}
for i, v in ipairs(results) do
parts[i] = serialize(v, 0)
end
local output = table.concat(parts, ", ")
if #parts == 0 then output = "nil" end
fw.log("INFO", "CONSOLE", "Exec: " .. code:sub(1, 80) .. " => " .. output:sub(1, 80))
return fw.json_encode({ok = true, result = output, count = #parts})
end)
fw.log("INFO", "CONSOLE", "Module loaded")
end
-- Serialize a Lua value into a readable string -- Serialize a Lua value into a readable string
function serialize(val, depth) local function serialize(val, depth)
depth = depth or 0 depth = depth or 0
if depth > 4 then return "..." end if depth > 4 then return "..." end
@ -96,4 +49,51 @@ function serialize(val, depth)
end end
end end
function M.init(fw)
local static_dir = fw.modules_dir .. "/console/static"
fw.register_module("console", static_dir)
fw.on_api("console", "exec", function(body)
local ok, data = pcall(fw.json_decode, body)
if not ok or not data or not data.code then
return '{"ok":false,"error":"invalid body"}'
end
local code = data.code
if #code == 0 then
return '{"ok":false,"error":"empty code"}'
end
-- Try as expression first (return value), then as statement
local fn, err = loadstring("return " .. code)
if not fn then
fn, err = loadstring(code)
end
if not fn then
return fw.json_encode({ok = false, error = "Compile: " .. tostring(err)})
end
local results = {pcall(fn)}
local success = table.remove(results, 1)
if not success then
return fw.json_encode({ok = false, error = tostring(results[1])})
end
-- Format results
local parts = {}
for i, v in ipairs(results) do
parts[i] = serialize(v, 0)
end
local output = table.concat(parts, ", ")
if #parts == 0 then output = "nil" end
fw.log("INFO", "CONSOLE", "Exec: " .. code:sub(1, 80) .. " => " .. output:sub(1, 80))
return fw.json_encode({ok = true, result = output, count = #parts})
end)
fw.log("INFO", "CONSOLE", "Module loaded")
end
return M return M

@ -24,6 +24,11 @@ ffi.cdef[[
unsigned int rgl_db_submit(const char* ops_json); unsigned int rgl_db_submit(const char* ops_json);
char* rgl_db_poll(unsigned int id); char* rgl_db_poll(unsigned int id);
void rgl_free(char* s); void rgl_free(char* s);
void rgl_auth_init(const char* secret_dir);
void rgl_auth_set(const char* login, const char* password);
void rgl_auth_clear();
void rgl_auth_reset();
int rgl_auth_enabled();
]] ]]
local rust = nil local rust = nil
@ -35,11 +40,13 @@ local module_states = {} -- persistent state per module for render()
local command_handlers = {} local command_handlers = {}
local framework = {} local framework = {}
local _current_module = nil local _current_module = nil
admin_visible = false local admin_visible = false
local admin_state = {}
local recent_logs = {} local recent_logs = {}
local MAX_LOGS = 100 local MAX_LOGS = 100
local notifications = {} -- {text, level, time, start} local notifications = {} -- {text, level, time, start}
local module_errors = {} -- ["module_name"] = "last error" local module_errors = {} -- ["module_name"] = "last error"
local module_windows = {} -- {name = {visible, title, width, height}}
local function log(level, tag, ...) local function log(level, tag, ...)
if not rust then return print("[" .. level .. "][" .. tag .. "]", ...) end if not rust then return print("[" .. level .. "][" .. tag .. "]", ...) end
@ -86,7 +93,10 @@ function main()
rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db") rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db")
log("INFO", "INIT", "DB initialized") log("INFO", "INIT", "DB initialized")
setup_framework() rust.rgl_auth_init(getWorkingDirectory())
log("INFO", "INIT", "Auth initialized")
if setup_framework() == false then return end
log("INFO", "INIT", "Framework ready") log("INFO", "INIT", "Framework ready")
register_admin() register_admin()
log("INFO", "INIT", "Admin registered") log("INFO", "INIT", "Admin registered")
@ -352,13 +362,12 @@ function setup_framework()
end end
local cjson_ok, cjson = pcall(require, "cjson") local cjson_ok, cjson = pcall(require, "cjson")
if cjson_ok then if not cjson_ok then
log("ERROR", "INIT", "cjson not found — framework cannot start")
return false
end
framework.json_encode = cjson.encode framework.json_encode = cjson.encode
framework.json_decode = cjson.decode framework.json_decode = cjson.decode
else
framework.json_decode = function(s) local t = {} for k,v in s:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do t[k]=v end return t end
framework.json_encode = function(t) local p = {} for k,v in pairs(t) do p[#p+1]='"'..k..'":"'..tostring(v)..'"' end return "{"..table.concat(p,",").."}" end
end
framework.rust = rust framework.rust = rust
framework.log = log framework.log = log
@ -503,6 +512,33 @@ function setup_framework()
rust.rgl_register_command(name, owner) rust.rgl_register_command(name, owner)
log("INFO", "CMD", "Registered /" .. name .. " (" .. owner .. ")") log("INFO", "CMD", "Registered /" .. name .. " (" .. owner .. ")")
end end
-- Module imgui window registry
framework.register_window = function(opts)
opts = opts or {}
local mod_name = _current_module or "__unknown"
module_windows[mod_name] = {
visible = false,
title = opts.title or mod_name,
width = opts.width or 450,
height = opts.height or 400,
}
end
framework.toggle_window = function(name)
local w = module_windows[name or _current_module]
if w then w.visible = not w.visible end
end
framework.show_window = function(name)
local w = module_windows[name or _current_module]
if w then w.visible = true end
end
framework.hide_window = function(name)
local w = module_windows[name or _current_module]
if w then w.visible = false end
end
end end
---------------------------------------------------------------- ----------------------------------------------------------------
@ -514,8 +550,15 @@ function load_single_module(name)
local f = io.open(path); if not f then return false, "not found" end; f:close() local f = io.open(path); if not f then return false, "not found" end; f:close()
_current_module = name _current_module = name
local ok, mod = pcall(dofile, path) local chunk, cerr = loadfile(path)
if not ok then _current_module = nil; return false, "load: " .. tostring(mod) end if not chunk then _current_module = nil; return false, "load: " .. tostring(cerr) end
-- Sandbox: module gets its own environment, reads fall through to _G
local sandbox = setmetatable({}, {__index = _G})
setfenv(chunk, sandbox)
local ok, mod = pcall(chunk)
if not ok then _current_module = nil; return false, "exec: " .. tostring(mod) end
if not mod or not mod.init then _current_module = nil; return false, "no init()" end if not mod or not mod.init then _current_module = nil; return false, "no init()" end
-- Create per-module framework wrapper with bound db prefix -- Create per-module framework wrapper with bound db prefix
@ -565,7 +608,7 @@ function load_single_module(name)
_current_module = nil _current_module = nil
if not iok then return false, "init: " .. tostring(ierr) end if not iok then return false, "init: " .. tostring(ierr) end
loaded_modules[name] = {mod = mod, status = "loaded"} loaded_modules[name] = {mod = mod, status = "loaded", sandbox = sandbox}
module_states[name] = module_states[name] or {} module_states[name] = module_states[name] or {}
register_module_render(name) register_module_render(name)
log("INFO", "MODS", "Loaded: " .. name) log("INFO", "MODS", "Loaded: " .. name)
@ -585,23 +628,38 @@ function unload_single_module(name)
event_interceptors[ev] = new event_interceptors[ev] = new
end end
rust.rgl_register_module(name, "") rust.rgl_register_module(name, "")
module_windows[name] = nil
loaded_modules[name] = nil loaded_modules[name] = nil
log("INFO", "MODS", "Unloaded: " .. name) log("INFO", "MODS", "Unloaded: " .. name)
return true return true
end end
function load_all_modules() -- List module directories safely (wraps io.popen in pcall)
function list_module_dirs()
local dir = framework.modules_dir local dir = framework.modules_dir
local ls = io.popen('ls "' .. dir .. '" 2>/dev/null') local result = {}
if not ls then return end local ok, ls = pcall(io.popen, 'ls "' .. dir .. '" 2>/dev/null')
if not ok or not ls then
log("WARN", "MODS", "Failed to list modules dir: " .. tostring(ls))
return result
end
for name in ls:lines() do for name in ls:lines() do
local path = dir .. "/" .. name .. "/init.lua" local path = dir .. "/" .. name .. "/init.lua"
local f = io.open(path) local f = io.open(path)
if f then f:close(); local ok,err = load_single_module(name) if f then
if not ok then log("ERROR", "MODS", name .. ": " .. err) end f:close()
result[#result + 1] = name
end end
end end
ls:close() ls:close()
return result
end
function load_all_modules()
for _, name in ipairs(list_module_dirs()) do
local ok, err = load_single_module(name)
if not ok then log("ERROR", "MODS", name .. ": " .. err) end
end
end end
---------------------------------------------------------------- ----------------------------------------------------------------
@ -642,23 +700,13 @@ function admin_render(ui, state)
end end
end end
ui.separator() ui.separator()
local dir = framework.modules_dir for _, name in ipairs(list_module_dirs()) do
local ls = io.popen('ls "' .. dir .. '" 2>/dev/null')
if ls then
for name in ls:lines() do
if not loaded_modules[name] then if not loaded_modules[name] then
local path = dir .. "/" .. name .. "/init.lua"
local f = io.open(path)
if f then
f:close()
ui.text_colored(0.5, 0.5, 0.5, 1, name) ui.text_colored(0.5, 0.5, 0.5, 1, name)
ui.sameline() ui.sameline()
if ui.button("Load##" .. name) then load_single_module(name) end if ui.button("Load##" .. name) then load_single_module(name) end
end end
end end
end
ls:close()
end
ui.tab_end() ui.tab_end()
end end
@ -720,6 +768,38 @@ function admin_render(ui, state)
ui.tab_end() ui.tab_end()
end end
if ui.tab_item("Auth") then
local auth_on = rust.rgl_auth_enabled() == 1
if auth_on then
ui.text_colored(0.3, 0.8, 0.3, 1, "Auth: ON")
ui.spacing()
if ui.button("Disable") then
rust.rgl_auth_clear()
log("INFO", "AUTH", "Auth disabled")
end
ui.sameline()
if ui.button("Reset") then
rust.rgl_auth_reset()
log("INFO", "AUTH", "Auth fully reset")
end
else
ui.text_colored(0.5, 0.5, 0.5, 1, "Auth: OFF")
end
ui.separator()
ui.text("Set credentials:")
state.auth_login = ui.input("Login", state.auth_login or "")
state.auth_pass = ui.input("Password", state.auth_pass or "")
if ui.button("Save") then
if #(state.auth_login or "") > 0 and #(state.auth_pass or "") > 0 then
rust.rgl_auth_set(state.auth_login, state.auth_pass)
log("INFO", "AUTH", "Credentials saved")
state.auth_login = ""
state.auth_pass = ""
end
end
ui.tab_end()
end
end end
end end
@ -731,7 +811,7 @@ function register_admin()
pcall(function() interactions = framework.json_decode(body) end) pcall(function() interactions = framework.json_decode(body) end)
end end
local ui = create_ui_builder(interactions) local ui = create_ui_builder(interactions)
admin_render(ui, {}) admin_render(ui, admin_state)
return framework.json_encode({widgets = ui._get_widgets()}) return framework.json_encode({widgets = ui._get_widgets()})
end, owner = "__admin"} end, owner = "__admin"}
@ -951,7 +1031,7 @@ if imgui_loaded and imgui then
local ui = create_ui_imgui() local ui = create_ui_imgui()
if ui then if ui then
local rok, rerr = pcall(admin_render, ui, {}) local rok, rerr = pcall(admin_render, ui, admin_state)
if not rok then if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:") imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr)) imgui.TextWrapped(tostring(rerr))
@ -964,20 +1044,30 @@ if imgui_loaded and imgui then
end end
) )
-- BTC module window (and any other module with render()) -- Dynamic module windows (registered via fw.register_window)
local btc_window = imgui.new.bool() local mod_window_bool = imgui.new.bool()
imgui.OnFrame( imgui.OnFrame(
function() return btc_visible end,
function() function()
btc_window[0] = true for _, w in pairs(module_windows) do
imgui.SetNextWindowSize(imgui.ImVec2(450 * dpi, 400 * dpi), imgui.Cond.FirstUseEver) if w.visible then return true end
imgui.Begin("BTC Miner", btc_window, imgui.WindowFlags.NoCollapse) end
return false
end,
function()
for name, w in pairs(module_windows) do
if w.visible then
mod_window_bool[0] = true
imgui.SetNextWindowSize(
imgui.ImVec2(w.width * dpi, w.height * dpi),
imgui.Cond.FirstUseEver
)
imgui.Begin(w.title, mod_window_bool, imgui.WindowFlags.NoCollapse)
local mod = loaded_modules and loaded_modules["btc"] local entry = loaded_modules and loaded_modules[name]
if mod and mod.mod and mod.mod.render then if entry and entry.mod and entry.mod.render then
local ui = create_ui_imgui() local ui = create_ui_imgui()
if ui then if ui then
local rok, rerr = pcall(mod.mod.render, ui, module_states["btc"] or {}) local rok, rerr = pcall(entry.mod.render, ui, module_states[name] or {})
if not rok then if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:") imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr)) imgui.TextWrapped(tostring(rerr))
@ -985,11 +1075,13 @@ if imgui_loaded and imgui then
imgui.EndTabBar() imgui.EndTabBar()
end end
else else
imgui.Text("BTC module not loaded") imgui.Text(name .. " module not loaded")
end end
imgui.End() imgui.End()
if not btc_window[0] then btc_visible = false end if not mod_window_bool[0] then w.visible = false end
end
end
end end
) )

@ -16,8 +16,10 @@ name = "arz-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"chrono", "chrono",
"encoding_rs", "encoding_rs",
"rand",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",

@ -15,6 +15,8 @@ encoding_rs = "0.8"
tower-http = { version = "0.6", features = ["fs"] } tower-http = { version = "0.6", features = ["fs"] }
tokio-rusqlite = { version = "0.7", features = ["bundled"] } tokio-rusqlite = { version = "0.7", features = ["bundled"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
rand = "0.9"
base64 = "0.22"
[profile.release] [profile.release]
lto = true lto = true

@ -0,0 +1,305 @@
//! Authentication — secret generation, credential storage, request verification.
//!
//! Secret is generated once and stored in a file outside the modules directory.
//! Credentials (login/password) are XOR-encrypted with the secret and stored in
//! a separate `auth` DB table that modules cannot access through the kv API.
use std::sync::{Mutex, OnceLock};
use rand::Rng;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::logging;
struct AuthState {
secret: String,
credentials: Option<(String, String)>,
secret_paths: Vec<String>,
}
static STATE: OnceLock<Mutex<AuthState>> = OnceLock::new();
fn state() -> &'static Mutex<AuthState> {
STATE.get_or_init(|| Mutex::new(AuthState {
secret: String::new(),
credentials: None,
secret_paths: Vec::new(),
}))
}
fn lock_state() -> std::sync::MutexGuard<'static, AuthState> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
/// Generate a 32-byte hex secret.
fn generate_secret() -> String {
let bytes: [u8; 32] = rand::rng().random();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// XOR encrypt/decrypt data with the secret (symmetric).
fn xor_with_secret(data: &str, secret: &str) -> String {
let secret_bytes = secret.as_bytes();
let encrypted: Vec<u8> = data.as_bytes().iter().enumerate()
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
.collect();
BASE64.encode(&encrypted)
}
/// XOR decrypt base64 data with the secret.
fn decrypt_with_secret(encoded: &str, secret: &str) -> Option<String> {
let encrypted = BASE64.decode(encoded).ok()?;
let secret_bytes = secret.as_bytes();
let decrypted: Vec<u8> = encrypted.iter().enumerate()
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
.collect();
String::from_utf8(decrypted).ok()
}
/// Initialize auth: load or generate secret, load credentials from DB.
pub async fn init(secret_paths: &[String], db_conn: &tokio_rusqlite::Connection) {
// Create auth table
if let Err(e) = db_conn.call(|conn| {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS auth (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await {
logging::log("ERROR", "AUTH", &format!("failed to create auth table: {e}"));
return;
}
// Load or generate secret
let secret = load_or_generate_secret(secret_paths).await;
logging::log("INFO", "AUTH", "secret ready");
// Load credentials from DB
let creds = db_conn.call(|conn| {
let login: Option<String> = conn.query_row(
"SELECT value FROM auth WHERE key = 'login'", [], |r| r.get(0),
).ok();
let password: Option<String> = conn.query_row(
"SELECT value FROM auth WHERE key = 'password'", [], |r| r.get(0),
).ok();
Ok::<_, tokio_rusqlite::rusqlite::Error>((login, password))
}).await.unwrap_or((None, None));
let mut s = lock_state();
s.secret_paths = secret_paths.to_vec();
if let (Some(enc_login), Some(enc_pass)) = creds {
if let (Some(login), Some(password)) = (
decrypt_with_secret(&enc_login, &secret),
decrypt_with_secret(&enc_pass, &secret),
) {
logging::log("INFO", "AUTH", &format!("credentials loaded for user '{login}'"));
s.credentials = Some((login, password));
} else {
logging::log("WARN", "AUTH", "credentials in DB couldn't be decrypted (secret changed?), auth disabled");
}
}
s.secret = secret;
}
async fn load_or_generate_secret(paths: &[String]) -> String {
for path in paths {
if let Ok(content) = tokio::fs::read_to_string(path).await {
let trimmed = content.trim().to_string();
if trimmed.len() == 64 {
logging::log("DEBUG", "AUTH", &format!("secret loaded from {path}"));
return trimmed;
}
}
}
let secret = generate_secret();
for path in paths {
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if tokio::fs::write(path, &secret).await.is_ok() {
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
return secret;
}
}
logging::log("WARN", "AUTH", "couldn't persist secret to any path, using ephemeral");
secret
}
/// Set credentials. Encrypts and stores in DB.
pub fn set_credentials(login: &str, password: &str) {
let (enc_login, enc_pass) = {
let mut s = lock_state();
if s.secret.is_empty() { return; }
let enc_login = xor_with_secret(login, &s.secret);
let enc_pass = xor_with_secret(password, &s.secret);
s.credentials = Some((login.to_string(), password.to_string()));
(enc_login, enc_pass)
};
if let Some(handle) = crate::server::runtime_handle() {
handle.spawn(async move {
if let Some(conn) = crate::db::get_connection() {
let _ = conn.call(move |conn| {
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('login', ?1)", [&enc_login])?;
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('password', ?1)", [&enc_pass])?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await;
logging::log("INFO", "AUTH", "credentials saved");
}
});
}
}
/// Clear credentials (disable auth, keep secret).
pub fn clear_credentials() {
lock_state().credentials = None;
if let Some(handle) = crate::server::runtime_handle() {
handle.spawn(async move {
if let Some(conn) = crate::db::get_connection() {
let _ = conn.call(|conn| {
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await;
logging::log("INFO", "AUTH", "credentials cleared");
}
});
}
}
/// Full reset: clear credentials + regenerate secret.
pub fn reset() {
let paths = {
let mut s = lock_state();
s.credentials = None;
s.secret = generate_secret();
let new_secret = s.secret.clone();
let paths = s.secret_paths.clone();
drop(s);
// Write new secret to file and clear DB
if let Some(handle) = crate::server::runtime_handle() {
let paths_clone = paths.clone();
handle.spawn(async move {
// Write new secret
for path in &paths_clone {
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if tokio::fs::write(path, &new_secret).await.is_ok() {
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
break;
}
}
// Clear credentials from DB
if let Some(conn) = crate::db::get_connection() {
let _ = conn.call(|conn| {
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await;
}
logging::log("INFO", "AUTH", "auth fully reset with new secret");
});
}
paths
};
let _ = paths; // suppress unused warning
}
/// Check if auth is enabled (credentials are set).
pub fn has_auth() -> bool {
lock_state().credentials.is_some()
}
/// Check an HTTP request's authorization.
pub fn check_auth(auth_header: Option<&str>) -> bool {
let s = lock_state();
let Some((ref login, ref password)) = s.credentials else {
return true; // no auth configured
};
let Some(header) = auth_header else {
return false;
};
// Bearer token (secret)
if let Some(token) = header.strip_prefix("Bearer ") {
return token == s.secret;
}
// Basic auth
if let Some(encoded) = header.strip_prefix("Basic ") {
if let Ok(decoded) = BASE64.decode(encoded) {
if let Ok(pair) = String::from_utf8(decoded) {
if let Some((u, p)) = pair.split_once(':') {
return u == login && p == password;
}
}
}
}
false
}
/// Get the secret token (for external integrations).
pub fn get_secret() -> String {
lock_state().secret.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xor_roundtrip() {
let secret = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let data = "hello world";
let encrypted = xor_with_secret(data, secret);
let decrypted = decrypt_with_secret(&encrypted, secret).unwrap();
assert_eq!(decrypted, data);
}
#[test]
fn test_generate_secret_length() {
let secret = generate_secret();
assert_eq!(secret.len(), 64);
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_auth_flow() {
// Setup
{
let mut s = lock_state();
s.secret = "a".repeat(64);
s.credentials = Some(("admin".to_string(), "pass123".to_string()));
}
// Valid basic auth
let encoded = BASE64.encode("admin:pass123");
assert!(check_auth(Some(&format!("Basic {encoded}"))));
// Wrong password
let wrong = BASE64.encode("admin:wrong");
assert!(!check_auth(Some(&format!("Basic {wrong}"))));
// No header
assert!(!check_auth(None));
// Bearer with secret
let secret = get_secret();
assert!(check_auth(Some(&format!("Bearer {secret}"))));
// Clean up
lock_state().credentials = None;
}
#[test]
fn test_no_auth_allows_all() {
lock_state().credentials = None;
assert!(check_auth(None));
assert!(check_auth(Some("garbage")));
}
}

@ -10,7 +10,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{ use std::sync::{
atomic::{AtomicU32, Ordering}, atomic::{AtomicU32, Ordering},
Condvar, Mutex, OnceLock, Condvar, Mutex, MutexGuard, OnceLock,
}; };
use crate::logging; use crate::logging;
@ -56,18 +56,23 @@ fn state() -> &'static BridgeState {
}) })
} }
/// Lock a mutex, recovering from poison if needed.
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|e| {
logging::log("WARN", "BRIDGE", "mutex was poisoned, recovering");
e.into_inner()
})
}
/// Initialize the event broadcast channel. Called once from server::start. /// Initialize the event broadcast channel. Called once from server::start.
pub fn init_event_channel() -> tokio::sync::broadcast::Receiver<EventMessage> { pub fn init_event_channel() -> tokio::sync::broadcast::Receiver<EventMessage> {
let (tx, rx) = tokio::sync::broadcast::channel(256); let (tx, rx) = tokio::sync::broadcast::channel(1024);
*state().event_tx.lock().unwrap() = Some(tx); *lock_or_recover(&state().event_tx) = Some(tx);
rx rx
} }
pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> { pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> {
state() lock_or_recover(&state().event_tx)
.event_tx
.lock()
.unwrap()
.as_ref() .as_ref()
.map(|tx| tx.subscribe()) .map(|tx| tx.subscribe())
} }
@ -77,7 +82,7 @@ pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessag
/// In the future, cancelable events will return a response. /// In the future, cancelable events will return a response.
pub fn push_event(event_name: &str, json_args: &str) -> Option<String> { pub fn push_event(event_name: &str, json_args: &str) -> Option<String> {
let s = state(); let s = state();
if let Some(tx) = s.event_tx.lock().unwrap().as_ref() { if let Some(tx) = lock_or_recover(&s.event_tx).as_ref() {
let _ = tx.send(EventMessage { let _ = tx.send(EventMessage {
event: event_name.to_string(), event: event_name.to_string(),
args: json_args.to_string(), args: json_args.to_string(),
@ -91,14 +96,14 @@ pub fn request_lua_exec(code: String) -> u32 {
let s = state(); let s = state();
let id = s.next_id.fetch_add(1, Ordering::Relaxed); let id = s.next_id.fetch_add(1, Ordering::Relaxed);
logging::log("DEBUG", "BRIDGE", &format!("request id={id} code={}", &code[..code.len().min(80)])); logging::log("DEBUG", "BRIDGE", &format!("request id={id} code={}", &code[..code.len().min(80)]));
s.pending_requests.lock().unwrap().push(LuaRequest { id, code }); lock_or_recover(&s.pending_requests).push(LuaRequest { id, code });
id id
} }
/// Wait for a result of a previously queued request (blocking, with timeout). /// Wait for a result of a previously queued request (blocking, with timeout).
pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Option<String> { pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Option<String> {
let s = state(); let s = state();
let mut results = s.results.lock().unwrap(); let mut results = lock_or_recover(&s.results);
let deadline = std::time::Instant::now() + timeout; let deadline = std::time::Instant::now() + timeout;
loop { loop {
if let Some(result) = results.remove(&id) { if let Some(result) = results.remove(&id) {
@ -108,18 +113,26 @@ pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Opti
if remaining.is_zero() { if remaining.is_zero() {
return None; return None;
} }
let (guard, timeout_result) = s.results_ready.wait_timeout(results, remaining).unwrap(); match s.results_ready.wait_timeout(results, remaining) {
Ok((guard, timeout_result)) => {
results = guard; results = guard;
if timeout_result.timed_out() { if timeout_result.timed_out() {
return results.remove(&id); return results.remove(&id);
} }
} }
Err(e) => {
logging::log("WARN", "BRIDGE", "condvar poisoned, recovering");
results = e.into_inner().0;
return results.remove(&id);
}
}
}
} }
/// Poll for pending requests (called from Lua main loop, must be fast). /// Poll for pending requests (called from Lua main loop, must be fast).
pub fn poll_requests() -> Option<String> { pub fn poll_requests() -> Option<String> {
let s = state(); let s = state();
let mut pending = s.pending_requests.lock().unwrap(); let mut pending = lock_or_recover(&s.pending_requests);
if pending.is_empty() { if pending.is_empty() {
return None; return None;
} }
@ -130,15 +143,87 @@ pub fn poll_requests() -> Option<String> {
/// Non-blocking check for a result (used by async polling in api_handler). /// Non-blocking check for a result (used by async polling in api_handler).
pub fn try_get_result(id: u32) -> Option<String> { pub fn try_get_result(id: u32) -> Option<String> {
state().results.lock().unwrap().remove(&id) lock_or_recover(&state().results).remove(&id)
} }
/// Report result from Lua execution (called from Lua main loop). /// Report result from Lua execution (called from Lua main loop).
pub fn respond(request_id: u32, result: &str) { pub fn respond(request_id: u32, result: &str) {
let s = state(); let s = state();
s.results lock_or_recover(&s.results).insert(request_id, result.to_string());
.lock()
.unwrap()
.insert(request_id, result.to_string());
s.results_ready.notify_all(); s.results_ready.notify_all();
} }
#[cfg(test)]
mod tests {
use super::*;
// Note: bridge tests share global state (static BRIDGE).
// Each test must be self-contained — don't assume poll_requests is empty.
#[test]
fn test_request_response_cycle() {
let id = request_lua_exec("return 42".to_string());
assert!(id > 0);
// Respond directly (poll may race with other tests in shared state)
respond(id, "42");
assert_eq!(try_get_result(id), Some("42".to_string()));
// Result consumed
assert!(try_get_result(id).is_none());
}
#[test]
fn test_poll_returns_pending() {
let id = request_lua_exec("test_poll_code".to_string());
// Immediately poll — should find at least our request
let json = poll_requests();
assert!(json.is_some());
// Clean up
while poll_requests().is_some() {}
respond(id, "done");
try_get_result(id);
}
#[test]
fn test_multiple_requests_unique_ids() {
let id1 = request_lua_exec("code1".to_string());
let id2 = request_lua_exec("code2".to_string());
let id3 = request_lua_exec("code3".to_string());
assert_ne!(id1, id2);
assert_ne!(id2, id3);
// Drain all pending
while poll_requests().is_some() {}
// Respond out of order
respond(id3, "r3");
respond(id1, "r1");
respond(id2, "r2");
assert_eq!(try_get_result(id1), Some("r1".to_string()));
assert_eq!(try_get_result(id2), Some("r2".to_string()));
assert_eq!(try_get_result(id3), Some("r3".to_string()));
}
#[test]
fn test_sync_wait_timeout() {
let id = request_lua_exec("never_responds".to_string());
while poll_requests().is_some() {} // drain
let result = request_lua_exec_sync_wait(id, std::time::Duration::from_millis(50));
assert!(result.is_none());
}
#[test]
fn test_event_broadcast() {
let _ = init_event_channel();
let mut rx = subscribe_events().expect("should get receiver");
push_event("test_event", "[1,2,3]");
let msg = rx.try_recv().expect("should receive event");
assert_eq!(msg.event, "test_event");
assert_eq!(msg.args, "[1,2,3]");
}
}

@ -14,6 +14,11 @@ static DB: OnceLock<Connection> = OnceLock::new();
static BATCH_RESULTS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new(); static BATCH_RESULTS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1); static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1);
/// Get a reference to the DB connection (for auth module).
pub fn get_connection() -> Option<&'static Connection> {
DB.get()
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Init // Init
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@ -21,14 +26,23 @@ static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1);
/// Init — opens DB and creates table. /// Init — opens DB and creates table.
/// Must be called after tokio runtime is available (after rgl_start). /// Must be called after tokio runtime is available (after rgl_start).
pub fn init(path: &str) { pub fn init(path: &str) {
let handle = server::runtime_handle().expect("tokio runtime not started"); let Some(handle) = server::runtime_handle() else {
logging::log("ERROR", "DB", "init failed: tokio runtime not started");
return;
};
let path = path.to_string(); let path = path.to_string();
// Use a oneshot channel to get the Connection back from the tokio task // Use a oneshot channel to get the Connection back from the tokio task
let (tx, rx) = std::sync::mpsc::sync_channel(1); let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move { handle.spawn(async move {
let conn = Connection::open(&path).await.expect("Failed to open DB"); let conn = match Connection::open(&path).await {
conn.call(|conn| { Ok(c) => c,
Err(e) => {
logging::log("ERROR", "DB", &format!("failed to open {path}: {e}"));
return;
}
};
if let Err(e) = conn.call(|conn| {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS kv ( "CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@ -36,12 +50,19 @@ pub fn init(path: &str) {
)" )"
)?; )?;
Ok::<_, rusqlite::Error>(()) Ok::<_, rusqlite::Error>(())
}).await.expect("Failed to create kv table"); }).await {
logging::log("ERROR", "DB", &format!("failed to create kv table: {e}"));
return;
}
tx.send(conn).ok(); tx.send(conn).ok();
}); });
let conn = rx.recv().expect("Failed to receive DB connection"); match rx.recv() {
DB.set(conn).ok(); Ok(conn) => { DB.set(conn).ok(); }
Err(e) => {
logging::log("ERROR", "DB", &format!("failed to receive connection: {e}"));
}
}
BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok(); BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok();
} }
@ -130,7 +151,9 @@ pub fn submit_batch(ops_json: &str) -> u32 {
handle.spawn(async move { handle.spawn(async move {
let result = execute_batch_async(&ops).await; let result = execute_batch_async(&ops).await;
if let Some(results) = BATCH_RESULTS.get() { if let Some(results) = BATCH_RESULTS.get() {
results.lock().unwrap().insert(id, result); if let Ok(mut map) = results.lock() {
map.insert(id, result);
}
} }
}); });
} }

@ -74,3 +74,81 @@ pub fn parse_samp_colors(text: &str) -> String {
} }
result result
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_win1251_ascii() {
let input = b"Hello, world!";
assert_eq!(win1251_to_utf8(input), "Hello, world!");
}
#[test]
fn test_win1251_cyrillic_roundtrip() {
let utf8 = "Привет мир";
let win1251 = utf8_to_win1251(utf8);
let back = win1251_to_utf8(&win1251);
assert_eq!(back, utf8);
}
#[test]
fn test_win1251_empty() {
assert_eq!(win1251_to_utf8(b""), "");
assert_eq!(utf8_to_win1251(""), Vec::<u8>::new());
}
#[test]
fn test_color_to_css_red() {
assert_eq!(samp_color_to_css(0xFF000000_u32 as i64), "rgba(255,0,0,1.00)");
}
#[test]
fn test_color_to_css_green_alpha() {
// 0x00FF00AA = green with alpha 170
assert_eq!(samp_color_to_css(0x00FF00AA_u32 as i64), "rgba(0,255,0,0.67)");
}
#[test]
fn test_color_to_css_zero_alpha_becomes_opaque() {
// Alpha 0 is treated as 255 (fully opaque)
assert_eq!(samp_color_to_css(0xFF000000_u32 as i64), "rgba(255,0,0,1.00)");
}
#[test]
fn test_parse_colors_simple() {
let result = parse_samp_colors("Hello {FF0000}world");
assert_eq!(result, "Hello <span style=\"color:#FF0000\">world</span>");
}
#[test]
fn test_parse_colors_multiple() {
let result = parse_samp_colors("{FF0000}Red{00FF00}Green");
assert_eq!(result, "<span style=\"color:#FF0000\">Red</span><span style=\"color:#00FF00\">Green</span>");
}
#[test]
fn test_parse_colors_html_escape() {
let result = parse_samp_colors("<script>alert('xss')</script>");
assert!(result.contains("&lt;script&gt;"));
assert!(!result.contains("<script>"));
}
#[test]
fn test_parse_colors_invalid_code() {
// Incomplete hex — should be output as-is
let result = parse_samp_colors("{GGGG}text");
assert!(result.contains("{GG"));
}
#[test]
fn test_parse_colors_empty() {
assert_eq!(parse_samp_colors(""), "");
}
#[test]
fn test_parse_colors_no_codes() {
assert_eq!(parse_samp_colors("plain text"), "plain text");
}
}

@ -3,6 +3,7 @@ mod bridge;
mod events; mod events;
mod logging; mod logging;
mod db; mod db;
mod auth;
use std::ffi::{c_char, c_int, CStr, CString}; use std::ffi::{c_char, c_int, CStr, CString};
@ -162,6 +163,51 @@ pub extern "C" fn rgl_free(s: *mut c_char) {
} }
} }
// --- Auth ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_init(secret_dir: *const c_char) {
let dir = unsafe { CStr::from_ptr(secret_dir) }.to_str().unwrap_or("");
let paths = vec![
format!("/data/data/com.arizona.game.git/{dir}/rgl_secret"),
format!("/sdcard/Android/data/com.arizona.game.git/{dir}/rgl_secret"),
format!("{dir}/rgl_secret"),
];
if let Some(handle) = server::runtime_handle() {
if let Some(conn) = db::get_connection() {
let conn = conn.clone();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
auth::init(&paths, &conn).await;
tx.send(()).ok();
});
let _ = rx.recv(); // wait for init to complete
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_set(login: *const c_char, password: *const c_char) {
let login = unsafe { CStr::from_ptr(login) }.to_str().unwrap_or("");
let password = unsafe { CStr::from_ptr(password) }.to_str().unwrap_or("");
auth::set_credentials(login, password);
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_clear() {
auth::clear_credentials();
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_reset() {
auth::reset();
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_enabled() -> c_int {
if auth::has_auth() { 1 } else { 0 }
}
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn rgl_hello() -> c_int { pub extern "C" fn rgl_hello() -> c_int {
42 42

@ -23,7 +23,9 @@ pub fn init(path: &str) {
.append(true) .append(true)
.open(path) .open(path)
.ok(); .ok();
*log_file().lock().unwrap() = file; if let Ok(mut guard) = log_file().lock() {
*guard = file;
}
} }
/// Write a log entry — to file + broadcast to WS. /// Write a log entry — to file + broadcast to WS.

@ -1,18 +1,18 @@
//! Axum HTTP/WS server — admin UI is built-in, modules are Lua-side. //! Axum HTTP/WS server — admin UI is built-in, modules are Lua-side.
use axum::{ use axum::{
body::Body, extract::{Path, Request, ws::{Message, WebSocket, WebSocketUpgrade}},
extract::{Path, ws::{Message, WebSocket, WebSocketUpgrade}},
http::{header, StatusCode}, http::{header, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, MutexGuard, OnceLock};
use std::collections::HashMap; use std::collections::HashMap;
use crate::bridge; use crate::{auth, bridge};
use crate::logging; use crate::logging;
const BUILD_TS: &str = match option_env!("ARZ_BUILD_TS") { const BUILD_TS: &str = match option_env!("ARZ_BUILD_TS") {
@ -39,30 +39,35 @@ fn module_dirs() -> &'static Mutex<HashMap<String, String>> {
MODULE_DIRS.get_or_init(|| Mutex::new(HashMap::new())) MODULE_DIRS.get_or_init(|| Mutex::new(HashMap::new()))
} }
/// Lock a mutex, recovering from poison if needed.
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn register_module(name: &str, static_dir: &str) { pub fn register_module(name: &str, static_dir: &str) {
if static_dir.is_empty() { if static_dir.is_empty() {
module_dirs().lock().unwrap().remove(name); lock_or_recover(module_dirs()).remove(name);
} else { } else {
module_dirs().lock().unwrap().insert(name.to_string(), static_dir.to_string()); lock_or_recover(module_dirs()).insert(name.to_string(), static_dir.to_string());
} }
} }
pub fn unregister_module(name: &str) { pub fn unregister_module(name: &str) {
module_dirs().lock().unwrap().remove(name); lock_or_recover(module_dirs()).remove(name);
} }
pub fn list_modules() -> Vec<String> { pub fn list_modules() -> Vec<String> {
module_dirs().lock().unwrap().keys().cloned().collect() lock_or_recover(module_dirs()).keys().cloned().collect()
} }
/// Check if a module has a static/index.html registered /// Check if a module has a static/index.html registered
pub fn module_has_static(name: &str) -> bool { pub fn module_has_static(name: &str) -> bool {
let dirs = module_dirs().lock().unwrap(); let dirs = lock_or_recover(module_dirs());
dirs.get(name).map(|d| !d.is_empty()).unwrap_or(false) dirs.get(name).map(|d| !d.is_empty()).unwrap_or(false)
} }
pub fn register_command(name: &str, owner: &str) { pub fn register_command(name: &str, owner: &str) {
let mut cmds = commands().lock().unwrap(); let mut cmds = lock_or_recover(commands());
// Avoid duplicates // Avoid duplicates
if !cmds.iter().any(|(n, _)| n == name) { if !cmds.iter().any(|(n, _)| n == name) {
cmds.push((name.to_string(), owner.to_string())); cmds.push((name.to_string(), owner.to_string()));
@ -70,13 +75,55 @@ pub fn register_command(name: &str, owner: &str) {
} }
pub fn get_commands_json() -> String { pub fn get_commands_json() -> String {
let cmds = commands().lock().unwrap(); let cmds = lock_or_recover(commands());
let items: Vec<String> = cmds.iter() let items: Vec<String> = cmds.iter()
.map(|(n, o)| format!(r#"{{"name":"{}","owner":"{}"}}"#, n, o)) .map(|(n, o)| format!(r#"{{"name":"{}","owner":"{}"}}"#, n, o))
.collect(); .collect();
format!("[{}]", items.join(",")) format!("[{}]", items.join(","))
} }
// --- Auth middleware ---
fn unauthorized_response() -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"ARZ Web Helper\"")
.body(axum::body::Body::from("Unauthorized"))
.unwrap_or_else(|_| (StatusCode::UNAUTHORIZED, "Unauthorized").into_response())
}
async fn auth_middleware(request: Request, next: Next) -> Response {
if !auth::has_auth() {
return next.run(request).await;
}
// WebSocket upgrade: check ?token= query param
let uri = request.uri().clone();
if uri.path() == "/ws" {
let query = uri.query().unwrap_or("");
let token = query.split('&')
.find_map(|p| p.strip_prefix("token="));
if let Some(t) = token {
if auth::check_auth(Some(&format!("Bearer {t}"))) {
return next.run(request).await;
}
}
}
// Check Authorization header
let auth_header = request.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
if auth::check_auth(auth_header) {
next.run(request).await
} else {
unauthorized_response()
}
}
// --- Server start/stop ---
pub fn start(port: u16) -> Result<(), String> { pub fn start(port: u16) -> Result<(), String> {
let _initial_rx = bridge::init_event_channel(); let _initial_rx = bridge::init_event_channel();
@ -95,7 +142,7 @@ pub fn start(port: u16) -> Result<(), String> {
}; };
RT_HANDLE.set(rt.handle().clone()).ok(); RT_HANDLE.set(rt.handle().clone()).ok();
rt_tx.send(true).ok(); // Signal that runtime is ready rt_tx.send(true).ok();
rt.block_on(async move { rt.block_on(async move {
let app = Router::new() let app = Router::new()
@ -105,7 +152,8 @@ pub fn start(port: u16) -> Result<(), String> {
.route("/api/modules", get(modules_list_handler)) .route("/api/modules", get(modules_list_handler))
.route("/api/commands", get(commands_list_handler)) .route("/api/commands", get(commands_list_handler))
.route("/api/{module}/{action}", post(api_handler)) .route("/api/{module}/{action}", post(api_handler))
.fallback(static_file_handler); .fallback(static_file_handler)
.layer(middleware::from_fn(auth_middleware));
let addr = format!("0.0.0.0:{port}"); let addr = format!("0.0.0.0:{port}");
let socket = match tokio::net::TcpSocket::new_v4() { let socket = match tokio::net::TcpSocket::new_v4() {
@ -113,7 +161,13 @@ pub fn start(port: u16) -> Result<(), String> {
Err(_) => return, Err(_) => return,
}; };
socket.set_reuseaddr(true).ok(); socket.set_reuseaddr(true).ok();
let bind_addr: std::net::SocketAddr = addr.parse().unwrap(); let bind_addr: std::net::SocketAddr = match addr.parse() {
Ok(a) => a,
Err(e) => {
logging::log("ERROR", "SERVER", &format!("invalid bind address: {e}"));
return;
}
};
if socket.bind(bind_addr).is_err() { return; } if socket.bind(bind_addr).is_err() { return; }
if let Ok(listener) = socket.listen(128) { if let Ok(listener) = socket.listen(128) {
SHUTDOWN.store(false, AtomicOrdering::Relaxed); SHUTDOWN.store(false, AtomicOrdering::Relaxed);
@ -129,7 +183,6 @@ pub fn start(port: u16) -> Result<(), String> {
}) })
.map_err(|e| format!("thread spawn error: {e}"))?; .map_err(|e| format!("thread spawn error: {e}"))?;
// Wait for tokio runtime to be ready before returning
match rt_rx.recv() { match rt_rx.recv() {
Ok(true) => Ok(()), Ok(true) => Ok(()),
_ => Err("runtime init failed".to_string()), _ => Err("runtime init failed".to_string()),
@ -157,10 +210,7 @@ async fn admin_handler() -> Html<String> {
async fn commands_list_handler() -> impl IntoResponse { async fn commands_list_handler() -> impl IntoResponse {
let json = get_commands_json(); let json = get_commands_json();
Response::builder() ([(header::CONTENT_TYPE, "application/json")], json).into_response()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json))
.unwrap()
} }
// --- API --- // --- API ---
@ -181,7 +231,6 @@ async fn api_handler(
); );
let id = bridge::request_lua_exec(code); let id = bridge::request_lua_exec(code);
// Poll for result with async timeout — never blocks tokio workers
let result = tokio::time::timeout( let result = tokio::time::timeout(
std::time::Duration::from_secs(2), std::time::Duration::from_secs(2),
async { async {
@ -195,16 +244,10 @@ async fn api_handler(
).await; ).await;
match result { match result {
Ok(r) => Response::builder() Ok(r) => ([(header::CONTENT_TYPE, "application/json")], r).into_response(),
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r))
.unwrap(),
Err(_) => { Err(_) => {
logging::log("WARN", "API", &format!("timeout: {module}/{action}")); logging::log("WARN", "API", &format!("timeout: {module}/{action}"));
Response::builder() (StatusCode::GATEWAY_TIMEOUT, r#"{"error":"lua timeout"}"#).into_response()
.status(StatusCode::GATEWAY_TIMEOUT)
.body(Body::from(r#"{"error":"lua timeout"}"#))
.unwrap()
} }
} }
} }
@ -223,10 +266,9 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
}; };
let base_dir = { let base_dir = {
let dirs = module_dirs().lock().unwrap(); let dirs = lock_or_recover(module_dirs());
match dirs.get(module) { match dirs.get(module) {
Some(d) if d == "__render__" => { Some(d) if d == "__render__" => {
// Module uses render() API — serve auto-UI page
let html = include_str!("../static/ui_page.html") let html = include_str!("../static/ui_page.html")
.replace("{{MODULE}}", module) .replace("{{MODULE}}", module)
.replace("{{TITLE}}", module); .replace("{{TITLE}}", module);
@ -268,7 +310,10 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
// --- WebSocket --- // --- WebSocket ---
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse { async fn ws_handler(
ws: WebSocketUpgrade,
) -> impl IntoResponse {
// Auth already checked by middleware (including ?token= for WS)
ws.on_upgrade(handle_ws) ws.on_upgrade(handle_ws)
} }
@ -282,7 +327,9 @@ async fn handle_ws(mut socket: WebSocket) {
loop { loop {
tokio::select! { tokio::select! {
Ok(event) = event_rx.recv() => { result = event_rx.recv() => {
match result {
Ok(event) => {
let json = serde_json::json!({ let json = serde_json::json!({
"type": "event", "type": "event",
"event": event.event, "event": event.event,
@ -293,6 +340,13 @@ async fn handle_ws(mut socket: WebSocket) {
break; break;
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
logging::log("WARN", "WS", &format!("client lagged, {n} events dropped"));
continue;
}
Err(_) => break,
}
}
msg = socket.recv() => { msg = socket.recv() => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {

@ -74,6 +74,10 @@ let interactions = {};
async function render() { async function render() {
try { try {
// Collect current input values before sending
uiEl.querySelectorAll('input[data-wid]').forEach(inp => {
if (inp.type === 'text') interactions[inp.dataset.wid] = inp.value;
});
const res = await fetch('/api/' + MODULE + '/__render', { const res = await fetch('/api/' + MODULE + '/__render', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -162,7 +166,8 @@ function createWidget(w) {
const inp = document.createElement('input'); const inp = document.createElement('input');
inp.type = 'text'; inp.type = 'text';
inp.value = w.value || ''; inp.value = w.value || '';
inp.onchange = () => { interactions[w.id] = inp.value; render(); }; inp.dataset.wid = w.id;
inp.oninput = () => { interactions[w.id] = inp.value; };
d.appendChild(lbl); d.appendChild(lbl);
d.appendChild(inp); d.appendChild(inp);
return d; return d;

Loading…
Cancel
Save