Compare commits

..

No commits in common. '24eb878b299fd9f5bbd39b214350f3f4526558cb' and 'e92bbdb62cf0437b6a930a50c4070f25dddf9f09' have entirely different histories.

6
.gitignore vendored

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

@ -12,13 +12,59 @@ Prioritized backlog of issues, improvements, and feature ideas.
## High
*No high issues currently.*
### 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.
---
## Medium
*No medium issues currently.*
### 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
---
@ -30,6 +76,9 @@ 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,54 +1,6 @@
-- Lua Console module — execute Lua code in the game's main thread
local M = {}
-- Serialize a Lua value into a readable string
local function serialize(val, depth)
depth = depth or 0
if depth > 4 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "string" then
if #val > 200 then
return '"' .. val:sub(1, 200):gsub('[\n\r]', '\\n') .. '..." [' .. #val .. ' chars]'
end
return '"' .. val:gsub('[\n\r]', '\\n'):gsub('"', '\\"') .. '"'
elseif t == "number" or t == "boolean" then
return tostring(val)
elseif t == "table" then
local items = {}
local n = 0
-- Array part
for i, v in ipairs(val) do
if n >= 20 then
items[#items + 1] = "... +" .. (#val - n) .. " more"
break
end
items[#items + 1] = serialize(v, depth + 1)
n = n + 1
end
-- Hash part
for k, v in pairs(val) do
if type(k) ~= "number" or k < 1 or k > #val or k ~= math.floor(k) then
if n >= 20 then
items[#items + 1] = "..."
break
end
local ks = type(k) == "string" and k or "[" .. tostring(k) .. "]"
items[#items + 1] = ks .. " = " .. serialize(v, depth + 1)
n = n + 1
end
end
if #items == 0 then return "{}" end
return "{ " .. table.concat(items, ", ") .. " }"
elseif t == "function" then
return "function: " .. tostring(val)
else
return tostring(val)
end
end
function M.init(fw)
local static_dir = fw.modules_dir .. "/console/static"
fw.register_module("console", static_dir)
@ -96,4 +48,52 @@ function M.init(fw)
fw.log("INFO", "CONSOLE", "Module loaded")
end
-- Serialize a Lua value into a readable string
function serialize(val, depth)
depth = depth or 0
if depth > 4 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "string" then
if #val > 200 then
return '"' .. val:sub(1, 200):gsub('[\n\r]', '\\n') .. '..." [' .. #val .. ' chars]'
end
return '"' .. val:gsub('[\n\r]', '\\n'):gsub('"', '\\"') .. '"'
elseif t == "number" or t == "boolean" then
return tostring(val)
elseif t == "table" then
local items = {}
local n = 0
-- Array part
for i, v in ipairs(val) do
if n >= 20 then
items[#items + 1] = "... +" .. (#val - n) .. " more"
break
end
items[#items + 1] = serialize(v, depth + 1)
n = n + 1
end
-- Hash part
for k, v in pairs(val) do
if type(k) ~= "number" or k < 1 or k > #val or k ~= math.floor(k) then
if n >= 20 then
items[#items + 1] = "..."
break
end
local ks = type(k) == "string" and k or "[" .. tostring(k) .. "]"
items[#items + 1] = ks .. " = " .. serialize(v, depth + 1)
n = n + 1
end
end
if #items == 0 then return "{}" end
return "{ " .. table.concat(items, ", ") .. " }"
elseif t == "function" then
return "function: " .. tostring(val)
else
return tostring(val)
end
end
return M

@ -24,11 +24,6 @@ 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
@ -40,13 +35,11 @@ local module_states = {} -- persistent state per module for render()
local command_handlers = {}
local framework = {}
local _current_module = nil
local admin_visible = false
local admin_state = {}
admin_visible = false
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
@ -93,10 +86,7 @@ function main()
rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db")
log("INFO", "INIT", "DB initialized")
rust.rgl_auth_init(getWorkingDirectory())
log("INFO", "INIT", "Auth initialized")
if setup_framework() == false then return end
setup_framework()
log("INFO", "INIT", "Framework ready")
register_admin()
log("INFO", "INIT", "Admin registered")
@ -362,12 +352,13 @@ function setup_framework()
end
local cjson_ok, cjson = pcall(require, "cjson")
if not cjson_ok then
log("ERROR", "INIT", "cjson not found — framework cannot start")
return false
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
end
framework.json_encode = cjson.encode
framework.json_decode = cjson.decode
framework.rust = rust
framework.log = log
@ -512,33 +503,6 @@ 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
----------------------------------------------------------------
@ -550,15 +514,8 @@ 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 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
local ok, mod = pcall(dofile, path)
if not ok then _current_module = nil; return false, "load: " .. 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
@ -608,7 +565,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", sandbox = sandbox}
loaded_modules[name] = {mod = mod, status = "loaded"}
module_states[name] = module_states[name] or {}
register_module_render(name)
log("INFO", "MODS", "Loaded: " .. name)
@ -628,38 +585,23 @@ 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
-- List module directories safely (wraps io.popen in pcall)
function list_module_dirs()
function load_all_modules()
local dir = framework.modules_dir
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
local ls = io.popen('ls "' .. dir .. '" 2>/dev/null')
if not ls then return end
for name in ls:lines() do
local path = dir .. "/" .. name .. "/init.lua"
local f = io.open(path)
if f then
f:close()
result[#result + 1] = name
if f then f:close(); local ok,err = load_single_module(name)
if not ok then log("ERROR", "MODS", name .. ": " .. err) end
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
----------------------------------------------------------------
@ -700,12 +642,22 @@ function admin_render(ui, state)
end
end
ui.separator()
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
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
end
ls:close()
end
ui.tab_end()
end
@ -768,38 +720,6 @@ 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
@ -811,7 +731,7 @@ function register_admin()
pcall(function() interactions = framework.json_decode(body) end)
end
local ui = create_ui_builder(interactions)
admin_render(ui, admin_state)
admin_render(ui, {})
return framework.json_encode({widgets = ui._get_widgets()})
end, owner = "__admin"}
@ -1031,7 +951,7 @@ if imgui_loaded and imgui then
local ui = create_ui_imgui()
if ui then
local rok, rerr = pcall(admin_render, ui, admin_state)
local rok, rerr = pcall(admin_render, ui, {})
if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr))
@ -1044,44 +964,32 @@ if imgui_loaded and imgui then
end
)
-- Dynamic module windows (registered via fw.register_window)
local mod_window_bool = imgui.new.bool()
-- BTC module window (and any other module with render())
local btc_window = imgui.new.bool()
imgui.OnFrame(
function() return btc_visible end,
function()
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")
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))
end
imgui.End()
if not mod_window_bool[0] then w.visible = false end
imgui.EndTabBar()
end
else
imgui.Text("BTC module not loaded")
end
imgui.End()
if not btc_window[0] then btc_visible = false end
end
)

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

@ -15,8 +15,6 @@ 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

@ -1,305 +0,0 @@
//! 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, MutexGuard, OnceLock,
Condvar, Mutex, OnceLock,
};
use crate::logging;
@ -56,23 +56,18 @@ 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(1024);
*lock_or_recover(&state().event_tx) = Some(tx);
let (tx, rx) = tokio::sync::broadcast::channel(256);
*state().event_tx.lock().unwrap() = Some(tx);
rx
}
pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> {
lock_or_recover(&state().event_tx)
state()
.event_tx
.lock()
.unwrap()
.as_ref()
.map(|tx| tx.subscribe())
}
@ -82,7 +77,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) = lock_or_recover(&s.event_tx).as_ref() {
if let Some(tx) = s.event_tx.lock().unwrap().as_ref() {
let _ = tx.send(EventMessage {
event: event_name.to_string(),
args: json_args.to_string(),
@ -96,14 +91,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)]));
lock_or_recover(&s.pending_requests).push(LuaRequest { id, code });
s.pending_requests.lock().unwrap().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 = lock_or_recover(&s.results);
let mut results = s.results.lock().unwrap();
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(result) = results.remove(&id) {
@ -113,18 +108,10 @@ pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Opti
if remaining.is_zero() {
return None;
}
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);
}
let (guard, timeout_result) = s.results_ready.wait_timeout(results, remaining).unwrap();
results = guard;
if timeout_result.timed_out() {
return results.remove(&id);
}
}
}
@ -132,7 +119,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 = lock_or_recover(&s.pending_requests);
let mut pending = s.pending_requests.lock().unwrap();
if pending.is_empty() {
return None;
}
@ -143,87 +130,15 @@ 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> {
lock_or_recover(&state().results).remove(&id)
state().results.lock().unwrap().remove(&id)
}
/// Report result from Lua execution (called from Lua main loop).
pub fn respond(request_id: u32, result: &str) {
let s = state();
lock_or_recover(&s.results).insert(request_id, result.to_string());
s.results
.lock()
.unwrap()
.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,11 +14,6 @@ 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
// ----------------------------------------------------------------
@ -26,23 +21,14 @@ pub fn get_connection() -> Option<&'static Connection> {
/// Init — opens DB and creates table.
/// Must be called after tokio runtime is available (after rgl_start).
pub fn init(path: &str) {
let Some(handle) = server::runtime_handle() else {
logging::log("ERROR", "DB", "init failed: tokio runtime not started");
return;
};
let handle = server::runtime_handle().expect("tokio runtime not started");
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 = 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| {
let conn = Connection::open(&path).await.expect("Failed to open DB");
conn.call(|conn| {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
@ -50,19 +36,12 @@ pub fn init(path: &str) {
)"
)?;
Ok::<_, rusqlite::Error>(())
}).await {
logging::log("ERROR", "DB", &format!("failed to create kv table: {e}"));
return;
}
}).await.expect("Failed to create kv table");
tx.send(conn).ok();
});
match rx.recv() {
Ok(conn) => { DB.set(conn).ok(); }
Err(e) => {
logging::log("ERROR", "DB", &format!("failed to receive connection: {e}"));
}
}
let conn = rx.recv().expect("Failed to receive DB connection");
DB.set(conn).ok();
BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok();
}
@ -151,9 +130,7 @@ 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() {
if let Ok(mut map) = results.lock() {
map.insert(id, result);
}
results.lock().unwrap().insert(id, result);
}
});
}

