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
env.fish
rust_core/target/
build/
*.so
*.log
rgl_data.db

@ -12,59 +12,13 @@ Prioritized backlog of issues, improvements, and feature ideas.
## High
### Add proper .gitignore
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.
*No high issues currently.*
---
## Medium
### Fix BTC module global state
`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
*No medium issues currently.*
---
@ -76,9 +30,6 @@ No protection against API spam. Could add simple per-endpoint rate limits.
### Add request/response logging middleware
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
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
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
function serialize(val, depth)
local function serialize(val, depth)
depth = depth or 0
if depth > 4 then return "..." end
@ -96,4 +49,51 @@ function serialize(val, depth)
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

@ -24,6 +24,11 @@ ffi.cdef[[
unsigned int rgl_db_submit(const char* ops_json);
char* rgl_db_poll(unsigned int id);
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
@ -35,11 +40,13 @@ local module_states = {} -- persistent state per module for render()
local command_handlers = {}
local framework = {}
local _current_module = nil
admin_visible = false
local admin_visible = false
local admin_state = {}
local recent_logs = {}
local MAX_LOGS = 100
local notifications = {} -- {text, level, time, start}
local module_errors = {} -- ["module_name"] = "last error"
local module_windows = {} -- {name = {visible, title, width, height}}
local function log(level, tag, ...)
if not rust then return print("[" .. level .. "][" .. tag .. "]", ...) end
@ -86,7 +93,10 @@ function main()
rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db")
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")
register_admin()
log("INFO", "INIT", "Admin registered")
@ -352,13 +362,12 @@ function setup_framework()
end
local cjson_ok, cjson = pcall(require, "cjson")
if cjson_ok then
framework.json_encode = cjson.encode
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
if not cjson_ok then
log("ERROR", "INIT", "cjson not found — framework cannot start")
return false
end
framework.json_encode = cjson.encode
framework.json_decode = cjson.decode
framework.rust = rust
framework.log = log
@ -503,6 +512,33 @@ function setup_framework()
rust.rgl_register_command(name, owner)
log("INFO", "CMD", "Registered /" .. name .. " (" .. owner .. ")")
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
----------------------------------------------------------------
@ -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()
_current_module = name
local ok, mod = pcall(dofile, path)
if not ok then _current_module = nil; return false, "load: " .. tostring(mod) end
local chunk, cerr = loadfile(path)
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
-- Create per-module framework wrapper with bound db prefix
@ -565,7 +608,7 @@ function load_single_module(name)
_current_module = nil
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 {}
register_module_render(name)
log("INFO", "MODS", "Loaded: " .. name)
@ -585,23 +628,38 @@ function unload_single_module(name)
event_interceptors[ev] = new
end
rust.rgl_register_module(name, "")
module_windows[name] = nil
loaded_modules[name] = nil
log("INFO", "MODS", "Unloaded: " .. name)
return true
end
function load_all_modules()
-- List module directories safely (wraps io.popen in pcall)
function list_module_dirs()
local dir = framework.modules_dir
local ls = io.popen('ls "' .. dir .. '" 2>/dev/null')
if not ls then return end
local result = {}
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
local path = dir .. "/" .. name .. "/init.lua"
local f = io.open(path)
if f then f:close(); local ok,err = load_single_module(name)
if not ok then log("ERROR", "MODS", name .. ": " .. err) end
if f then
f:close()
result[#result + 1] = name
end
end
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
----------------------------------------------------------------
@ -642,22 +700,12 @@ function admin_render(ui, state)
end
end
ui.separator()
local dir = framework.modules_dir
local ls = io.popen('ls "' .. dir .. '" 2>/dev/null')
if ls then
for name in ls:lines() do
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.sameline()
if ui.button("Load##" .. name) then load_single_module(name) end
end
end
for _, name in ipairs(list_module_dirs()) do
if not loaded_modules[name] then
ui.text_colored(0.5, 0.5, 0.5, 1, name)
ui.sameline()
if ui.button("Load##" .. name) then load_single_module(name) end
end
ls:close()
end
ui.tab_end()
end
@ -720,6 +768,38 @@ function admin_render(ui, state)
ui.tab_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
@ -731,7 +811,7 @@ function register_admin()
pcall(function() interactions = framework.json_decode(body) end)
end
local ui = create_ui_builder(interactions)
admin_render(ui, {})
admin_render(ui, admin_state)
return framework.json_encode({widgets = ui._get_widgets()})
end, owner = "__admin"}
@ -951,7 +1031,7 @@ if imgui_loaded and imgui then
local ui = create_ui_imgui()
if ui then
local rok, rerr = pcall(admin_render, ui, {})
local rok, rerr = pcall(admin_render, ui, admin_state)
if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr))
@ -964,32 +1044,44 @@ if imgui_loaded and imgui then
end
)
-- BTC module window (and any other module with render())
local btc_window = imgui.new.bool()
-- Dynamic module windows (registered via fw.register_window)
local mod_window_bool = imgui.new.bool()
imgui.OnFrame(
function() return btc_visible end,
function()
btc_window[0] = true
imgui.SetNextWindowSize(imgui.ImVec2(450 * dpi, 400 * dpi), imgui.Cond.FirstUseEver)
imgui.Begin("BTC Miner", btc_window, imgui.WindowFlags.NoCollapse)
local mod = loaded_modules and loaded_modules["btc"]
if mod and mod.mod and mod.mod.render then
local ui = create_ui_imgui()
if ui then
local rok, rerr = pcall(mod.mod.render, ui, module_states["btc"] or {})
if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr))
for _, w in pairs(module_windows) do
if w.visible then return true end
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 entry = loaded_modules and loaded_modules[name]
if entry and entry.mod and entry.mod.render then
local ui = create_ui_imgui()
if ui then
local rok, rerr = pcall(entry.mod.render, ui, module_states[name] or {})
if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr))
end
imgui.EndTabBar()
end
else
imgui.Text(name .. " module not loaded")
end
imgui.EndTabBar()
imgui.End()
if not mod_window_bool[0] then w.visible = false end
end
else
imgui.Text("BTC module not loaded")
end
imgui.End()
if not btc_window[0] then btc_visible = false end
end
)

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

@ -15,6 +15,8 @@ encoding_rs = "0.8"
tower-http = { version = "0.6", features = ["fs"] }
tokio-rusqlite = { version = "0.7", features = ["bundled"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
rand = "0.9"
base64 = "0.22"
[profile.release]
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::sync::{
atomic::{AtomicU32, Ordering},
Condvar, Mutex, OnceLock,
Condvar, Mutex, MutexGuard, OnceLock,
};
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.
pub fn init_event_channel() -> tokio::sync::broadcast::Receiver<EventMessage> {
let (tx, rx) = tokio::sync::broadcast::channel(256);
*state().event_tx.lock().unwrap() = Some(tx);
let (tx, rx) = tokio::sync::broadcast::channel(1024);
*lock_or_recover(&state().event_tx) = Some(tx);
rx
}
pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> {
state()
.event_tx
.lock()
.unwrap()
lock_or_recover(&state().event_tx)
.as_ref()
.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.
pub fn push_event(event_name: &str, json_args: &str) -> Option<String> {
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 {
event: event_name.to_string(),
args: json_args.to_string(),
@ -91,14 +96,14 @@ pub fn request_lua_exec(code: String) -> u32 {
let s = state();
let id = s.next_id.fetch_add(1, Ordering::Relaxed);
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
}
/// 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> {
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;
loop {
if let Some(result) = results.remove(&id) {
@ -108,10 +113,18 @@ pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Opti
if remaining.is_zero() {
return None;
}
let (guard, timeout_result) = s.results_ready.wait_timeout(results, remaining).unwrap();
results = guard;
if timeout_result.timed_out() {
return results.remove(&id);
match s.results_ready.wait_timeout(results, remaining) {
Ok((guard, timeout_result)) => {
results = guard;
if timeout_result.timed_out() {
return results.remove(&id);
}
}
Err(e) => {
logging::log("WARN", "BRIDGE", "condvar poisoned, recovering");
results = e.into_inner().0;
return results.remove(&id);
}
}
}
}
@ -119,7 +132,7 @@ pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Opti
/// Poll for pending requests (called from Lua main loop, must be fast).
pub fn poll_requests() -> Option<String> {
let s = state();
let mut pending = s.pending_requests.lock().unwrap();
let mut pending = lock_or_recover(&s.pending_requests);
if pending.is_empty() {
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).
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).
pub fn respond(request_id: u32, result: &str) {
let s = state();
s.results
.lock()
.unwrap()
.insert(request_id, result.to_string());
lock_or_recover(&s.results).insert(request_id, result.to_string());
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_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
// ----------------------------------------------------------------
@ -21,14 +26,23 @@ static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1);
/// Init — opens DB and creates table.
/// Must be called after tokio runtime is available (after rgl_start).
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();
// Use a oneshot channel to get the Connection back from the tokio task
let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
let conn = Connection::open(&path).await.expect("Failed to open DB");
conn.call(|conn| {
let conn = match Connection::open(&path).await {
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(
"CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
@ -36,12 +50,19 @@ pub fn init(path: &str) {
)"
)?;
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();
});
let conn = rx.recv().expect("Failed to receive DB connection");
DB.set(conn).ok();
match rx.recv() {
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();
}
@ -130,7 +151,9 @@ pub fn submit_batch(ops_json: &str) -> u32 {
handle.spawn(async move {
let result = execute_batch_async(&ops).await;
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
}
#[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 logging;
mod db;
mod auth;
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)]
pub extern "C" fn rgl_hello() -> c_int {
42

@ -23,7 +23,9 @@ pub fn init(path: &str) {
.append(true)
.open(path)
.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.

@ -1,18 +1,18 @@
//! Axum HTTP/WS server — admin UI is built-in, modules are Lua-side.
use axum::{
body::Body,
extract::{Path, ws::{Message, WebSocket, WebSocketUpgrade}},
extract::{Path, Request, ws::{Message, WebSocket, WebSocketUpgrade}},
http::{header, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::{get, post},
Router,
};
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Mutex, OnceLock};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::collections::HashMap;
use crate::bridge;
use crate::{auth, bridge};
use crate::logging;
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()))
}
/// 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) {
if static_dir.is_empty() {
module_dirs().lock().unwrap().remove(name);
lock_or_recover(module_dirs()).remove(name);
} 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) {
module_dirs().lock().unwrap().remove(name);
lock_or_recover(module_dirs()).remove(name);
}
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
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)
}
pub fn register_command(name: &str, owner: &str) {
let mut cmds = commands().lock().unwrap();
let mut cmds = lock_or_recover(commands());
// Avoid duplicates
if !cmds.iter().any(|(n, _)| n == name) {
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 {
let cmds = commands().lock().unwrap();
let cmds = lock_or_recover(commands());
let items: Vec<String> = cmds.iter()
.map(|(n, o)| format!(r#"{{"name":"{}","owner":"{}"}}"#, n, o))
.collect();
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> {
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_tx.send(true).ok(); // Signal that runtime is ready
rt_tx.send(true).ok();
rt.block_on(async move {
let app = Router::new()
@ -105,7 +152,8 @@ pub fn start(port: u16) -> Result<(), String> {
.route("/api/modules", get(modules_list_handler))
.route("/api/commands", get(commands_list_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 socket = match tokio::net::TcpSocket::new_v4() {
@ -113,7 +161,13 @@ pub fn start(port: u16) -> Result<(), String> {
Err(_) => return,
};
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 let Ok(listener) = socket.listen(128) {
SHUTDOWN.store(false, AtomicOrdering::Relaxed);
@ -129,7 +183,6 @@ pub fn start(port: u16) -> Result<(), String> {
})
.map_err(|e| format!("thread spawn error: {e}"))?;
// Wait for tokio runtime to be ready before returning
match rt_rx.recv() {
Ok(true) => Ok(()),
_ => Err("runtime init failed".to_string()),
@ -157,10 +210,7 @@ async fn admin_handler() -> Html<String> {
async fn commands_list_handler() -> impl IntoResponse {
let json = get_commands_json();
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json))
.unwrap()
([(header::CONTENT_TYPE, "application/json")], json).into_response()
}
// --- API ---
@ -181,7 +231,6 @@ async fn api_handler(
);
let id = bridge::request_lua_exec(code);
// Poll for result with async timeout — never blocks tokio workers
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
async {
@ -195,16 +244,10 @@ async fn api_handler(
).await;
match result {
Ok(r) => Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r))
.unwrap(),
Ok(r) => ([(header::CONTENT_TYPE, "application/json")], r).into_response(),
Err(_) => {
logging::log("WARN", "API", &format!("timeout: {module}/{action}"));
Response::builder()
.status(StatusCode::GATEWAY_TIMEOUT)
.body(Body::from(r#"{"error":"lua timeout"}"#))
.unwrap()
(StatusCode::GATEWAY_TIMEOUT, r#"{"error":"lua timeout"}"#).into_response()
}
}
}
@ -223,10 +266,9 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
};
let base_dir = {
let dirs = module_dirs().lock().unwrap();
let dirs = lock_or_recover(module_dirs());
match dirs.get(module) {
Some(d) if d == "__render__" => {
// Module uses render() API — serve auto-UI page
let html = include_str!("../static/ui_page.html")
.replace("{{MODULE}}", module)
.replace("{{TITLE}}", module);
@ -268,7 +310,10 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
// --- 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)
}
@ -282,15 +327,24 @@ async fn handle_ws(mut socket: WebSocket) {
loop {
tokio::select! {
Ok(event) = event_rx.recv() => {
let json = serde_json::json!({
"type": "event",
"event": event.event,
"args": serde_json::from_str::<serde_json::Value>(&event.args)
.unwrap_or(serde_json::Value::Null),
});
if socket.send(Message::Text(json.to_string().into())).await.is_err() {
break;
result = event_rx.recv() => {
match result {
Ok(event) => {
let json = serde_json::json!({
"type": "event",
"event": event.event,
"args": serde_json::from_str::<serde_json::Value>(&event.args)
.unwrap_or(serde_json::Value::Null),
});
if socket.send(Message::Text(json.to_string().into())).await.is_err() {
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() => {

@ -74,6 +74,10 @@ let interactions = {};
async function render() {
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', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@ -162,7 +166,8 @@ function createWidget(w) {
const inp = document.createElement('input');
inp.type = 'text';
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(inp);
return d;

Loading…
Cancel
Save