@ -74,81 +74,3 @@ 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,7 +3,6 @@ mod bridge;
mod events;
mod logging;
mod db;
mod auth;
use std::ffi::{c_char, c_int, CStr, CString};
@ -163,51 +162,6 @@ 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,9 +23,7 @@ pub fn init(path: &str) {
.append(true)
.open(path)
.ok();
if let Ok(mut guard) = log_file().lock() {
*guard = file;
}
*log_file().lock().unwrap() = 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::{
extract::{Path, Request, ws::{Message, WebSocket, WebSocketUpgrade}},
body::Body,
extract::{Path, 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, MutexGuard, OnceLock};
use std::sync::{Mutex, OnceLock};
use std::collections::HashMap;
use crate::{auth, bridge};
use crate::bridge;
use crate::logging;
const BUILD_TS: &str = match option_env!("ARZ_BUILD_TS") {
@ -39,35 +39,30 @@ 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() {
lock_or_recover(module_dirs()).remove(name);
module_dirs().lock().unwrap().remove(name);
} else {
lock_or_recover(module_dirs()).insert(name.to_string(), static_dir.to_string());
module_dirs().lock().unwrap().insert(name.to_string(), static_dir.to_string());
}
}
pub fn unregister_module(name: &str) {
lock_or_recover(module_dirs()).remove(name);
module_dirs().lock().unwrap().remove(name);
}
pub fn list_modules() -> Vec<String> {
lock_or_recover(module_dirs()).keys().cloned().collect()
module_dirs().lock().unwrap().keys().cloned().collect()
}
/// Check if a module has a static/index.html registered
pub fn module_has_static(name: &str) -> bool {
let dirs = lock_or_recover(module_dirs());
let dirs = module_dirs().lock().unwrap();
dirs.get(name).map(|d| !d.is_empty()).unwrap_or(false)
}
pub fn register_command(name: &str, owner: &str) {
let mut cmds = lock_or_recover(commands());
let mut cmds = commands().lock().unwrap();
// Avoid duplicates
if !cmds.iter().any(|(n, _)| n == name) {
cmds.push((name.to_string(), owner.to_string()));
@ -75,55 +70,13 @@ pub fn register_command(name: &str, owner: &str) {
}
pub fn get_commands_json() -> String {
let cmds = lock_or_recover(commands());
let cmds = commands().lock().unwrap();
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();
@ -142,7 +95,7 @@ pub fn start(port: u16) -> Result<(), String> {
};
RT_HANDLE.set(rt.handle().clone()).ok();
rt_tx.send(true).ok();
rt_tx.send(true).ok(); // Signal that runtime is ready
rt.block_on(async move {
let app = Router::new()
@ -152,8 +105,7 @@ 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)
.layer(middleware::from_fn(auth_middleware));
.fallback(static_file_handler);
let addr = format!("0.0.0.0:{port}");
let socket = match tokio::net::TcpSocket::new_v4() {
@ -161,13 +113,7 @@ pub fn start(port: u16) -> Result<(), String> {
Err(_) => return,
};
socket.set_reuseaddr(true).ok();
let bind_addr: std::net::SocketAddr = match addr.parse() {
Ok(a) => a,
Err(e) => {
logging::log("ERROR", "SERVER", &format!("invalid bind address: {e}"));
return;
}
};
let bind_addr: std::net::SocketAddr = addr.parse().unwrap();
if socket.bind(bind_addr).is_err() { return; }
if let Ok(listener) = socket.listen(128) {
SHUTDOWN.store(false, AtomicOrdering::Relaxed);
@ -183,6 +129,7 @@ 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()),
@ -210,7 +157,10 @@ async fn admin_handler() -> Html<String> {
async fn commands_list_handler() -> impl IntoResponse {
let json = get_commands_json();
([(header::CONTENT_TYPE, "application/json")], json).into_response()
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json))
.unwrap()
}
// --- API ---
@ -231,6 +181,7 @@ 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 {
@ -244,10 +195,16 @@ async fn api_handler(
).await;
match result {
Ok(r) => ([(header::CONTENT_TYPE, "application/json")], r).into_response(),
Ok(r) => Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r))
.unwrap(),
Err(_) => {
logging::log("WARN", "API", &format!("timeout: {module}/{action}"));
(StatusCode::GATEWAY_TIMEOUT, r#"{"error":"lua timeout"}"#).into_response()
Response::builder()
.status(StatusCode::GATEWAY_TIMEOUT)
.body(Body::from(r#"{"error":"lua timeout"}"#))
.unwrap()
}
}
}
@ -266,9 +223,10 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
};
let base_dir = {
let dirs = lock_or_recover(module_dirs());
let dirs = module_dirs().lock().unwrap();
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);
@ -310,10 +268,7 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
// --- WebSocket ---
async fn ws_handler(
ws: WebSocketUpgrade,
) -> impl IntoResponse {
// Auth already checked by middleware (including ?token= for WS)
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_ws)
}
@ -327,24 +282,15 @@ async fn handle_ws(mut socket: WebSocket) {
loop {
tokio::select! {
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,
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;
}
}
msg = socket.recv() => {

@ -74,10 +74,6 @@ 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'},
@ -166,8 +162,7 @@ function createWidget(w) {
const inp = document.createElement('input');
inp.type = 'text';
inp.value = w.value || '';
inp.dataset.wid = w.id;
inp.oninput = () => { interactions[w.id] = inp.value; };
inp.onchange = () => { interactions[w.id] = inp.value; render(); };
d.appendChild(lbl);
d.appendChild(inp);
return d;

Loading…
Cancel
Save